Skip to content

Merge each SDL type with its extensions once - #951

Merged
oryan-block merged 12 commits into
agent-friendlyfrom
agent-friendly-p8-merged-definitions
Nov 5, 2026
Merged

oryan-block merged 12 commits into
agent-friendlyfrom
agent-friendly-p8-merged-definitions

Conversation

@oryan-block

@oryan-block oryan-block commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Agents working in graphql-java-tools keep repeating the same mistakes, and the repo should change so a change that looks right from one file is right for the whole repo. For type extensions it's the same mistake every time. A type and its extensions travel separately, so every reader of a type's fields, members, values or interfaces has to remember withExtensions (19 calls), and every create* in SchemaParser repeats the same header by hand: extension directives, the repeat check, definition/extensionDefinitions, description and SDL directives. A reader or builder that forgets one still compiles and passes every test without extensions. #863 (the repeat check only covered some kinds) and #864 (scalars dropped their directives) are that mistake.

Checklist

  • Pull requests follows the contribution guide
  • New or modified functionality is covered by tests

Description

Each SDL type is now merged with its extensions once, in MergedDefinitions.kt, and nothing outside that file can read the raw definitions.

  • MergedType has one leaf per kind (Object, Interface, Union, Enum, Input, Scalar). Each exposes its merged fields, interfaces, members or values, definition first, and keeps the definition and its extensions private. The scanner, ScanDictionary, SealedSubclassMatcher, TypeClassMatcher, TypeReferenceErrors, DictionaryTypeResolver and the parser take MergedType instead of TypeDefinition.
  • MergedType.prepare is the shared recipe: the repeat check, name, definition, extensionDefinitions, description and SDL directives. The six create* functions only add members.
  • withExtensions, extensionsOf, SdlDirectives.directivesOf and the TypeExtensions alias are gone. No caller is left on the old path.
  • The lookups keep the shapes Pin how type extensions behave before merging definitions #948 pinned: byName is last-wins, objectsWithRepeats keeps every object definition, the specification scalars resolve first, and type errors search the reached definitions, then directive definitions, then extensions.
  • "only input object types may share a class" is one helper, TypeClassDictionary.requireOnlyInputTypesShareAClass. The supplied dictionary check and the scanned one both call it and keep their own messages.

Kotlin's internal is module-wide, so DefinitionsBoundaryTest (ArchUnit, test scope) checks the compiled classes. The allowed classes are named exactly (graphql.kickstart.tools.MergedDefinitions and MergedType), so a second file or class with the same name somewhere else doesn't get a pass. Outside them, code can't:

  • read the fields, interfaces, members, values, input fields, directives or child nodes (getChildren, getNamedChildren) of a type definition. That includes reads through a receiver typed as a supertype, like DirectivesContainer<*>, Node<*> or Definition<*>, since it may hold a type definition. Only RelayConnectionFactory is allowed, because it gets raw definitions by contract. Any other TypeDefinitionFactory isn't.
  • see a type extension class.
  • walk SDL nodes with NodeTraverser, a NodeVisitor or AstTransformer.
  • read a TypeDefinitionRegistry (types(), objectTypeExtensions() and so on).
  • start a named-type builder. Only DirectiveWiringHelper may transform a built type.

A violation names the method, the line and what to use instead:

Rule '... should access target where reads the members, directives or child nodes of what may be an SDL type definition, because each type is merged with its extensions once in MergedDefinitions.kt. Look the type up with MergedDefinitions.byName or resolve(name) and read MergedType.fields, interfaces, members or values, which hold the extensions' items too. MergedType.prepare applies the merged directives, and a built graphql-java type lists them in appliedDirectives. A field, argument or enum value's own directives are fine to read through a FieldDefinition, InputValueDefinition or EnumValueDefinition receiver, not a DirectivesContainer or Node one' was violated (1 times):
Method <graphql.kickstart.tools.bypass.B21DirectivesContainerKt.directivesOf(graphql.language.DirectivesContainer)> calls method <graphql.language.DirectivesContainer.getDirectives()> in (B21DirectivesContainer.kt:7)

There's no public merged member for a read-only type directive check, since nothing needs one yet. So the message points at MergedType.prepare and the built type's appliedDirectives instead of adding one.

TypeKindRecipeTest checks each recipe step on every kind, and its first test compares its cases with MergedType's sealed leaves. So a seventh kind fails to compile in every exhaustive when first, then fails the test until it gets a case:

java.lang.AssertionError: MergedType.Seventh has no case. Add a Kind for it to kinds, and add to sdl a definition with a description and @a and an extension with @b

ArchUnit logs at DEBUG, so src/test/resources/logback-test.xml sets it to INFO. The published tests jar leaves that file out, so a project with that jar on its test classpath and no logging config of its own doesn't pick it up.

No release note, nothing changes for library users.

Scope

  • Only SDL type definitions. Field, argument and enum value definitions are still read directly, since they don't have extensions.
  • Reflection still gets past the boundary test. The method name is a string, so no bytecode check sees the call, and nothing in the library has a reason to reach graphql-java's AST that way.
  • A field's own directives read through a DirectivesContainer<*> helper fail the test even though they're fine. The message says to type the receiver as FieldDefinition instead. Nothing in the library reads them that way today.
  • The perf check against trunk (schema build time) is left to the verification run.

Blast Radius

Every schema build goes through the new path, so a mistake here would show up as a changed schema, error or WARN. The suite, CompatTest and the probe diff below say nothing changed. Contributors get ArchUnit at test scope and about 1 s more in mvn verify.

Verification

  • mvn -B -ntp clean verify on JDK 17 at the head: 480 tests pass, including CompatTest and ResolverCompatibilityTest. The API check finds no incompatible change against 15.0.0 and the jar check passes. Same 480 on JDK 25. unzip -l on the tests jar lists no logback-test.xml.
  • All 40 verify-tools scenarios ran at agent-friendly and at 920275f5, with the exit codes, printed schemas, results, WARNs and build errors diffed. 31 build and 9 throw on both sides. The schemas, results, WARNs and error messages are identical. The only difference is in the 9 build errors' stack traces, where line numbers moved and the repeat check now throws from MergedType.rejectRepeated instead of SchemaParser.rejectRepeated. The commits since only touch tests, AGENTS.md and the tests jar config.
  • I reran 31 bypass attempts as scratch files in src/main, plus two more that read a type's directives through a DirectivesContainer<*> helper and its fields through namedChildren.getChildren(...). They cover import aliases, qualified names, property syntax, Java files, generic receivers, with blocks, callable and constructor references, typealiases, when over the kinds, other packages, getChildren, NodeTraverser, TypeDefinitionRegistry, a second MergedDefinitions.kt in another package and another TypeDefinitionFactory. All but reflection fail the test, with the line and a message naming the replacement. Before this round, 10 got through. Five more on top (a class named MergedType elsewhere, graphql.util.Traverser over children, a Definition<*> receiver, types() on a registry, a field's directives through DirectivesContainer<*>) fail too, and the same read through a FieldDefinition receiver passes.
  • A scratch seventh MergedType leaf fails to compile, and with the whens filled in it fails TypeKindRecipeTest with the message above. On 98846412, dropping extensionDefinitions from the enum failed it too.

oryan-block and others added 12 commits November 4, 2026 19:43
The scanner and the parser now see an SDL type only as a MergedType, built once in MergedDefinitions.kt. Each one merges its extensions' fields, interfaces, members, values and directives, base first, and keeps the definition and extensions themselves private. MergedType.prepare runs what every kind's builder needs: the repeated-declaration check, the name, definition and extensionDefinitions, the description and the SDL directives. The six create functions in SchemaParser only add members.

withExtensions, extensionsOf, directivesOf and the TypeExtensions alias are gone, with all 18 calls of withExtensions. The lookups keep their shapes: byName is last-wins like before, baseObjects keeps every object definition, the specification scalars resolve first, and type errors search definitions before extensions. One helper in TypeClassDictionary owns "only input object types may share a class" for both the supplied dictionary and the scanned one, each with its own message.

Co-Authored-By: Claude <noreply@anthropic.com>
Kotlin's internal is module-wide, so DefinitionsBoundaryTest checks the compiled classes with ArchUnit. Outside MergedDefinitions.kt no code may read the fields, interfaces, members, values, input fields or directives of a type definition, see a type extension class, or start a named-type builder. Only the directive wiring may transform a built type. TypeDefinitionFactory implementations such as the Relay factory get raw definitions by contract and stay allowed. Each violation names the method, the line and the MergedType member to use instead.

It catches import aliases, qualified names, property syntax, Java files, generic receivers, with blocks, callable references, imported nested builders and typealiases. Reflection and getNamedChildren still get through. logback-test.xml keeps ArchUnit's class import at INFO.

Co-Authored-By: Claude <noreply@anthropic.com>
TypeKindRecipeTest builds one type of each kind with a description, a directive on the definition and one on its extension, and checks the built description, definition, extension definitions and applied directives. It also checks that each kind with members rejects a member its extension repeats, with the exact error and source location. Its first test compares its cases with the leaves of MergedType, so a new kind without a case fails, after the compiler has already asked for it in every exhaustive when.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…ding

byName and baseObjects come from the merged types instead of a second pass over the definitions, and the type reference errors take a type's kind from MergedType.kind. A scalar keeps its provided description through newScalar, so prepare only sets one the SDL gives. The boundary test finds extension classes and named types by their classes instead of their names.

Co-Authored-By: Claude <noreply@anthropic.com>
objectsWithRepeats, kindPhrase, declarationTextOf and findUsage's reference parameter say what the KDoc said, so the comments go.

Co-Authored-By: Claude <noreply@anthropic.com>
The allowlist now names the exact classes in graphql.kickstart.tools, and only the Relay factory may read raw definitions. Reads through a DirectivesContainer or Node receiver, getChildren and getNamedChildren, SDL node walks and TypeDefinitionRegistry reads fail too, and each message names the MergedType member to use. TypeKindRecipeTest names the leaf with no case and what to add.
A project with this jar on its test classpath and no logging config of its own would pick it up.
@sonarqubecloud

sonarqubecloud Bot commented Nov 5, 2026

Copy link
Copy Markdown

@oryan-block
oryan-block merged commit ea0b9a1 into agent-friendly Nov 5, 2026
5 checks passed
@oryan-block
oryan-block deleted the agent-friendly-p8-merged-definitions branch November 5, 2026 23:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant