Skip to content

Add JSpecify annotations to language package nodes - redo (12 more)#4257

Open
dondonz wants to merge 9 commits intomasterfrom
add-jspecify-redo
Open

Add JSpecify annotations to language package nodes - redo (12 more)#4257
dondonz wants to merge 9 commits intomasterfrom
add-jspecify-redo

Conversation

@dondonz
Copy link
Member

@dondonz dondonz commented Feb 22, 2026

Summary

Adds JSpecify nullability annotations (@NullMarked, @NullUnmarked, @Nullable) to the following graphql.language classes and interfaces, removing each from the JSpecifyAnnotationsCheck exemption list:

  • NodeTraverser
  • NonNullType
  • ObjectField
  • ObjectTypeDefinition / ObjectTypeExtensionDefinition
  • OperationDefinition
  • OperationTypeDefinition
  • PrettyAstPrinter
  • SDLDefinition / SDLExtensionDefinition
  • SDLNamedDefinition / TypeDefinition

Key decisions

Key interesting point - NamedNode.getName() is @Nullable
OperationDefinition can be anonymous (e.g. { user { name } }) — the GraphQL spec makes the operation name optional. Pushing @Nullable onto NamedNode.getName() is the truthful representation of the interface contract.

TypeDefinition.getName() overrides as non-null
TypeDefinition extends both SDLNamedDefinition (non-null getName()) and NamedNode (@Nullable getName()). SDL type definitions always have a name, so TypeDefinition explicitly overrides getName() to return non-null. This prevents @Nullable from cascading into callers like TypeDefinitionRegistry that work with concrete schema types.

OperationDefinition.selectionSet is non-null
Legacy convenience constructors that passed null for selectionSet were deleted (they were only used in one test). The field, constructor, and getSelectionSet() are all non-null, matching the GraphQL spec requirement that every operation has a selection set.

OperationDefinition.operation is @Nullable
The shorthand query form (e.g. { user { name } }) omits the query keyword, so getOperation() can legitimately return null.

Builder inner classes are @NullUnmarked
All builder static inner classes are annotated @NullUnmarked rather than annotating each field individually.

PrettyAstPrinter private helpers
node(@Nullable Class startClass), isEmpty(@Nullable ...), nvl(@Nullable ...), block(... @Nullable String separatorSingleLine, @Nullable String whenEmpty), and spaced(@Nullable String... args) / join(String, @Nullable String...) are nullable because null is actually passed at the call sites or because the method bodies already handle null gracefully.

Cascading fixes

  • OperationValidator: getName() is now @Nullable on OperationDefinition, so error message construction uses a pre-checked local variable or Objects.toString(x, "") fallback for the anonymous operation case.
  • TypeDefinitionRegistry: two Assert.assertNotNull(t.getName(), ...) workarounds are removed now that TypeDefinition.getName() is non-null.

🤖 Generated with Claude Code

@github-actions
Copy link
Contributor

github-actions bot commented Feb 22, 2026

Test Results

  335 files  ±0    335 suites  ±0   5m 8s ⏱️ +3s
5 376 tests ±0  5 367 ✅ ±0  9 💤 ±0  0 ❌ ±0 
5 465 runs  ±0  5 456 ✅ ±0  9 💤 ±0  0 ❌ ±0 

Results for commit 1063251. ± Comparison against base commit 50c22c1.

This pull request removes 196 and adds 172 tests. Note that renamed tests count towards both.
	?

	, expected: combo-\"\\\b\f\n\r\t, #4]
                __schema { types { fields { args { type { name fields { name }}}}}}
                __schema { types { fields { type { name fields { name }}}}}
                __schema { types { inputFields { type { inputFields { name }}}}}
                __schema { types { interfaces { fields { type { interfaces { name } } } } } }
                __schema { types { name} }
                __type(name : "t") { name }
                a1: __schema { types { name} }
                a1: __type(name : "t") { name }
…
graphql.AssertTest ‑ assertFalse with different number of error args but false does not throw assertions [toRun: <graphql.AssertTest$__spock_feature_0_21prov0_closure23@296e281a delegate=inaccessible owner=inaccessible thisObject=inaccessible resolveStrategy=inaccessible directive=inaccessible parameterTypes=inaccessible maximumNumberOfParameters=inaccessible bcw=inaccessible thisType=inaccessible>, expectedMessage: error arg1, #0]
graphql.AssertTest ‑ assertFalse with different number of error args but false does not throw assertions [toRun: <graphql.AssertTest$__spock_feature_0_21prov0_closure24@59cda16e delegate=inaccessible owner=inaccessible thisObject=inaccessible resolveStrategy=inaccessible directive=inaccessible parameterTypes=inaccessible maximumNumberOfParameters=inaccessible bcw=inaccessible thisType=inaccessible>, expectedMessage: error arg1 arg2, #1]
graphql.AssertTest ‑ assertFalse with different number of error args but false does not throw assertions [toRun: <graphql.AssertTest$__spock_feature_0_21prov0_closure25@5dd903be delegate=inaccessible owner=inaccessible thisObject=inaccessible resolveStrategy=inaccessible directive=inaccessible parameterTypes=inaccessible maximumNumberOfParameters=inaccessible bcw=inaccessible thisType=inaccessible>, expectedMessage: error arg1 arg2 arg3, #2]
graphql.AssertTest ‑ assertFalse with different number of error args throws assertions [toRun: <graphql.AssertTest$__spock_feature_0_20prov0_closure20@70887727 delegate=inaccessible owner=inaccessible thisObject=inaccessible resolveStrategy=inaccessible directive=inaccessible parameterTypes=inaccessible maximumNumberOfParameters=inaccessible bcw=inaccessible thisType=inaccessible>, expectedMessage: error arg1, #0]
graphql.AssertTest ‑ assertFalse with different number of error args throws assertions [toRun: <graphql.AssertTest$__spock_feature_0_20prov0_closure21@56da7487 delegate=inaccessible owner=inaccessible thisObject=inaccessible resolveStrategy=inaccessible directive=inaccessible parameterTypes=inaccessible maximumNumberOfParameters=inaccessible bcw=inaccessible thisType=inaccessible>, expectedMessage: error arg1 arg2, #1]
graphql.AssertTest ‑ assertFalse with different number of error args throws assertions [toRun: <graphql.AssertTest$__spock_feature_0_20prov0_closure22@599e4d41 delegate=inaccessible owner=inaccessible thisObject=inaccessible resolveStrategy=inaccessible directive=inaccessible parameterTypes=inaccessible maximumNumberOfParameters=inaccessible bcw=inaccessible thisType=inaccessible>, expectedMessage: error arg1 arg2 arg3, #2]
graphql.AssertTest ‑ assertNotNull with different number of  error args throws assertions [toRun: <graphql.AssertTest$__spock_feature_0_5prov0_closure3@fab35b1 delegate=inaccessible owner=inaccessible thisObject=inaccessible resolveStrategy=inaccessible directive=inaccessible parameterTypes=inaccessible maximumNumberOfParameters=inaccessible bcw=inaccessible thisType=inaccessible>, expectedMessage: error arg1, #0]
graphql.AssertTest ‑ assertNotNull with different number of  error args throws assertions [toRun: <graphql.AssertTest$__spock_feature_0_5prov0_closure4@5c77ba8f delegate=inaccessible owner=inaccessible thisObject=inaccessible resolveStrategy=inaccessible directive=inaccessible parameterTypes=inaccessible maximumNumberOfParameters=inaccessible bcw=inaccessible thisType=inaccessible>, expectedMessage: error arg1 arg2, #1]
graphql.AssertTest ‑ assertNotNull with different number of  error args throws assertions [toRun: <graphql.AssertTest$__spock_feature_0_5prov0_closure5@4a734c04 delegate=inaccessible owner=inaccessible thisObject=inaccessible resolveStrategy=inaccessible directive=inaccessible parameterTypes=inaccessible maximumNumberOfParameters=inaccessible bcw=inaccessible thisType=inaccessible>, expectedMessage: error arg1 arg2 arg3, #2]
graphql.AssertTest ‑ assertNotNull with different number of error args with non null does not throw assertions [toRun: <graphql.AssertTest$__spock_feature_0_6prov0_closure6@10a0fe30 delegate=inaccessible owner=inaccessible thisObject=inaccessible resolveStrategy=inaccessible directive=inaccessible parameterTypes=inaccessible maximumNumberOfParameters=inaccessible bcw=inaccessible thisType=inaccessible>, expectedMessage: error arg1, #0]
…

♻️ This comment has been updated with latest results.

@dondonz
Copy link
Member Author

dondonz commented Feb 22, 2026

This is a redo of a previous PR that went off the rails

- **Do not remove interfaces** from public classes (e.g., if a class implements `NamedNode`, it must continue to do so).
- **Be extremely careful when changing methods to return `@Nullable`**. If an interface contract (or widespread ecosystem usage) expects a non-null return value, changing it to `@Nullable` is a breaking change that will cause compilation errors or `NullPointerException`s for callers. For example, if a method returned `null` implicitly but its interface requires non-null, you must honor the non-null contract (e.g., returning an empty string or default value instead of `null`).
- **Do not change the binary signature** of methods or constructors in a way that breaks backwards compatibility.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous attempt randomly deleted a constructor parameter which caused that PR to go off the rails. Let's fix that here

* @return the name of this node, or null if this node is anonymous (e.g. an anonymous operation definition)
*/
String getName();
@Nullable String getName();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has to be nullable because an operation definition might not have a name

this.selectionSet = selectionSet;
}

public OperationDefinition(String name,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These constructors date back to 2018, no longer needed with the builders we have today.

I am removing these lines because these parameters should be not-nullable.

I would usually first deprecate the methods, then remove, but if I keep them here with a deprecated annotation, the nullability annotations will not be correct.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is technically a breaking change

@dondonz dondonz added the breaking change requires a new major version to be relased label Feb 22, 2026
@dondonz dondonz changed the title Add JSpecify annotations to language package nodes (batch 3) Add JSpecify annotations to language package nodes - redo Feb 22, 2026
@dondonz dondonz marked this pull request as draft February 22, 2026 05:09
OperationDefinition.Operation op = operationDefinition.getOperation() != null
? operationDefinition.getOperation()
: OperationDefinition.Operation.QUERY;
switch (op) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some follow on changes after adding nullability annotations

def operationDefinition = OperationDefinition.newOperationDefinition()
.name("q")
.selectionSet(SelectionSet.newSelectionSet().selection(new Field("f")).build())
.build()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor test change after selection set not nullable

@dondonz dondonz marked this pull request as ready for review February 22, 2026 05:52
@dondonz dondonz changed the title Add JSpecify annotations to language package nodes - redo Add JSpecify annotations to language package nodes - redo (12 more) Feb 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change requires a new major version to be relased

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant