fix: Java cast-wrapped and this.method() call edges (#2357)

* fix: resolve Java cast-wrapped and this.method() call edges

Two fixes for missing call edges in Java method resolution:

1. compound-receiver.ts — cast expression handling:
   - Strip (Type) cast wrappers from receiver text, tracking the
     outermost meaningful cast type
   - Resolve directly to the cast type class (not the field's
     declared type), since the cast narrows the receiver type
   - Add this.field chain walker for field-access receivers
   - Replace text → workingText throughout the function body

2. scope-resolver.ts:
   - Enable resolveThisViaEnclosingClass: true for Java
     (activates Case 0.5 in receiver-bound-calls.ts)

Verified on a large-scale Java codebase with no regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(scope-resolution): format compound-receiver.ts with prettier (#2353 review F10)

Mechanical prettier --write from repo root — 6 brace-expansion sites and one
ternary re-join, zero logic changes. Clears the quality/format CI failure
that was blocking CI Gate on PR #2353.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(scope-resolution): pin working Java cast-receiver shapes (#2353 review F3)

Fixture-backs the cast resolutions PR #2353 gets right — simple cast,
nested/CFR cast, cast over this.field, and the deliberate declared-type
fallback for a resolvable-shape cast to an unindexed type — each with a
same-named decoy method on the receiver's declared type so later refactors
cannot silently regress them. No resolver changes; tests are green as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scope-resolution): resolve nothing for unparseable cast types (#2353 review F1)

A receiver paren-group that is type-shaped but unparseable — generic
(List<String>), array (Foo[]), fully-qualified (com.example.Foo) — is a
cast whose type cannot be looked up. Stripping it and falling through
resolved the pre-cast expression's own declared type, emitting a
confident wrong CALLS edge. Classification is now three-way per peel:
simple identifier → capture (outermost wins), type-shaped-unparseable →
resolve nothing (pre-#2353 behavior; noise casts after a captured type
still win), anything else → not a cast, text left untouched. Cast
candidates require a non-empty trailing expression, so plain
parenthesized receivers never capture a cast type.

Red-first: all four shapes reproduced the wrong edge before the fix;
golden digest byte-stable after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(scope-resolution): delete duplicate this.field walker, seed literal-this chain heads (#2353 review F4/F5/F7)

A/B against the fixture corpus confirmed the generic per-segment walker
(head resolved via the synthesized this typeBinding) already covers every
method-body this.field chain — only initializer contexts (instance
initializer block, field initializer) were walker-dependent, since no
function scope exists there to carry a this binding. Deleting the
duplicate walker removes the naive chainRest.split('.') (F5) and the
widened fieldFallback use (F7) with it; the findEnclosingClassDef head
seed is the deliberate residue covering initializer contexts —
head-resolution only, the per-segment walk stays the single shared
implementation. Post-seed edge set is byte-identical to pre-deletion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scope-resolution): gate cast stripping behind opt-in stripReceiverCastExpressions (#2353 review F2)

Cast handling in resolveCompoundReceiverClass now runs only for
languages that opt in via the new ScopeResolver toggle (default off);
Java is the sole opt-in. The peel loop is extracted into the pure,
exported stripCastWrappers helper (placed with the file's other pure
string helpers) so it can be unit-tested directly. Non-opting languages
see receiver text untouched — pre-#2353 behavior by construction
(golden digest unchanged, TS/C++/C# suites green, 796/796). Shared-code
comments are language-neutral per AGENTS.md; the contract JSDoc carries
the classifier grammar, the second-language escalation rule, and the
Case 3b/Case 4 pass-through non-goal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scope-resolution): cap cast-peel iterations in stripCastWrappers (#2353 review F8)

MAX_CAST_PEEL = 16 (each cast level costs at most two peels, so this
covers 8-level nesting with headroom — real cast nesting, including
decompiler output, is a handful of levels). Each peel rescans the
working text for its matching close paren, so pathological nested-paren
input was O(N²); the cap bounds it at O(N·16). Exceeding the cap bails
all-or-nothing with the original text (not-a-cast outcome). Adds the
helper's first unit tests: 14 scenarios covering capture, unparseable
shapes, redundant-paren unwrap, captured-type precedence, rawName
no-op, over/under-cap, and unbalanced-paren termination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scope-resolution): revert Java resolveThisViaEnclosingClass, pin Case 4 bare-this dispatch (#2353 review F6/F9)

Remove resolveThisViaEnclosingClass from the Java scope resolver: the
toggle's own contract doc prescribes keeping it disabled where Case 4
(the synthesized this typeBinding) already handles this, and Case 0.5's
C++-authored semantics (hiddenByName arity-hiding, method-before-field)
provably bypass the interface-dispatch fan-out only Case 4 emits.

A/B gate (new java-this-dispatch pinning fixtures): flag-off 7/7 green;
flag-on 2/7 red (hiddenByName drops the this.greet overload site —
masked by a free-call-fallback 'local-call' edge — and the
interface-dispatch fan-out is missing). Corpus A/B over all 54 java-*
fixtures: 2 fixtures differ — java-this-dispatch (reason
'local-call'→'global' on the bare-this overload site; +2
interface-dispatch fan-out edges flag-off) and java-this-field-chain
(2 initializer-context bare-this ACCESSES reads emitted only by Case
0.5, which Case 4 cannot resolve — no synthesized this binding without
a Function scope; the corresponding CALLS edges are unaffected via the
F4 commit's literal-this head seed).

Also (F9): insert Case 0.5 into the I4 case-order listings (contract +
receiver-bound-calls header, now 8-case, marked gated) so the next flag
flip is visible at review time; the two 'sole C++ language' comments
are accurate again unedited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scope-resolution): restrict literal-this head seed to initializer contexts (#2353 review follow-up)

Final-review finding (two independent reviewer angles): the literal-this
chain-head seed landed ungated in shared code, so any language's
this-headed chain in a scope without a synthesized this typeBinding —
including contexts where the language DELIBERATELY leaves this unbound
(object-literal methods, nested plain functions) — would seed from the
lexically enclosing class. isInitializerContext now permits the seed
only when no Function scope sits between the site and its class, which
is precisely the field-initializer / instance-initializer shape the
seed exists for. Adds a TS guard fixture pinning that an
object-literal method's this.field.method() chain emits no fabricated
edge (mechanism did not empirically reproduce even ungated — the
restriction is conservative hardening, and the pin keeps it that way).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(scope-resolution): attach stripCastWrappers JSDoc, fast-path non-paren receivers (#2353 review nits)

Two final-review nits: a blank line detached the helper's 30-line
classification-contract JSDoc from the declaration (IDE hover showed
nothing at call sites); and the gate now skips the helper call plus
result allocation for the majority of receivers that cannot be casts
because they do not start with '(' — the helper's own check stays as
the safety net.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* bench(scope-capture): rebaseline Java fingerprint for new #2357 fixtures

The scope-capture correctness fingerprint hashes captures over the
java-* fixture corpus; the three fixture dirs added by this PR
(java-cast-receiver, java-this-field-chain, java-this-dispatch) extend
that corpus, so the fingerprint moves. Verified purely additive: with
the three new dirs parked, the fingerprint reproduces the prior
baseline byte-identically — no emit/capture behavior changed.
--check now passes for all 14 languages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: ww <ww@wwdeMacBook-Pro.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-07-02 17:19:33 +01:00 • committed by GitHub
parent fb40d15a16
commit fa8ebf672e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1135 additions and 32 deletions

View file

@ -13,8 +13,8 @@
"c": {
"fingerprint": "12a196b2d6249c8d86a931b12ecebc2a0cdf8d6f47683acdd0d8e9d8bc7657f5",
"scaling_budget": 1.5,
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance — flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.",
"_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.",
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.",
"_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c \u2014 worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.",
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
},
"cpp": {
@ -24,7 +24,7 @@
"_note_1899_followup": "#1899 follow-up: braced-init metadata now carries element count, intentionally changing C++ capture output; CI benchmark scaling remains linear (1.129 < 1.5).",
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).",
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5)."
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift \u2014 no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures \u2014 pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture \u2014 pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5)."
},
"csharp": {
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
@ -35,20 +35,20 @@
"rust": {
"fingerprint": "ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82",
"scaling_budget": 1.5,
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) — legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures — pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
},
"php": {
"fingerprint": "bc2c27c5ba26d5aea61142a2a99fb772222f5b969205260eb7a71b4c0bd73cdb",
"scaling_budget": 1.5,
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04).",
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class — fixture count 138→140, fingerprint drift expected."
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected."
},
"ruby": {
"fingerprint": "b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753",
"scaling_budget": 1.5,
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1991: + ruby-nested-mixin-tail-collision fixture (85→86). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb."
"_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb."
},
"swift": {
"fingerprint": "180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998",
@ -62,16 +62,16 @@
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
},
"java": {
"fingerprint": "9b29cafe32873b4902bda311bd089ffc04efe08f13557b966d29544be514080a",
"fingerprint": "062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada",
"scaling_budget": 1.5,
"_rebaselined": "#1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically \u2014 no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box<T>()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06)."
},
"typescript": {
"fingerprint": "3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9",
"scaling_budget": 1.5,
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures — fingerprint drift expected.",
"_note": "#1968: F44, F85, F87 — fingerprint drift expected."
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.",
"_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected."
},
"javascript": {
"fingerprint": "d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8",
@ -84,6 +84,6 @@
"scaling_budget": 1.5,
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.",
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land — until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear)."
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear)."
}
}

View file

@ -57,6 +57,7 @@ const javaScopeResolver: ScopeResolver = {
propagatesReturnTypesAcrossImports: true,
collapseMemberCallsByCallerTarget: true,
hoistTypeBindingsToModule: true,
stripReceiverCastExpressions: true,
populateNamespaceSiblings: populateJavaPackageSiblings,
populateRangeBindings: populateJavaCrossFileReturnTypes,

View file

@ -110,11 +110,17 @@
* in this order; the FIRST that emits an edge wins:
* 1. super branch (`provider.isSuperReceiver(receiverName)`)
* 2. Case 0 compound (`receiverName` has `.` or `(`)
* 3. Case 1 namespace-receiver
* 4. Case 2 class-name receiver
* 5. Case 3 dotted typeBinding for namespace prefix
* 6. Case 3b chain-typebinding (compound resolver)
* 7. Case 4 simple typeBinding (MRO walk + findOwnedMember)
* 3. Case 0.5 implicit-`this` chain walk — GATED: fires only for
* languages that set `resolveThisViaEnclosingClass === true`;
* it intercepts every bare-`this` call/read/write site ahead of
* Case 4 and does NOT emit Case 4's interface-dispatch fan-out,
* so enabling the toggle for a language changes that language's
* `this` dispatch semantics (see the toggle's doc below)
* 4. Case 1 namespace-receiver
* 5. Case 2 class-name receiver
* 6. Case 3 dotted typeBinding for namespace prefix
* 7. Case 3b chain-typebinding (compound resolver)
* 8. Case 4 simple typeBinding (MRO walk + findOwnedMember)
* Reordering or merging cases changes resolution semantics. The
* numbering is part of the contract — keep the comments.
*
@ -927,6 +933,44 @@ export interface ScopeResolver {
*/
readonly hoistTypeBindingsToModule?: boolean;
/**
* Whether the compound-receiver resolver should strip C-style cast
* expressions from receiver-position text before resolving it —
* `((Target)((Object)expr)).method()` peels to receiver `expr` with
* cast type `Target`, and the outermost captured cast type wins as
* the receiver's class. Default `false`.
*
* Java opts in: decompiler output is dense with cast-wrapped
* receivers, and Java's `(Type) expr` cast syntax makes the paren
* group textually classifiable. Keep disabled elsewhere:
* `(...)`-prefixed receiver text is ambiguous across languages
* (grouping, tuples, IIFEs, C-style declarations), so treating it
* as a cast would fabricate receiver types — non-opting languages
* must see receiver text completely untouched.
*
* Classifier grammar (exact): a peeled paren group whose content is
* a simple identifier (`/^[a-zA-Z_]\w*$/`) is captured as the cast
* type; content matching `Ident(.Ident)*(<...>)?([])*` — dotted,
* generic, and/or array shapes — is recognized as a cast whose
* target type cannot be looked up, and the resolver resolves
* NOTHING for that receiver (never the pre-cast expression's own
* declared type). Any other paren-group content is not a cast and
* the text falls through to the normal resolver.
*
* A second opting language must extend the classifier grammar or
* convert this toggle into a per-language classifier hook (the
* `unwrapCollectionAccessor` pattern) — do not flip this flag for
* another language as-is.
*
* Known non-goal: the compound-receiver options built from this
* toggle also feed Case 3b (chain-typeBinding rawNames — declared
* types / member paths, never cast RHS for Java) and Case 4's
* compound fallback (`receiverName`, paren-free because Case 0
* intercepts receivers containing `(` or `.` first), so the
* stripper is structurally inert on those inputs.
*/
readonly stripReceiverCastExpressions?: boolean;
/**
* Optional: detect structural (duck-typing) interface implementations.
* Languages like Go use structural typing — a struct satisfies an

View file

@ -25,6 +25,7 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
import {
findClassBindingInScope,
findEnclosingClassDef,
findExportedDefByName,
findReceiverTypeBinding,
} from '../scope/walkers.js';
@ -41,6 +42,22 @@ const COMPOUND_RECEIVER_MAX_DEPTH = 8;
const MAP_TUPLE_SENTINEL_RE = /^__MAP_TUPLE_(\d+)__:(.+)$/;
/** Cast type the resolver can look up directly: a simple identifier. */
const SIMPLE_CAST_TYPE_RE = /^[a-zA-Z_]\w*$/;
/** Classification-only shape for a cast type that is recognizable but
* NOT resolvable here: dotted qualifier (`com.example.Foo`), generic
* (`List<String>`), array (`Foo[]`), or combinations
* (`com.example.List<Foo>[]`) — shape `Ident(.Ident)*(<…>)?([])*`,
* whitespace-tolerant. No attempt is made to parse generic contents;
* `[^()]*` merely keeps expression-like paren content from matching.
* Matching this shape (when the simple-identifier shape doesn't)
* means the paren group IS a C-style cast whose target type we cannot
* look up — the only safe outcome is to resolve nothing, never to
* fall through to the pre-cast expression's own declared type. */
const UNPARSEABLE_CAST_TYPE_RE =
/^[a-zA-Z_]\w*(?:\s*\.\s*[a-zA-Z_]\w*)*(?:\s*<[^()]*>)?(?:\s*\[\s*\])*$/;
function parseMapTupleSentinel(text: string): { tupleIdx: number; rhs: string } | null {
const match = MAP_TUPLE_SENTINEL_RE.exec(text);
if (match === null) return null;
@ -67,6 +84,12 @@ interface ResolveCompoundReceiverOptions {
* languages that hoist return-type bindings to Module scope (C#);
* otherwise we risk picking up unrelated module-level bindings. */
readonly hoistTypeBindingsToModule?: boolean;
/** Strip C-style cast expressions from the receiver text before
* resolving it (`stripCastWrappers`). Default `false` — the text
* reaches the resolver untouched and no cast logic runs. See the
* `ScopeResolver` contract toggle of the same name for the
* classifier grammar and per-language opt-in rules. */
readonly stripReceiverCastExpressions?: boolean;
}
export function resolveCompoundReceiverClass(
@ -83,12 +106,40 @@ export function resolveCompoundReceiverClass(
if (text.length === 0) return undefined;
const fieldFallback = options.fieldFallback ?? true;
// ── Pre-processing: strip C-style cast expressions (opt-in) ──────
// Cast-wrapped receivers like ((Type)((Object)this.field)).method()
// produce parenthesized-expression receiver text. For languages that
// opt in via `stripReceiverCastExpressions`, peel outer (Type)
// layers so the resolver sees the actual receiver (e.g. this.field)
// — `stripCastWrappers` documents the classification rules. When
// the toggle is off, the text reaches the resolver untouched and no
// cast logic runs.
let workingText = text;
if (options.stripReceiverCastExpressions === true && text.startsWith('(')) {
const stripped = stripCastWrappers(text);
// A recognized cast whose target type cannot be looked up here:
// the only safe outcome is to resolve nothing — falling through
// to the pre-cast expression's own declared type would emit a
// confident wrong edge.
if (stripped.unresolvableCast) return undefined;
workingText = stripped.workingText;
// A captured cast type names the exact receiver type for method
// resolution — the cast narrows the receiver's declared type, so
// resolve to the CAST type, not the underlying expression's type.
if (stripped.castType !== undefined) {
const cls = findClassBindingInScope(inScope, stripped.castType, scopes);
if (cls !== undefined) return cls;
}
}
// ── End pre-processing ─────────────────────────────────────────
// Bare identifier — resolve via typeBinding first, then fall back to
// a direct class-name lookup. The class-name fallback handles
// "static receiver" shapes like `UserService.findUser()` where
// `UserService` isn't a variable but a class imported into scope.
if (!text.includes('.') && !text.includes('(')) {
const mapTuple = parseMapTupleSentinel(text);
if (!workingText.includes('.') && !workingText.includes('(')) {
const mapTuple = parseMapTupleSentinel(workingText);
if (mapTuple !== null) {
const rhsTb = findReceiverTypeBinding(inScope, mapTuple.rhs, scopes);
if (rhsTb === undefined) return undefined;
@ -97,7 +148,7 @@ export function resolveCompoundReceiverClass(
return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes);
}
const tb = findReceiverTypeBinding(inScope, text, scopes);
const tb = findReceiverTypeBinding(inScope, workingText, scopes);
if (tb !== undefined) {
// Map for-of: binding name is `user` but rawType is
// `__MAP_TUPLE_i__:entries` (see captures.ts) — same extraction as
@ -167,17 +218,17 @@ export function resolveCompoundReceiverClass(
if (compound !== undefined) return compound;
}
}
return findClassBindingInScope(inScope, text, scopes);
return findClassBindingInScope(inScope, workingText, scopes);
}
// Trailing `()` — call expression. Strip it and resolve the function
// expression's return type. We only handle the canonical `f()` /
// `obj.method()` shape; nested-arg expressions like `f(g())` are
// out of scope for V1 (depth-capped recursion catches infinite loops).
if (text.endsWith(')')) {
const openIdx = matchingOpenParen(text);
if (workingText.endsWith(')')) {
const openIdx = matchingOpenParen(workingText);
if (openIdx === -1) return undefined;
const fnExpr = text.slice(0, openIdx).trim();
const fnExpr = workingText.slice(0, openIdx).trim();
if (fnExpr.length === 0) return undefined;
const lastDot = fnExpr.lastIndexOf('.');
@ -286,7 +337,7 @@ export function resolveCompoundReceiverClass(
// (method return-type). We accept both on each hop because class
// scopes store both method return types and field types under
// `typeBindings` keyed by the member name.
const parts = splitChainAtTopLevel(text);
const parts = splitChainAtTopLevel(workingText);
// Language-specific collection-accessor suffix (C#'s `data.Values`
// on Dictionary<K,V>, etc.). When the provider hook recognizes
@ -335,6 +386,26 @@ export function resolveCompoundReceiverClass(
let currentClass: SymbolDefinition | undefined = headType
? findClassBindingInScope(headType.declaredAtScope, headType.rawName, scopes)
: findClassBindingInScope(inScope, headMemberName, scopes);
// Head seed for a literal `this` head with no receiver typeBinding in
// scope: languages synthesize `this` typeBindings per function scope,
// so a chain site outside any function scope (a field initializer or
// an instance initializer block) has none — there, the enclosing
// class definition IS the receiver type. Restricted to initializer
// contexts (no Function scope between the site and its class): a
// Function scope WITHOUT a `this` typeBinding means the language
// deliberately left `this` unbound there (object-literal methods,
// nested plain functions, static contexts), and seeding the
// lexically enclosing class would fabricate edges. Head resolution
// only; the per-segment walk below is shared with every other
// chain shape.
if (
currentClass === undefined &&
headType === undefined &&
headMemberName === 'this' &&
isInitializerContext(inScope, scopes)
) {
currentClass = findEnclosingClassDef(inScope, scopes);
}
// `const user = getUser(); user.address` — the typeBinding for `user`
// is an alias to the callee name (`getUser`), not a class. When
// `findClassBinding` on that rawName fails, treat it as a zero-arg
@ -443,6 +514,27 @@ function stripCallParens(segment: string): string {
return segment.slice(0, open);
}
/** True when `startScope` sits under a Class scope with no Function
* scope in between — a field-initializer or instance-initializer
* context, the only place a literal `this` chain head may be seeded
* from the lexically enclosing class. Function bodies are excluded
* on purpose: a Function scope carrying no `this` typeBinding means
* the language deliberately left `this` unbound there. */
function isInitializerContext(startScope: ScopeId, scopes: ScopeResolutionIndexes): boolean {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return false;
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) return false;
if (scope.kind === 'Class') return true;
if (scope.kind === 'Function') return false;
currentId = scope.parent;
}
return false;
}
/** Find the index of the `(` that matches the trailing `)` of a
* call-expression text. Returns -1 if unbalanced. */
function matchingOpenParen(text: string): number {
@ -459,6 +551,112 @@ function matchingOpenParen(text: string): number {
return -1;
}
/** Max peel iterations for `stripCastWrappers`. Real cast nesting —
* including decompiler output like `((Target)((Object)expr))` —
* is a handful of levels, and each cast level costs at most two
* peels (a redundant-paren unwrap plus the cast group itself), so
* 16 covers 8-level nesting with headroom. Each peel rescans the
* working text for its matching close paren, so pathological input
* like `((((…))))` would otherwise cost O(N²); the cap bounds it at
* O(N · MAX_CAST_PEEL). Exceeding the cap bails with the not-a-cast
* outcome and the ORIGINAL text — all-or-nothing, never a
* partially-peeled result. */
const MAX_CAST_PEEL = 16;
/**
* Peel C-style cast layers off a receiver-position expression:
* `((Target)((Other)expr))` → `workingText` `expr`, `castType`
* `Target`. Pure text scan — no scope or index access — consumed by
* `resolveCompoundReceiverClass` when a language opts in via
* `stripReceiverCastExpressions`. Track the outermost meaningful cast
* type: the cast narrows the receiver's declared type, so the caller
* resolves the CAST type, not the underlying expression's type.
*
* Each peeled paren group with a non-empty trailing expression (a
* cast candidate) is classified three ways:
* (a) simple identifier (`SIMPLE_CAST_TYPE_RE`) → cast type
* captured (outermost capture wins; later simple groups are
* noise casts, as in decompiler output like
* `((Target)((Object)expr))`);
* (b) type-shaped but unparseable here — dotted / generic / array
* (`UNPARSEABLE_CAST_TYPE_RE`) → this IS a cast, but its type
* cannot be looked up: report `unresolvableCast: true` so the
* caller resolves nothing rather than falling through to the
* pre-cast expression's own declared type (the pre-#2353 safe
* no-op for these shapes);
* (c) anything else → not a cast: stop scanning and return the
* text peeled so far for the normal resolver.
* A paren group with an EMPTY remainder is never a cast candidate —
* `((…))` / `(foo)` is a redundant-paren unwrap: unwrap and re-scan
* without capturing anything.
*
* Known limitation: the paren scan is not string-literal-aware — a
* `)` inside a quoted call argument (e.g. `((T)f(")")).g`) mis-scans
* the group boundary. Such shapes classify as not-a-cast and fall
* through safely to the normal resolver.
*/
export function stripCastWrappers(text: string): {
workingText: string;
castType: string | undefined;
unresolvableCast: boolean;
} {
let castType: string | undefined;
let workingText = text;
let peels = 0;
while (true) {
if (!workingText.startsWith('(')) break;
peels++;
if (peels > MAX_CAST_PEEL) {
return { workingText: text, castType: undefined, unresolvableCast: false };
}
let d = 1;
let closeIdx = -1;
for (let i = 1; i < workingText.length; i++) {
if (workingText[i] === '(') d++;
else if (workingText[i] === ')') {
d--;
if (d === 0) {
closeIdx = i;
break;
}
}
}
if (closeIdx === -1) break;
const insideParens = workingText.slice(1, closeIdx).trim();
const remainder = workingText.slice(closeIdx + 1).trim();
// Empty remainder: redundant outer parens — `((…))`, or a plain
// parenthesized expression like `(foo)`. Unwrap and re-scan.
// Never a cast candidate: a cast needs a trailing expression, so
// nothing is captured from this group.
if (remainder.length === 0) {
workingText = insideParens;
continue;
}
// A cast operand starts with `(`, an identifier, or `this`. Any
// other remainder shape (e.g. `.member` access on the paren
// group) means this group is not a cast — leave the text for the
// normal resolver.
if (!remainder.startsWith('(') && !/^[a-zA-Z_]/.test(remainder)) break;
if (SIMPLE_CAST_TYPE_RE.test(insideParens)) {
// (a) Resolvable cast type — capture the FIRST (outermost) one.
if (castType === undefined) castType = insideParens;
} else if (UNPARSEABLE_CAST_TYPE_RE.test(insideParens)) {
// (b) Type-shaped but unparseable cast. Once a simple cast type
// has been captured, later unparseable groups are noise casts
// and the captured type wins; otherwise report the whole
// expression as an unresolvable cast so the caller bails out.
if (castType === undefined) {
return { workingText, castType: undefined, unresolvableCast: true };
}
} else {
// (c) Not a cast.
break;
}
workingText = remainder;
}
return { workingText, castType, unresolvableCast: false };
}
/** Type arguments of a shallow `Map<K,V>` / `ReadonlyMap<K,V>` (depth-aware). */
function extractShallowMapTypeArgByIndex(mapText: string, wantIndex: number): string | undefined {
const t = mapText.trim();

View file

@ -1,5 +1,5 @@
/**
* Receiver-bound CALLS / ACCESSES emit pass — generic 7-case
* Receiver-bound CALLS / ACCESSES emit pass — generic 8-case
* dispatcher consuming `ScopeResolver` for the language-specific bits
* (super recognizer, field-fallback toggle).
*
@ -9,19 +9,26 @@
* 1. **super branch** — `provider.isSuperReceiver(receiverName)` →
* MRO walk skipping self
* 2. **Case 0 (compound)** — receiver has `.` or `(` → compound resolver
* 3. **Case 1 (namespace)** — receiver in `namespaceTargets` → exported def
* 4. **Case 2 (class-name / static receiver)** — receiver resolves to a
* 3. **Case 0.5 (implicit `this` receiver)** — GATED: fires only when
* the language sets `resolveThisViaEnclosingClass === true` AND the
* receiver is literally `this` → enclosing-class + MRO chain walk
* with C++ member-name-hiding semantics. Languages that leave the
* toggle unset skip this case entirely; their `this` sites fall
* through to Case 4 via the synthesized `this` typeBinding (which
* also emits interface-dispatch fan-out that this case does not).
* 4. **Case 1 (namespace)** — receiver in `namespaceTargets` → exported def
* 5. **Case 2 (class-name / static receiver)** — receiver resolves to a
* class-like binding (Class/Interface/Struct/Record/Enum/Trait) → MRO
* walk on that class. Also handles static-style invocations
* (`ILogger.Warn(...)`) with kind-aware reason/confidence for
* read/write ACCESSES.
* 5. **Case 3 (dotted typeBinding for namespace prefix)** —
* 6. **Case 3 (dotted typeBinding for namespace prefix)** —
* `typeRef.rawName` like `models.User`
* 6. **Case 3b (chain-typebinding)** — `typeRef.rawName` has a dot
* 7. **Case 3b (chain-typebinding)** — `typeRef.rawName` has a dot
* but not a namespace prefix → compound resolver
* 7. **Case 4 (simple typeBinding)** — `typeRef.rawName` has no dot →
* 8. **Case 4 (simple typeBinding)** — `typeRef.rawName` has no dot →
* MRO walk + `findOwnedMember`
* 8. **Case 5 (value-receiver bridge)** — receiver is a `Const`/`Variable`
* 9. **Case 5 (value-receiver bridge)** — receiver is a `Const`/`Variable`
* whose `nodeId` is referenced as an `ownerId` in `model.methods`
* (object-literal services). Last-resort fallback for lowercase
* receivers with no class-like or type-binding match. Mirrors
@ -86,6 +93,7 @@ type ReceiverBoundProviderSubset = Pick<
| 'collapseMemberCallsByCallerTarget'
| 'unwrapCollectionAccessor'
| 'hoistTypeBindingsToModule'
| 'stripReceiverCastExpressions'
| 'resolveQualifiedReceiverMember'
| 'resolveReceiverMember'
| 'resolveThisViaEnclosingClass'
@ -171,6 +179,7 @@ export function emitReceiverBoundCalls(
fieldFallback,
unwrapCollectionAccessor: provider.unwrapCollectionAccessor,
hoistTypeBindingsToModule,
stripReceiverCastExpressions: provider.stripReceiverCastExpressions === true,
};
// Build an interface → implementors map from IMPLEMENTS edges.

View file

@ -0,0 +1,77 @@
import models.Box;
import models.Fallback;
import models.Shape;
import models.Target;
import models.Wrapper;
public class App {
private Shape held;
private Shape held2;
// Simple cast: resolve via the cast type (Box), not obj's declared
// type (Wrapper). Wrapper.open is the decoy.
public void castSimple(Wrapper obj) {
((Box) obj).open();
}
// Nested/CFR-decompiler cast: the outermost meaningful cast (Target)
// wins — not the inner (Object) noise cast, not expr's declared type
// (Shape). Shape.render is the decoy.
public void castNested(Shape expr) {
((Target) ((Object) expr)).render();
}
// Cast wrapping a this.field chain: the cast type (Target) wins over
// the field's declared type (Shape). Shape.draw is the decoy.
public void castThisField() {
((Target) ((Object) this.held)).draw();
}
// Cast to a resolvable-shape but locally-unindexed simple type
// (String): resolution deliberately falls back to obj's OWN declared
// type (Fallback). Unlike the unparseable-cast case (#2353 review F1:
// generic/array/FQN cast types must resolve to nothing), a
// simple-identifier cast to an unindexed type carries no better
// information, and upcast casts make the declared type plausible.
public void castUnindexedType(Fallback obj) {
((String) obj).act();
}
// ── Unparseable-cast scenarios (#2353 review F1) ─────────────────
// Each cast below is type-shaped but UNPARSEABLE by the resolver
// (generic / array / fully-qualified). Resolution must produce NO
// call edge: falling through to the receiver's own declared type
// (the decoy owning the same-named method) emits a confident wrong
// edge.
// Generic cast: Wrapper.open is the decoy (obj's declared type).
public void castGeneric(Wrapper obj) {
((Box<String>) obj).open();
}
// Array cast: Wrapper.act2 is the decoy (obj's declared type).
public void castArray(Wrapper obj) {
((Box[]) obj).act2();
}
// Fully-qualified cast: Wrapper.act3 is the decoy (obj's declared
// type).
public void castQualified(Wrapper obj) {
((models.Box) obj).act3();
}
// Generic-FQN cast over a this.field chain: Shape.act4 is the decoy
// (the held2 field's declared type — and the generic argument, so a
// future generic-arg extraction resolving List's method to the
// element type would also be caught).
public void castGenericFqnThisField() {
((java.util.List<Shape>) this.held2).act4();
}
// Non-cast parenthesized receiver: not a cast at all — must fall
// through untouched (no crash, no fabricated edge). act5 is defined
// on no class in this fixture, so any emitted edge is fabricated.
public void nonCastParen(Wrapper x, Wrapper y, boolean flag) {
(flag ? x : y).act5();
}
}

View file

@ -0,0 +1,7 @@
package models;
public class Box {
public void open() {
// cast target for ((Box) obj).open()
}
}

View file

@ -0,0 +1,8 @@
package models;
public class Fallback {
public void act() {
// obj's OWN declared type — the deliberate fallback target for a
// cast to an unindexed simple type: ((String) obj).act()
}
}

View file

@ -0,0 +1,17 @@
package models;
public class Shape {
public void render() {
// decoy: same-named method on expr's DECLARED type
}
public void draw() {
// decoy: same-named method on the this.held field's DECLARED type
}
public void act4() {
// decoy for ((java.util.List<Shape>) this.held2).act4(): an
// unparseable cast that falls through to the held2 field's
// declared type resolves here
}
}

View file

@ -0,0 +1,11 @@
package models;
public class Target {
public void render() {
// cast target for ((Target)((Object)expr)).render()
}
public void draw() {
// cast target for ((Target)((Object)this.held)).draw()
}
}

View file

@ -0,0 +1,18 @@
package models;
public class Wrapper {
public void open() {
// decoy: same-named method on obj's DECLARED type — a regression
// that ignores the cast would resolve here instead of Box.open
}
public void act2() {
// decoy for the array cast ((Box[]) obj).act2(): an unparseable
// cast that falls through to obj's declared type resolves here
}
public void act3() {
// decoy for the fully-qualified cast ((models.Box) obj).act3():
// an unparseable cast that falls through resolves here
}
}

View file

@ -0,0 +1,7 @@
package models;
public class Base {
public String greet(String name) {
return "hi " + name;
}
}

View file

@ -0,0 +1,11 @@
package models;
public class Derived extends Base {
public String greet(String name, int times) {
return name + times;
}
public String announce() {
return this.greet("world");
}
}

View file

@ -0,0 +1,7 @@
package models;
public class FastTask implements Task {
public String run() {
return "fast";
}
}

View file

@ -0,0 +1,7 @@
package models;
public class Runner {
public String run() {
return "not a task";
}
}

View file

@ -0,0 +1,9 @@
package models;
public class SizeDecoy {
public int size;
public int size() {
return 42;
}
}

View file

@ -0,0 +1,7 @@
package models;
public class SlowTask implements Task {
public String run() {
return "slow";
}
}

View file

@ -0,0 +1,9 @@
package models;
public interface Task {
String run();
default String runAll() {
return this.run();
}
}

View file

@ -0,0 +1,18 @@
package models;
public class Widget {
public int size;
public int size() {
return 7;
}
public int describe() {
int current = this.size;
return current;
}
public int measure() {
return this.size();
}
}

View file

@ -0,0 +1,52 @@
import models.Core;
import models.Decoy;
import models.Engine;
import models.Mapper;
import models.Monitor;
import models.Report;
import models.ReportFactory;
public class App {
private Engine engine;
private Monitor monitor;
private Mapper mapper;
private Decoy decoy;
private ReportFactory factory = new ReportFactory();
// Field-initializer context (#2353 review F4): the chain runs outside
// any method/constructor scope. Decoy.make is the decoy.
private Report summary = this.factory.make();
// Instance-initializer-block context (#2353 review F4): the chain runs
// outside any method/constructor scope. Decoy.watch is the decoy.
{
this.monitor.watch();
}
// One-hop chain: this.engine → Engine, start() → Engine.start.
// Decoy.start is the decoy.
public void chainOneHop() {
this.engine.start();
}
// Two-hop chain through two typed fields: this.engine → Engine,
// .core → Core, ignite() → Core.ignite. Decoy.ignite is the decoy.
public void chainTwoHop() {
this.engine.core.ignite();
}
// Chain whose call argument contains a dot (#2353 review F5): the
// receiver of run() is `this.mapper.lookup("a.b")` — the dot inside
// the string argument must not break chain segmentation.
// Decoy.run is the decoy.
public void chainDottedArg() {
this.mapper.lookup("a.b").run();
}
// Consistency guard: an identically-shaped parameter-receiver chain
// (same classes) must resolve the same way as the this. variant —
// no this-only special-casing in the resolver.
public void chainOneHopParam(App obj) {
obj.engine.start();
}
}

View file

@ -0,0 +1,7 @@
package models;
public class Core {
public void ignite() {
// two-hop chain target for this.engine.core.ignite()
}
}

View file

@ -0,0 +1,24 @@
package models;
public class Decoy {
public void start() {
// decoy: same-named method as Engine.start
}
public void ignite() {
// decoy: same-named method as Core.ignite
}
public void watch() {
// decoy: same-named method as Monitor.watch
}
public Report make() {
// decoy: same-named method as ReportFactory.make
return new Report();
}
public void run() {
// decoy: same-named method as Result.run
}
}

View file

@ -0,0 +1,9 @@
package models;
public class Engine {
public Core core;
public void start() {
// one-hop chain target for this.engine.start()
}
}

View file

@ -0,0 +1,8 @@
package models;
public class Mapper {
public Result lookup(String key) {
// middle-of-chain call whose argument contains a dot ("a.b")
return new Result();
}
}

View file

@ -0,0 +1,7 @@
package models;
public class Monitor {
public void watch() {
// instance-initializer-block chain target for this.monitor.watch()
}
}

View file

@ -0,0 +1,7 @@
package models;
public class Report {
public void archive() {
// gives Report a member; not called anywhere in the fixture
}
}

View file

@ -0,0 +1,8 @@
package models;
public class ReportFactory {
public Report make() {
// field-initializer chain target for this.factory.make()
return new Report();
}
}

View file

@ -0,0 +1,7 @@
package models;
public class Result {
public void run() {
// dotted-arg chain target for this.mapper.lookup("a.b").run()
}
}

View file

@ -0,0 +1,20 @@
class Router {
go(): void {
// decoy target: a wrong seed resolves this.route via App's class
// scope and emits onClick → Router.go
}
}
export class App {
route = new Router();
// Object-literal method: `this` at runtime is the handlers object,
// NOT the App instance — the language deliberately leaves `this`
// unbound here, so no CALLS edge to Router.go may be fabricated
// from the lexically enclosing class.
static handlers = {
onClick() {
this.route.go();
},
};
}

View file

@ -2373,3 +2373,355 @@ describe('Java User implements Validator — interface default method (SM-11)',
expect(validateCall!.source).toBe('run');
});
});
// ---------------------------------------------------------------------------
// Cast-wrapped receivers: ((Type) expr).method() resolves via the CAST type
// (#2353). Every scenario pairs the cast target with a decoy class owning a
// same-named method on the receiver's declared type, so a regression that
// ignores the cast produces a detectably wrong edge instead of a silent pass.
// ---------------------------------------------------------------------------
describe('Java cast receiver resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-cast-receiver'), () => {});
}, 60000);
it('detects the caller plus target and decoy classes', () => {
expect(getNodesByLabel(result, 'Class')).toEqual([
'App',
'Box',
'Fallback',
'Shape',
'Target',
'Wrapper',
]);
});
it('resolves simple cast ((Box) obj).open() to Box.open, not declared-type Wrapper.open', () => {
const calls = getRelationships(result, 'CALLS');
const openCall = calls.find((c) => c.target === 'open' && c.source === 'castSimple');
expect(openCall).toBeDefined();
expect(openCall!.targetFilePath).toBe('models/Box.java');
});
it('does not emit an open() edge to the decoy Wrapper', () => {
const calls = getRelationships(result, 'CALLS');
expect(
calls.some((c) => c.target === 'open' && c.targetFilePath === 'models/Wrapper.java'),
).toBe(false);
});
it('resolves nested CFR cast ((Target)((Object)expr)).render() to Target.render', () => {
const calls = getRelationships(result, 'CALLS');
const renderCall = calls.find((c) => c.target === 'render' && c.source === 'castNested');
expect(renderCall).toBeDefined();
expect(renderCall!.targetFilePath).toBe('models/Target.java');
});
it('does not emit a render() edge to the inner cast or to the decoy Shape (expr declared type)', () => {
const calls = getRelationships(result, 'CALLS');
expect(
calls.some((c) => c.target === 'render' && c.targetFilePath === 'models/Shape.java'),
).toBe(false);
});
it('resolves cast + this.field ((Target)((Object)this.held)).draw() to Target.draw', () => {
const calls = getRelationships(result, 'CALLS');
const drawCall = calls.find((c) => c.target === 'draw' && c.source === 'castThisField');
expect(drawCall).toBeDefined();
expect(drawCall!.targetFilePath).toBe('models/Target.java');
});
it('does not emit a draw() edge to the decoy Shape (field declared type)', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.some((c) => c.target === 'draw' && c.targetFilePath === 'models/Shape.java')).toBe(
false,
);
});
// ((String) obj).act(): `String` is a resolvable-SHAPE cast type (simple
// identifier) that is not locally indexed, so resolution deliberately falls
// back to obj's OWN declared type (Fallback). This is intentionally kept,
// unlike the unparseable-cast case (#2353 review F1), whose criterion is:
// a paren group that is type-shaped but UNPARSEABLE (generic / array / FQN)
// must resolve to nothing, because falling through to the pre-cast
// expression's declared type emits a confident wrong edge. Here the cast IS
// parseable — it just names a type we didn't index — so no better
// information exists, and upcast casts make the declared type a plausible
// dispatch target. Residual risk kept visible: a cross-cast to an unindexed
// sibling type would still emit this declared-type fallback edge.
it('falls back to the declared type for a cast to an unindexed simple type (String)', () => {
const calls = getRelationships(result, 'CALLS');
const actCall = calls.find((c) => c.target === 'act' && c.source === 'castUnindexedType');
expect(actCall).toBeDefined();
expect(actCall!.targetFilePath).toBe('models/Fallback.java');
});
});
// ---------------------------------------------------------------------------
// Unparseable casts (#2353 review F1): a receiver whose paren group is
// TYPE-SHAPED but unparseable — generic (Box<String>), array (Box[]),
// fully-qualified (models.Box) — is a cast the resolver cannot look up.
// It must resolve to NOTHING (pre-#2353 behavior): stripping the parens and
// falling through resolves the pre-cast expression's own declared type and
// emits a confident wrong CALLS edge (reason "import-resolved") to the decoy.
// Every assertion is source-scoped (c.source === caller method) so it cannot
// collide with the positive-shape scenarios pinned above.
// ---------------------------------------------------------------------------
describe('Java unparseable cast receiver resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-cast-receiver'), () => {});
}, 60000);
const callTargets = (source: string, target: string): string[] =>
getRelationships(result, 'CALLS')
.filter((c) => c.source === source && c.target === target)
.map((c) => `${c.source} → ${c.target} @ ${c.targetFilePath}`);
it('emits no open() edge for a generic cast ((Box<String>) obj) — declared-type decoy Wrapper', () => {
expect(callTargets('castGeneric', 'open')).toEqual([]);
});
it('emits no act2() edge for an array cast ((Box[]) obj) — declared-type decoy Wrapper', () => {
expect(callTargets('castArray', 'act2')).toEqual([]);
});
it('emits no act3() edge for a fully-qualified cast ((models.Box) obj) — declared-type decoy Wrapper', () => {
expect(callTargets('castQualified', 'act3')).toEqual([]);
});
it('emits no act4() edge for a generic-FQN cast over this.field — field declared-type decoy Shape', () => {
expect(callTargets('castGenericFqnThisField', 'act4')).toEqual([]);
});
it('leaves a non-cast parenthesized receiver untouched — no crash, no fabricated edge', () => {
const fromNonCast = getRelationships(result, 'CALLS')
.filter((c) => c.source === 'nonCastParen')
.map((c) => `${c.source} → ${c.target} @ ${c.targetFilePath}`);
expect(fromNonCast).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// this.field chains (#2353 review F4/F5/F7): resolved by the generic
// per-segment chain walker — the head `this` segment resolves via the
// synthesized Function-scope typeBinding, each following segment via
// class-scope typeBindings. Initializer-context sites (instance
// initializer block / field initializer) have no function scope and
// therefore no synthesized `this` binding; they resolve via the
// literal-`this` head seed (enclosing class def) and attribute their
// CALLS edge to the enclosing Class node. Every scenario has a decoy
// class (Decoy) owning a same-named method, so a wrong resolution
// emits a detectable edge.
// ---------------------------------------------------------------------------
describe('Java this.field chain resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-this-field-chain'), () => {});
}, 60000);
it('detects the caller plus target and decoy classes', () => {
expect(getNodesByLabel(result, 'Class')).toEqual([
'App',
'Core',
'Decoy',
'Engine',
'Mapper',
'Monitor',
'Report',
'ReportFactory',
'Result',
]);
});
it('resolves one-hop this.engine.start() to Engine.start', () => {
const calls = getRelationships(result, 'CALLS');
const edge = calls.find((c) => c.source === 'chainOneHop' && c.target === 'start');
expect(edge).toBeDefined();
expect(edge!.targetFilePath).toBe('models/Engine.java');
});
it('resolves two-hop this.engine.core.ignite() through two typed fields to Core.ignite', () => {
const calls = getRelationships(result, 'CALLS');
const edge = calls.find((c) => c.source === 'chainTwoHop' && c.target === 'ignite');
expect(edge).toBeDefined();
expect(edge!.targetFilePath).toBe('models/Core.java');
});
it('resolves this.monitor.watch() inside an instance initializer block to Monitor.watch', () => {
const calls = getRelationships(result, 'CALLS');
const edge = calls.find((c) => c.source === 'App' && c.target === 'watch');
expect(edge).toBeDefined();
expect(edge!.targetFilePath).toBe('models/Monitor.java');
});
it('resolves the field initializer this.factory.make() to ReportFactory.make', () => {
const calls = getRelationships(result, 'CALLS');
const edge = calls.find((c) => c.source === 'App' && c.target === 'make');
expect(edge).toBeDefined();
expect(edge!.targetFilePath).toBe('models/ReportFactory.java');
});
// #2353 review F5: the dot inside the string argument must not break
// chain segmentation — both the middle-of-chain lookup() call and the
// chained run() call resolve to their declaring classes.
it('resolves a chain whose call argument contains a dot — this.mapper.lookup("a.b").run()', () => {
const calls = getRelationships(result, 'CALLS');
const lookupEdge = calls.find((c) => c.source === 'chainDottedArg' && c.target === 'lookup');
expect(lookupEdge).toBeDefined();
expect(lookupEdge!.targetFilePath).toBe('models/Mapper.java');
const runEdge = calls.find((c) => c.source === 'chainDottedArg' && c.target === 'run');
expect(runEdge).toBeDefined();
expect(runEdge!.targetFilePath).toBe('models/Result.java');
});
// Consistency guard: no this-only special-casing — an identically-shaped
// parameter-receiver chain (same classes) resolves to the same target.
it('resolves an identically-shaped obj.field.method() chain the same way as the this. variant', () => {
const calls = getRelationships(result, 'CALLS');
const paramEdge = calls.find((c) => c.source === 'chainOneHopParam' && c.target === 'start');
expect(paramEdge).toBeDefined();
expect(paramEdge!.targetFilePath).toBe('models/Engine.java');
const thisEdge = calls.find((c) => c.source === 'chainOneHop' && c.target === 'start');
expect(thisEdge).toBeDefined();
expect(thisEdge!.targetFilePath).toBe(paramEdge!.targetFilePath);
});
it('emits no CALLS edge to any decoy method', () => {
const calls = getRelationships(result, 'CALLS');
const decoyEdges = calls
.filter((c) => c.targetFilePath === 'models/Decoy.java')
.map((c) => `${c.source} → ${c.target} @ ${c.targetFilePath}`);
expect(decoyEdges).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Bare-`this` dispatch pinning (#2353 review F6): Java `this.member` sites
// resolve through Case 4 — the synthesized Function-scope `this` typeBinding
// (languages/java/receiver-binding.ts) feeding the MRO walk — NOT through the
// C++-authored Case 0.5 chain walk gated by `resolveThisViaEnclosingClass`
// (receiver-bound-calls.ts). PR #2353 briefly enabled that flag for Java; U7
// reverted it per the toggle's own contract doc. These scenarios characterize
// the Case 4 baseline (characterization, not idealization — two deliberate
// baseline quirks are pinned with deferred-item comments below), and the
// interface-default fan-out scenario is the A/B discriminator: Case 0.5
// provably drops the interface-dispatch edges that only Case 4 emits.
// ---------------------------------------------------------------------------
describe('Java bare-this dispatch (Case 4 pinning)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-this-dispatch'), () => {});
}, 60000);
it('detects the hierarchy, collision, and interface fixture types', () => {
expect(getNodesByLabel(result, 'Class')).toEqual([
'Base',
'Derived',
'FastTask',
'Runner',
'SizeDecoy',
'SlowTask',
'Widget',
]);
expect(getNodesByLabel(result, 'Interface')).toEqual(['Task']);
});
// Characterization, not idealization: `this.greet("world")` (arity 1)
// inside Derived binds to Derived.greet(String, int) — Case 4's
// `pickFirstNonStaticOnly` short-circuits on a single-overload owner
// without arity narrowing, so the inherited Base.greet(String) never gets
// a look. The ideal Base.greet target is the deferred Case 4 arity item
// (docs/plans/2026-07-02-001 § Deferred). Under PR #2353's Case 0.5, the
// `hiddenByName` C++ name-hiding rule dropped this member site entirely
// and a free-call fallback edge (reason 'local-call') to the same target
// masked the drop.
it('pins this.greet("world") in Derived to Derived.greet(String,int) — arity-blind shortcut', () => {
const calls = getRelationships(result, 'CALLS');
const greetEdges = [
...new Set(
calls
.filter((c) => c.source === 'announce' && c.target === 'greet')
.map((c) => `${c.targetFilePath} reason=${c.rel.reason}`),
),
].sort();
expect(greetEdges).toEqual(['models/Derived.java reason=global']);
});
// Characterization, not idealization: with field `size` AND method
// `size()` on Widget, the bare-this READ `this.size` emits ACCESSES
// reason 'read' targeting the METHOD node — `pickFirstNonStaticOnly`
// consults methods before fields for every site kind, so methods shadow
// fields on read sites too. The read-should-target-the-Property fix is
// the deferred Case 4 methods-shadow-fields item
// (docs/plans/2026-07-02-001 § Deferred).
it('pins the this.size field read to the size() Method node (methods-shadow-fields)', () => {
const accesses = getRelationships(result, 'ACCESSES');
const sizeReads = accesses.filter((e) => e.source === 'describe' && e.target === 'size');
expect(sizeReads.length).toBe(1);
expect(sizeReads[0].rel.reason).toBe('read');
expect(sizeReads[0].targetLabel).toBe('Method');
expect(sizeReads[0].targetFilePath).toBe('models/Widget.java');
});
it('resolves the this.size() call beside the size field to Widget.size()', () => {
const calls = getRelationships(result, 'CALLS');
const sizeCalls = [
...new Set(
calls
.filter((c) => c.source === 'measure' && c.target === 'size')
.map((c) => `${c.targetLabel} @ ${c.targetFilePath}`),
),
].sort();
expect(sizeCalls).toEqual(['Method @ models/Widget.java']);
});
// The A/B discriminator: only Case 4 emits interface-dispatch fan-out
// (`emitInterfaceDispatchFor`); Case 0.5 resolved this same site to
// Task.run WITHOUT the implementor edges. Target-SET assertions rather
// than edge counts, per the deferred duplicate-reference-site quirk.
it('emits the primary this.run() edge from the default method to Task.run', () => {
const calls = getRelationships(result, 'CALLS');
const primaries = [
...new Set(
calls
.filter(
(c) =>
c.source === 'runAll' && c.target === 'run' && c.rel.reason !== 'interface-dispatch',
)
.map((c) => c.targetFilePath),
),
].sort();
expect(primaries).toEqual(['models/Task.java']);
});
it('fans this.run() out to exactly the implementors via interface-dispatch edges', () => {
const calls = getRelationships(result, 'CALLS');
const fanout = [
...new Set(
calls
.filter((c) => c.source === 'runAll' && c.rel.reason === 'interface-dispatch')
.map((c) => c.targetFilePath),
),
].sort();
expect(fanout).toEqual(['models/FastTask.java', 'models/SlowTask.java']);
});
it('interface-dispatch fan-out excludes the interface itself and the non-implementor Runner', () => {
const calls = getRelationships(result, 'CALLS');
const fanout = calls.filter((c) => c.rel.reason === 'interface-dispatch');
for (const edge of fanout) {
expect(edge.targetFilePath).not.toBe('models/Task.java');
expect(edge.targetFilePath).not.toBe('models/Runner.java');
}
});
});

View file

@ -3072,3 +3072,27 @@ describe('TypeScript factory-pattern singleton resolution (issue #1358 sub-case)
]);
});
});
// ---------------------------------------------------------------------------
// Dynamic-this contexts are never seeded from the lexically enclosing class
// (#2353 follow-up): an object-literal method's `this` is the literal, not
// the class instance — the compound resolver's literal-`this` head seed is
// restricted to initializer contexts and must not fire here.
// ---------------------------------------------------------------------------
describe('TS dynamic-this receiver seeding guard', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'ts-dynamic-this-no-seed'), () => {});
}, 60000);
it('detects the App and Router classes', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['App', 'Router']);
});
it('emits no CALLS edge from the object-literal method to Router.go', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.some((c) => c.target === 'go' && c.source === 'onClick')).toBe(false);
});
});

View file

@ -0,0 +1,113 @@
/**
* Unit tests for `stripCastWrappers` — the pure cast-peeling helper in
* `compound-receiver.ts`, consumed by `resolveCompoundReceiverClass`
* when a language opts in via `stripReceiverCastExpressions`.
*
* PR #2353 review F8: the peel loop rescans the working text for the
* matching close paren on every iteration, so adversarial nested-paren
* input (`((((…))))`) cost O(N²) with no iteration cap (the file's
* `COMPOUND_RECEIVER_MAX_DEPTH` guard does not cover this loop). The
* fix adds `MAX_CAST_PEEL`; exceeding it bails all-or-nothing with the
* ORIGINAL text and the not-a-cast outcome. These are the helper's
* first unit tests — they also pin the three-way cast classification
* (KTD2: simple identifier captured / type-shaped-but-unparseable
* reported / anything else untouched) that the Java integration
* fixtures exercise only end-to-end.
*
* The helper is a pure text scan — no fixtures, no pipeline needed.
*/
import { describe, it, expect } from 'vitest';
import { stripCastWrappers } from '../../../src/core/ingestion/scope-resolution/passes/compound-receiver.js';
describe('stripCastWrappers — cast classification (KTD2)', () => {
it.each([
{ input: '((Foo)x)', workingText: 'x', castType: 'Foo' },
{ input: '((Target)((Object)expr))', workingText: 'expr', castType: 'Target' },
{ input: '( Foo ) x', workingText: 'x', castType: 'Foo' },
])('captures the simple cast type in $input', ({ input, workingText, castType }) => {
expect(stripCastWrappers(input)).toEqual({
workingText,
castType,
unresolvableCast: false,
});
});
it.each([
{ input: '(List<String>)obj' },
{ input: '(Foo[])obj' },
{ input: '(com.example.Foo)obj' },
{ input: '( com.example.Foo ) obj' },
])('reports the type-shaped but unparseable cast $input as unresolvable', ({ input }) => {
expect(stripCastWrappers(input)).toEqual({
workingText: input,
castType: undefined,
unresolvableCast: true,
});
});
it('leaves a parenthesized non-cast expression untouched', () => {
expect(stripCastWrappers('(a || b).field')).toEqual({
workingText: '(a || b).field',
castType: undefined,
unresolvableCast: false,
});
});
it('unwraps a plain parenthesized variable without capturing a cast type (KTD2 rule ii)', () => {
// `(foo)` in receiver position (as in `(foo).bar()`) is a
// redundant-paren unwrap of a VARIABLE — capturing `foo` as a cast
// type here is exactly F1's wrong-edge shape.
expect(stripCastWrappers('(foo)')).toEqual({
workingText: 'foo',
castType: undefined,
unresolvableCast: false,
});
});
it('keeps the captured type when a later cast group is unparseable (KTD2 rule iii)', () => {
expect(stripCastWrappers('(Target)(List<String>)obj')).toEqual({
workingText: 'obj',
castType: 'Target',
unresolvableCast: false,
});
});
it('leaves a typeBinding-rawName-shaped input untouched', () => {
// Case 3b / Case 4 pass-through shape (U5's known non-goal): the
// stripper must be a structural no-op on rawName inputs.
expect(stripCastWrappers('Factory.get_user()')).toEqual({
workingText: 'Factory.get_user()',
castType: undefined,
unresolvableCast: false,
});
});
});
describe('stripCastWrappers — MAX_CAST_PEEL iteration cap (#2353 review F8)', () => {
it('bails all-or-nothing with the original text when nesting exceeds the cap', () => {
const input = '('.repeat(100) + 'Type' + ')'.repeat(100);
expect(stripCastWrappers(input)).toEqual({
workingText: input,
castType: undefined,
unresolvableCast: false,
});
});
it('still unwraps nesting under the cap', () => {
const input = '('.repeat(10) + 'Type' + ')'.repeat(10);
expect(stripCastWrappers(input)).toEqual({
workingText: 'Type',
castType: undefined,
unresolvableCast: false,
});
});
it('terminates on unbalanced parens with the text untouched', () => {
expect(stripCastWrappers('(((')).toEqual({
workingText: '(((',
castType: undefined,
unresolvableCast: false,
});
});
});