diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index f78fcf07a..d3383ae84 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -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), 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), 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()`); 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)." } } diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index 117b4b035..9eae59998 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -57,6 +57,7 @@ const javaScopeResolver: ScopeResolver = { propagatesReturnTypesAcrossImports: true, collapseMemberCallsByCallerTarget: true, hoistTypeBindingsToModule: true, + stripReceiverCastExpressions: true, populateNamespaceSiblings: populateJavaPackageSiblings, populateRangeBindings: populateJavaCrossFileReturnTypes, diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index ec2b3ec7d..d10e67da9 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index b1ee3d01b..c465299f9 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -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`), array (`Foo[]`), or combinations + * (`com.example.List[]`) — 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, 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(); + 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` / `ReadonlyMap` (depth-aware). */ function extractShallowMapTypeArgByIndex(mapText: string, wantIndex: number): string | undefined { const t = mapText.trim(); diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 21eef7aaa..1cb85be14 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -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. diff --git a/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/App.java b/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/App.java new file mode 100644 index 000000000..15c4edd84 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/App.java @@ -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) 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) 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(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Box.java b/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Box.java new file mode 100644 index 000000000..0387da271 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Box.java @@ -0,0 +1,7 @@ +package models; + +public class Box { + public void open() { + // cast target for ((Box) obj).open() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Fallback.java b/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Fallback.java new file mode 100644 index 000000000..5f50d7cdf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Fallback.java @@ -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() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Shape.java b/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Shape.java new file mode 100644 index 000000000..e9a3e8125 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Shape.java @@ -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) this.held2).act4(): an + // unparseable cast that falls through to the held2 field's + // declared type resolves here + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Target.java b/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Target.java new file mode 100644 index 000000000..95dfab15f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Target.java @@ -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() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Wrapper.java b/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Wrapper.java new file mode 100644 index 000000000..2cc985abc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-cast-receiver/models/Wrapper.java @@ -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 + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Base.java b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Base.java new file mode 100644 index 000000000..ad3425a20 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Base.java @@ -0,0 +1,7 @@ +package models; + +public class Base { + public String greet(String name) { + return "hi " + name; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Derived.java b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Derived.java new file mode 100644 index 000000000..44e32e36c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Derived.java @@ -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"); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/FastTask.java b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/FastTask.java new file mode 100644 index 000000000..d6c0a957e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/FastTask.java @@ -0,0 +1,7 @@ +package models; + +public class FastTask implements Task { + public String run() { + return "fast"; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Runner.java b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Runner.java new file mode 100644 index 000000000..a7cdb1382 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Runner.java @@ -0,0 +1,7 @@ +package models; + +public class Runner { + public String run() { + return "not a task"; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/SizeDecoy.java b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/SizeDecoy.java new file mode 100644 index 000000000..26b5ab271 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/SizeDecoy.java @@ -0,0 +1,9 @@ +package models; + +public class SizeDecoy { + public int size; + + public int size() { + return 42; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/SlowTask.java b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/SlowTask.java new file mode 100644 index 000000000..8b82ca391 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/SlowTask.java @@ -0,0 +1,7 @@ +package models; + +public class SlowTask implements Task { + public String run() { + return "slow"; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Task.java b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Task.java new file mode 100644 index 000000000..5ebc4c9e0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Task.java @@ -0,0 +1,9 @@ +package models; + +public interface Task { + String run(); + + default String runAll() { + return this.run(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Widget.java b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Widget.java new file mode 100644 index 000000000..d09331b11 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-dispatch/models/Widget.java @@ -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(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/App.java b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/App.java new file mode 100644 index 000000000..d4eb4daeb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/App.java @@ -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(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Core.java b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Core.java new file mode 100644 index 000000000..de1e8de1d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Core.java @@ -0,0 +1,7 @@ +package models; + +public class Core { + public void ignite() { + // two-hop chain target for this.engine.core.ignite() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Decoy.java b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Decoy.java new file mode 100644 index 000000000..aff2d9c4e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Decoy.java @@ -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 + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Engine.java b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Engine.java new file mode 100644 index 000000000..52716a668 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Engine.java @@ -0,0 +1,9 @@ +package models; + +public class Engine { + public Core core; + + public void start() { + // one-hop chain target for this.engine.start() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Mapper.java b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Mapper.java new file mode 100644 index 000000000..e63d06326 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Mapper.java @@ -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(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Monitor.java b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Monitor.java new file mode 100644 index 000000000..c658d9613 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Monitor.java @@ -0,0 +1,7 @@ +package models; + +public class Monitor { + public void watch() { + // instance-initializer-block chain target for this.monitor.watch() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Report.java b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Report.java new file mode 100644 index 000000000..6b47c035e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Report.java @@ -0,0 +1,7 @@ +package models; + +public class Report { + public void archive() { + // gives Report a member; not called anywhere in the fixture + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/ReportFactory.java b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/ReportFactory.java new file mode 100644 index 000000000..3f6157b41 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/ReportFactory.java @@ -0,0 +1,8 @@ +package models; + +public class ReportFactory { + public Report make() { + // field-initializer chain target for this.factory.make() + return new Report(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Result.java b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Result.java new file mode 100644 index 000000000..6aceb418d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-this-field-chain/models/Result.java @@ -0,0 +1,7 @@ +package models; + +public class Result { + public void run() { + // dotted-arg chain target for this.mapper.lookup("a.b").run() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-dynamic-this-no-seed/app.ts b/gitnexus/test/fixtures/lang-resolution/ts-dynamic-this-no-seed/app.ts new file mode 100644 index 000000000..00b1832e4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-dynamic-this-no-seed/app.ts @@ -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(); + }, + }; +} diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 7e764706f..ce6176c47 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -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), 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) 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'); + } + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index ad6f0b9fa..b667c2f31 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/strip-cast-wrappers.test.ts b/gitnexus/test/unit/scope-resolution/strip-cast-wrappers.test.ts new file mode 100644 index 000000000..2c49fb9b9 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/strip-cast-wrappers.test.ts @@ -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)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)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, + }); + }); +});