Skip to content

fix: Java constructor calls lose their target when the type is generic - #103

Open
dbianco wants to merge 1 commit into
NanoNets:mainfrom
dbianco:fix/java-generic-constructor-target
Open

fix: Java constructor calls lose their target when the type is generic#103
dbianco wants to merge 1 commit into
NanoNets:mainfrom
dbianco:fix/java-generic-constructor-target

Conversation

@dbianco

@dbianco dbianco commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

A regression from #83, found while measuring what to propose next. Reporting it as a fix rather than a feature because the defect is mine.

The defect

calleeName handed an object_creation_expression's constructed type to the resolver as raw text:

new Box()          ->  "Box"           resolves
new Box<String>()  ->  "Box<String>"   no such node
new Box<>()        ->  "Box<>"         no such node

The node is named Box, so every generic construction lost its edge while the raw form kept working. That asymmetry is why it survived review: the fixtures added in #83 all construct non-generic types, so the tests agreed with the bug.

The fix, and the half that says no

javaConstructedTypeName erases type arguments — and does only that. Erasure is the one transformation that provably does not change which type is named:

Box  |  Box<String>  |  Box<>   ->  Box

A qualified name is deliberately dropped rather than reduced to its final segment:

java.io.File  ->  unresolved   (not the repo's own `File`)
Beta.Builder  ->  unresolved   (not `Alpha.Builder` beside it)

Collapsing those was my first attempt, and it traded missing edges for wrong ones. Verified on purpose-built fixtures: new java.io.File(…) produced an edge to an unrelated in-repo com.acme.File, and a nested new Beta.Builder() bound to its sibling Alpha.Builder at extracted confidence, because resolveName's same-file branch returns the first candidate. Both are edges the source never expresses.

Dropping keeps this pass on the resolver's own rule — resolve precisely, or not at all — and costs 5 edges on gson against the collapsing version.

This helper is deliberately not shared with bindings.ts's javaTypeName. That one answers "what type does this variable hold", where reducing java.util.List to List is a local heuristic with different stakes. The two questions do not have the same safe answer, so they get separate helpers rather than one that is right for one caller and wrong for the other.

Measurement

google/gson, 264 files, origin/main vs this commit (baseline built from a clean worktree, not a stash):

before after
calls edges 4,995 5,329
of those, targeting a type node 1,673 2,007
self-loop calls 26 27

+334, and the two deltas are identical — every recovered edge is a constructor edge, and no other call kind moves.

The one added self-loop is a field initializer constructing its own enclosing class (Box next = new Box()), which is attributed to the class node. That relation is real, so it is left alone; the other 26 pre-date this change and are direct recursion. Worth flagging since selfLoopCalls is a reported quality signal: the counter now mixes recursion with class-level self-construction, and distinguishing them would need the call-site kind preserved, which is not this change.

Spot-check on the case that made me look: the three RuntimeTypeAdapterFactory.of overloads construct new RuntimeTypeAdapterFactory<>(), and the class node went from 0 incoming edges to 3. TypeToken now shows 167, TypeAdapter 51, LinkedTreeMap 20 — all silently absent before.

Tests

Three cases, all in test/graph-java.test.ts:

  • explicit-argument, diamond and raw construction all reach the same node;
  • a qualified new resolves to nothing, not to a same-named local type;
  • a nested new does not bind to a sibling of the same simple name.

Verified red both when erasure is removed and when qualified collapsing is reintroduced.

The nested fixture keeps the construction in the same file as both candidates on purpose. My first version of it put them in separate files, where the cross-file ambiguity check drops the edge anyway — so it passed with or without the bug and pinned nothing.

Suite: 625 passing.

Scope

No new node field, relation, resolver pass, dependency, or configuration. Two files.

Known and deliberately left for separate issues, since none is caused by this change:

  • typeIdentifiersIn walks into type_arguments, so implements Function<Greeter, String> emits implements Greeter and implements String. Worse than the constructor case, because extends edges feed classParents and a wrong parent produces wrong call edges through the ancestor walk.
  • resolveName's same-file branch returns local[0] with no ambiguity check at extracted confidence. That is the root cause the two negative tests here route around per-spelling; a local.length === 1 guard fixes the class once for every relation. I have measured it as a no-op on gson and spring-petclinic with the suite green, and would rather propose it on its own than fold a change to shared resolution semantics into a regression fix.
  • Anonymous-class methods take owner from the enclosing class.
  • argCount is persisted on constructor edges and never read, since a constructor edge targets the class node rather than a specific constructor.

Also not addressed: outer.new Inner() puts the qualifier in a sibling node, leaving type a bare type_identifier — so that spelling still reaches the same first-candidate tiebreak. The local.length === 1 guard above is what closes it; naming more spellings here would not.

Regression from NanoNets#83. `calleeName` handed an `object_creation_expression`'s
constructed type to the resolver as raw text, so:

    new Box()          -> "Box"           resolves
    new Box<String>()  -> "Box<String>"   no such node
    new Box<>()        -> "Box<>"         no such node

The node is named `Box`, so every generic construction lost its edge while
the raw form worked — which is why it went unnoticed: the fixtures added in
NanoNets#83 all construct non-generic types, so the tests agreed with the bug.

`javaConstructedTypeName` now erases type arguments, and does only that.
Erasure is the one transformation that provably does not change WHICH type
is named. A qualified name is deliberately dropped instead of reduced to its
final segment:

    java.io.File  -> unresolved   (not the repo's own `File`)
    Beta.Builder  -> unresolved   (not `Alpha.Builder` beside it)

Collapsing those was the first attempt and it traded missing edges for wrong
ones: `new java.io.File(…)` resolved to an unrelated in-repo `File`, and a
nested `Beta.Builder` bound to its sibling at `extracted` confidence, because
the same-file tiebreak takes the first candidate. Dropping keeps this pass on
the resolver's own rule — resolve precisely, or not at all. Supporting
qualified construction needs an import-aware type index, not a longer helper.

Deliberately NOT shared with bindings.ts's `javaTypeName`: that one answers
"what type does this variable hold", where reducing `java.util.List` to
`List` is a local heuristic with different stakes. The two questions do not
have the same safe answer.

Measured on google/gson (264 files), origin/main vs this commit:

    calls edges                 4995 -> 5329   (+334)
    of those, to a type node    1673 -> 2007   (+334)
    self-loop calls               26 -> 27     (+1)

Every recovered edge is a constructor edge; no other call kind moves. The one
new self-loop is a field initializer constructing its own enclosing class
(`Box next = new Box()`), which is attributed to the class node — a real
relation, left alone here. The other 26 pre-date this change and are direct
recursion.

Tests: three cases — generic/diamond/raw all reach one node; a qualified
`new` resolves to nothing rather than to a same-named local type; a nested
`new` does not bind to a sibling builder. Verified red both when erasure is
removed and when qualified collapsing is reintroduced. The nested fixture
keeps the construction in the SAME file as both candidates on purpose: split
across files the ambiguity drops anyway and the test would pin nothing.
Suite: 625.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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