fix: Java constructor calls lose their target when the type is generic - #103
Open
dbianco wants to merge 1 commit into
Open
fix: Java constructor calls lose their target when the type is generic#103dbianco wants to merge 1 commit into
dbianco wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
calleeNamehanded anobject_creation_expression's constructed type to the resolver as raw text: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
javaConstructedTypeNameerases 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 rather than reduced to its final segment:
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-repocom.acme.File, and a nestednew Beta.Builder()bound to its siblingAlpha.Builderatextractedconfidence, becauseresolveName'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'sjavaTypeName. That one answers "what type does this variable hold", where reducingjava.util.ListtoListis 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/mainvs this commit (baseline built from a clean worktree, not a stash):callsedges+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 sinceselfLoopCallsis 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.ofoverloads constructnew RuntimeTypeAdapterFactory<>(), and the class node went from 0 incoming edges to 3.TypeTokennow shows 167,TypeAdapter51,LinkedTreeMap20 — all silently absent before.Tests
Three cases, all in
test/graph-java.test.ts:newresolves to nothing, not to a same-named local type;newdoes 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:
typeIdentifiersInwalks intotype_arguments, soimplements Function<Greeter, String>emitsimplements Greeterandimplements String. Worse than the constructor case, becauseextendsedges feedclassParentsand a wrong parent produces wrong call edges through the ancestor walk.resolveName's same-file branch returnslocal[0]with no ambiguity check atextractedconfidence. That is the root cause the two negative tests here route around per-spelling; alocal.length === 1guard 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.ownerfrom the enclosing class.argCountis 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, leavingtypea baretype_identifier— so that spelling still reaches the same first-candidate tiebreak. Thelocal.length === 1guard above is what closes it; naming more spellings here would not.