GitNexus/gitnexus/test/unit/scope-resolution/strip-cast-wrappers.test.ts
Gergő Magyar fa8ebf672e
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>
2026-07-02 17:19:33 +01:00

113 lines
4.1 KiB
TypeScript

/**
* 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,
});
});
});