Skip to content

[Plan] issue-2088: receiver-correlated invoked-property evidence for object-literal value-refs - #2612

Closed
carlos-alm wants to merge 37 commits into
mainfrom
docs/plan-issue-2088
Closed

[Plan] issue-2088: receiver-correlated invoked-property evidence for object-literal value-refs#2612
carlos-alm wants to merge 37 commits into
mainfrom
docs/plan-issue-2088

Conversation

@carlos-alm

@carlos-alm carlos-alm commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Part of #2088. Docs-only — this PR adds a delivery plan, no product code. Merging it does not complete the issue; only the execute PR does.

Plan doc: docs/plans/issue-2088.md


What the issue asks for

collectInvokedPropertyNames (src/domain/graph/builder/call-resolver.ts:91) reduces to:

if (call.receiver && call.dynamicKind !== 'value-ref') names.add(call.name);

Any non-empty receiver anywhere in the processed file set credits the bare property name. So a promise.resolve() in an unrelated file keeps { resolve: neverCalled } from being flagged dead. Same in the native mirror, collect_invoked_property_names (crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs:861).

Confirmed still present on main @ 6221df16.

This is the conservative error direction — a false negative for dead-code detection, never a misclassification of live code as dead. Nothing downstream produces wrong results today. This is a recall improvement to the advisory roles --role dead command, not a soundness fix.

The approach in one paragraph

Give every object literal a stable allocation-site identity, teach the existing Andersen points-to solver to propagate those sites into receiver variables, and credit a property as live only when a receiver that provably points at that literal invokes that key. Gate the whole thing on an escape check: a site whose identity can leave what the solver models keeps today's exact predicate. That is what stops the fix from converting a conservative false negative into a false positive.

The resolver ends up with a three-tier ladder:

Tier Source Status
T1 correlated site|key #2088 (new) Used exclusively when the site is proven local-closed
T2 bare property name #1895 (unchanged) Reached only when the site is absent or escaping — today's behavior
T3 computed-access whole-table #2260 (unchanged) Independent channel, always ORed in, stays name-keyed

T1 being exclusive rather than ORed with T2 is what produces the recall gain; the escapes guard is what keeps it safe. Both are argued in the plan.

The §8.3 tension, addressed head-on

The dispatch brief flagged a possible conflict with ROADMAP §8.3, whose approach is explicitly field-based, not field-sensitive"treat all instances of obj.field as the same abstract location regardless of which obj instance".

There is no conflict: field sensitivity and allocation-site abstraction are orthogonal axes. Field sensitivity is about how fields are abstracted; allocation-site abstraction is about how objects are. §8.3's own Approach block already commits to the latter, in the bullet directly below the field-based one:

Allocation-site abstraction: each new Foo(), function literal, or arrow function creates an abstract object tagged with its source location

and §8.3's single remaining unchecked item is "Full allocation-site abstraction and constraint solver". So this plan delivers a slice of §8.3's own open item rather than deviating from it. The pts lattice stays field-based; the site|key evidence set is computed outside the solver, which never learns about fields. The one real extension — §8.3's allocation-site bullet does not mention object literals — is a roadmap text update in WU-9b.

ADR compliance

  • ADR-002 §"Resolution in the existing points-to solver" is binding and satisfied: new constraint rows land in src/domain/graph/resolver/points-to.ts and the Rust build_points_to_map. No new subsystem; the 50-iteration solver loop (buildCallSiteTypeMap / MAX_SOLVER_ITERATIONS) is untouched.
  • ADR-002 §Trade-offs/Costs.2 names the wasm-worker-{protocol,entry,pool}.ts seam the primary parity-divergence risk, so it is its own work unit (WU-3) with its own verification. Note Call.objectLiteralSite needs no protocol edit — SerializedExtractorOutput.calls is typed Call[] and passed whole (wasm-worker-protocol.ts:51); only top-level ExtractorOutput extras need explicit threading. Verified by reading the file, not assumed.
  • ADR-002 §Costs.5 (RES-2 over-approximation): new fixture cases go to pts-javascript; the javascript fixture's precision-1.0 floor must not move.
  • ADR-001 dual-engine parity: every WU touching extraction or resolution has a named Rust mirror (WU-7, WU-8). Note the native tree has no domain/graph/resolver/ directory — the Rust solver lives inside build_edges.rs, its pre-existing mirror location.

Building on prior art, not duplicating it

collectObjectLiteralValueRefCall already sets a value-ref Call's receiver to the dispatch table's name, feeding computedDispatchTableEvidence (#2260). That is a name-correlated evidence channel and it is kept exactly as-is as T3. #2088 adds a site-correlated tier beside it. The array-literal gap in #2260's own channel is filed separately (see below) rather than folded in.

Shape of the work

10 work units. Critical path is WU-1 → WU-2 → WU-7 → WU-8 → WU-10 → WU-9b — the bottleneck is the Rust chain, since WU-7 is a line-for-line mirror that should not start until the TS escape analysis is settled, and WU-10 cannot start until both engines are done because half of what it asserts is that they agree.

DB: migration v32 (current latest is v31) adds object_literal_sites and invoked_property_sites, both persisted and purged per-file exactly as invoked_property_names (#2087) is — deliberately not the in-memory-only shortcut #2260 took.

Config: exactly one new DEFAULTS key, analysis.correlatedPropertyEvidence. Setting it false restores pre-#2088 behavior exactly. No new language, no LANGUAGE_REGISTRY/AST_TYPE_MAPS/LangAstConfig change, no new runtime dependency.

What no test can prove — reviewer attention needed here

The escape analysis is a judgment about completeness. The tests prove the recognised shapes are classified correctly and that the fail-safe default is escapes: true; they cannot prove the recognised set is exhaustive.

A human reviewer must read computeObjectLiteralSiteEscapes (WU-2b) and its Rust mirror against TRACKED_REFERENCE_PARENTS and confirm every position not in that set is genuinely treated as an escape. That review is the real gate on the plan's soundness requirement. This is called out explicitly in the plan's Testing Strategy rather than left implicit.

Nine existing tests form the regression contract and must pass unedited — notably issue-1895-value-ref-invocation-check, whose fixture literal is returned from an exported makeTable() and therefore escapes and resolves on T2, i.e. today's exact path.

Out of scope — filed, not carried in prose

Plan provenance

Round 1. No plan-carry-forward artifact exists on #2088gh api .../issues/2088/comments --paginate returns zero comments carrying the sentinel, from any author, trusted or not. Everything in the plan is derived fresh from live source at 6221df16.

Verification status of this PR

Docs-only. npm run lint was run and passes (Biome is scoped to src//tests/, neither touched). It reports 1 pre-existing warning in src/graph/algorithms/louvain.ts:135 (ineffective biome-ignore suppression) — an untouched file, left alone per CLAUDE.md's "don't clean up lint issues in files you aren't working on". Flagging it rather than silently absorbing it.


✋ Human approval gate (/oversee)

  • Task: issue-2088 (issue follow-up: receiver-type-aware invoked-property matching to reduce dead-code false negatives #2088)
  • Plan doc: docs/plans/issue-2088.md
  • Current head: 8830670d
  • Critic verdict:PASSED at 59bd5752 — no blocking findings
  • Since that verdict: 8830670d, documentation-only (verified no-logic-change by full diff), closing the four non-blocking items the passing critic itself named
  • Revise rounds: 24 revision commits across 29 review rounds
  • Provenance: oversee/plan-gate=success on the current head

✅ Recommended for approval — with one trade-off you should weigh first.

The soundness bar was met and independently verified by execution. What remains is a
scope decision no reviewer can make for you. It is stated at the bottom of this gate.

What was verified, and how

The bar throughout: can this design ever report live code as dead? Today's
collectInvokedPropertyNames credits any truthy receiver and structurally cannot, so the
plan's whole safety argument is that it never converts that conservative false negative into
a false positive.

The final review drove the real pipelineparseFileAuto, the real points-to solver,
the real findCaller, cross-checked against a real codegraph build — not a model of it.

  • No under-escape path exists, and it is structurally impossible for the final change: the
    probe set feeds only collectInvokedPropertySites, whose sole effect is keys.add(...). It
    never touches escape analysis, nonEscapingSites, or the localClosed predicate.
  • Every fixture is non-vacuous. 86 escape-fallback cases and 36 correlation shapes, each
    verified to flip under ablation of the specific fix it covers.
  • Both engines checked against real Rust and TypeScript source, not paraphrase.

The trade-off, quantified

The final fix is a tolerance, not a tightening. The extractor and the resolver name scopes
differently, so the site lookup probes four keys instead of one. That cross-credits some
sites — a recall cost, independently raised by Greptile and confirmed rather than disputed.

Measured on codegraph's own src/ — 113 files with a for-of, 577 for-of bindings, 68 relevant
call sites:

resolving call sites colliding
single probe 28 2
widened 31 3

+3 resolved, +1 collision (1.5%). The severity is bounded by proof, not assertion: T1's
over-credit is a subset of T2's admit set, so the worst case is byte-identical to pre-#2088
behaviour for the affected pairs. It can never mark live what #1895 marked dead, and never
marks anything dead.
The proper fix — reconciling the two naming schemes in the extractor —
is filed as #2647, out of this plan's scope.

How the review got here

Four standing rules now govern the plan, each added after a failure the previous level could
not catch:

Rule Added after
Guard fixtures — prove a tightening does not over-escape A fix broke a verified-sound branch while "improving" it
Fixture-parse — confirm the node type a fix keys on exists A round shipped dead code and closed an issue on it
Ablation — remove a fix; its fixture must flip A rebuilt guard still passed with its own fix removed
Harness derivation — derive inputs the way the real pipeline does A harness built a permissive exportedNames and validated five false claims

The last one matters most: a verification model more permissive than reality confirms exactly
the claims reality falsifies — that gap survived twenty-five rounds and ~1M executed
combinations, because volume of execution cannot correct a miscalibrated input.

Residual limitations — all recall-direction, all tracked

Every excluded shape carries a direction label and a real open issue: #2610, #2611, #2617#2625,
#2627, #2629#2636, #2638#2643, #2645#2647. Under the plan's own standing rule an
UNDER-escape gap may never be filed as an accepted limitation; every one of these is
OVER-escape, except #2610, which is pre-existing and verified not worsened.

Reviewing this plan also surfaced #2628, #2639, #2643 and #2647 — suspected bugs
in shipped product code, unrelated to #2088.

The decision that is actually yours

Soundness has stopped being the interesting variable. T1 correlation now fires for a
deliberately narrow core, with roughly thirty tracked exclusions describing what it will not
catch, and the final tolerance trades a further 1.5% of precision for coverage.

Against that: 10 work units, a dual-engine mirror, and a v32 DB migration — for a recall
improvement to an advisory command (roles --role dead).

That trade is a judgement no critic can make for you, and it is the last thing standing
between this plan and a decision.


Review the plan above. To approve it for execution, tick this box, then run /oversee #2612:

  • APPROVED FOR EXECUTION — I have reviewed this plan and approve building it

Delivery plan for #2088: replace the bare-property-name liveness check for
object-literal value-refs with allocation-site correlation, gated on an
escape check so escaping sites keep today's exact (conservative) behavior.

Reconciles the apparent tension with ROADMAP 8.3's field-based points-to
choice: field sensitivity and allocation-site abstraction are orthogonal
axes, and 8.3's own Approach block already commits to the latter.

Part of #2088
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Heads up: this PR references #2260, #2087 without a closing keyword (Closes #N / Fixes #N). If this PR fully resolves #2260, #2087, update the description so the issue auto-closes on merge — otherwise disregard this comment.

@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a docs-only delivery plan for allocation-site-correlated invoked-property evidence while preserving conservative fallback behavior.

  • Defines TypeScript and Rust implementation work across extraction, points-to resolution, persistence, and incremental paths.
  • Specifies escape-analysis safeguards, dual-engine parity, configuration, migration, and regression coverage.
  • Documents the measured receiver-scope probing trade-off and tracked follow-up limitations.

Confidence Score: 5/5

This docs-only planning PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
docs/plans/issue-2088.md Adds the implementation plan and incorporates the prior review fixes, with no remaining eligible issue identified.

Reviews (48): Last reviewed commit: "docs(plan): correct stale declaringScope..." | Re-trigger Greptile

Comment thread docs/plans/issue-2088.md Outdated
Comment on lines +459 to +461
if (owner.bindingName === null) { // `return { … }` — no binding to scan
entry.escapes = exportedNames.has(owner.enclosingFn);
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Returned sites bypass escape tracking

For export const T = makeTable() with a non-exported local makeTable, this branch marks the returned literal local-closed solely from the factory's export status even though the planned call-assignment constraint propagates it into exported T. Exclusive correlated evidence then omits external calls to T, allowing live properties to be reported dead; the plan should account for destinations of returned sites before approving execution.

Fix in Claude Code

@carlos-alm carlos-alm Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the prior round (commit 8aa0167, "fix WU-2b's return-owner escape gap"): the owner.bindingName === null branch now unconditionally sets entry.escapes = true for a returned-site owner, regardless of the factory function's own export status, since the call-assignment that actually captures the return value can land in any binding this per-file pass can't see. Confirmed still in place at the current head.

Comment thread docs/plans/issue-2088.md
Comment on lines +418 to +422
const TRACKED_REFERENCE_PARENTS: ReadonlySet<string> = new Set([
'member_expression',
'subscript_expression',
'for_in_statement',
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Alias references force coarse fallback

In the promised const u = T; u.alpha() case, the reference to T has a variable_declarator parent, which this allowlist rejects and therefore marks the site escaping. The test can still pass through bare-name fallback, leaving unrelated same-named calls able to preserve aliased properties and failing to verify the advertised alias correlation.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

@carlos-alm carlos-alm Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 899188d. allReferencesTracked's condition 3 now recognizes a variable_declarator reference as tracked when its name field is a plain identifier — i.e. const u = T (the alias shape fnRefBindings already models) — while a destructuring name such as const { k } = T still escapes, since it extracts a property rather than aliasing the reference and fnRefBindings doesn't model it. The guard mirrors the existing name?.type !== 'identifier' check in findEnclosingTableName (src/extractors/javascript.ts:4519), which already draws this exact distinction for table-name resolution.

I also strengthened WU-10's correlation test: each of the four cases now must assert escapes = 0 for its site, not just the liveness outcome — otherwise, as you noted, the alias case could keep passing via T2's bare-name fallback even if this guard regressed, without the test ever catching it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@_scratch_reply1.txt

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disregard the reply directly above this one (the literal text @_scratch_reply1.txt) — that was a broken tool call from an earlier round, posted to the wrong thread by mistake, not a real comment. This thread's actual answer is the reply already above it (Fixed in 899188d7...), which stands unchanged. The intended target of that broken reply was the separate "Property extraction bypasses escape tracking" thread on this same file, which now has a proper reply.

…2612)

WU-8 (and the Dual-Engine Impact table) named
src/domain/graph/builder/stages/native-orchestrator.ts and a
computedDispatchTableEvidence precedent there for threading
objectLiteralSites across the native NAPI boundary. Neither exists:
native-orchestrator.ts has zero occurrences of that field and takes no
part in NAPI payload construction — it only runs tryNativeOrchestrator's
post-build JS passes (CHA expansion, this-dispatch, structure,
dataflow-vertices), which execute after Rust's own full-pipeline build
already extracted and consumed that evidence entirely in Rust memory.
FileEdgeInput, the Rust struct WU-8 cited, is Rust-only and never
appears under src/; its actual TS-side counterpart is NativeFileEntry
in build-edges.ts, which already carries computedDispatchTableEvidence.

Corrected WU-8's Files/Input contract/Implementation to name
NativeFileEntry/buildNativeFileEntry (build-edges.ts) and FileEdgeInput
(build_edges.rs) explicitly, added the matching row and a seam
paragraph to the Dual-Engine Impact table, and closed the verification
gap the wrong citation created: a plain full-build engine comparison
never reaches buildCallEdgesNative because tryNativeOrchestrator's fast
path returns early first. Documented the exact mechanism by which the
plan's existing --engine wasm -> --engine native verification pair
already forces that path (an engine-mismatch-triggered
forceFullRebuild), and marked that command order load-bearing so it
isn't silently broken by a future reordering.
…tion (#2612)

WU-2b's computeObjectLiteralSiteEscapes marked a return-statement-owned
site (`function f() { return {...} }`) local-closed whenever the
factory function itself was not exported, via
exportedNames.has(owner.enclosingFn). That checks the wrong binding:
the value a factory returns is captured by a call-assignment
(`const X = f()`) that can land in any binding, anywhere, and WU-4's
buildObjectLiteralSiteConstraints already flows the site into that
binding's pts set unconditionally, with no escape check of its own. A
return-owned site could therefore be marked non-escaping while a
capturing binding it has no visibility into is exported or otherwise
untracked, making T1 exclusive and letting a live property be reported
dead -- the exact failure direction #2088's soundness requirement
exists to rule out. Independently corroborated by Greptile's review
comment on the PR (id 5390203404, last updated after the round-1 fix
commit, so it reflects the current text, not a stale one).

Fixed the branch to always mark a return-owned site escaping --
consistent with the fail-safe default this analysis already uses
everywhere else -- and corrected condition 1 of the docstring, which
had listed the return-statement shape as one that could reach
non-escaping. Added WU-10 escape-fallback shape (d), covering
`function factory(){ return {...} } export const X = factory();
X.zeta();`, asserting both liveness and escapes === 1, as the
regression gate for this branch.

WU-5a also justified resolveReceiverSites' caller-scoped-then-bare pts
lookup by citing a function, resolveReceiverPtsAliases, that does not
exist anywhere in the repo, at incremental.ts:1436 (which is a
different function, emitIncrementalPtsReceiverEdges, doing a single
compound-key lookup, not the two-step pattern claimed). Replaced the
citation with real precedents: resolveReceiverEdge
(call-resolver.ts:773-775, same file), which build-edges.ts:2123
already mirrors by name, and the ptsMap-specific scopedPtsKey fallback
in emitPtsNoReceiverEdges (build-edges.ts:1965) / its incremental
mirror emitIncrementalPtsNoReceiverEdges (incremental.ts:1350).
…tions (#2612)

const u = T; u.alpha() previously fell through the escape allowlist (no
variable_declarator case), so the alias site was marked escaping and the
correlation test could only ever pass via T2's bare-name fallback -- never
actually exercising the T1 correlated-evidence path the alias test case
claims to cover.

Add a variable_declarator case to allReferencesTracked/condition 3, guarded
on the declarator's name field being a plain identifier (not a destructuring
pattern) -- mirroring the existing name?.type !== 'identifier' guard in
findEnclosingTableName -- so const u = T is recognized as the alias shape
fnRefBindings already models, while const { k } = T (property extraction,
not aliasing) still escapes, matching the escape-fallback test's own
destructuring case.

Also strengthen WU-10's correlation test to assert escapes = 0 for each of
the four cases, not just the liveness outcome, so a future regression in the
escape guard cannot pass silently through T2.
@carlos-alm

carlos-alm commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

Addressed in 899188d:

  • Alias references force coarse fallback: allReferencesTracked now tracks a variable_declarator reference when its name field is a plain identifier (const u = T), while still rejecting destructuring (const { k } = T) — see reply on the inline comment for details. Also strengthened WU-10's correlation test to assert escapes = 0 per case so it can't pass vacuously through T2.
  • Returned sites bypass escape tracking: confirmed already fixed by the prior round (8aa0167) and still in place at the current head — see reply on that inline comment.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review — re-posting this mention as a fresh comment (the prior comment's mention was only added via a later edit due to a gh api flag mistake on my end, which may not fire your webhook the same way a new comment does).

Summary of what changed in 899188d, also detailed in my replies on both inline threads:

  • Alias references force coarse fallback: allReferencesTracked now tracks a variable_declarator reference when its name field is a plain identifier (const u = T), while still rejecting destructuring (const { k } = T). WU-10's correlation test now also asserts escapes = 0 per case so it can't pass vacuously through T2.
  • Returned sites bypass escape tracking: confirmed already fixed by the prior round (8aa0167) and still in place at the current head.

Comment thread docs/plans/issue-2088.md Outdated

`resolveSiteOwner` reuses the existing walk shape of `findEnclosingTableName` (variable-declarator lookup through `TABLE_NAME_PASSTHROUGH_TYPES`), extended with two extra cases — `array` parent → `` `${arrayVarName}[*]` `` (the pts key `buildArrayElemConstraints` already produces), and `return_statement` parent → `` `${enclosingFnName}::return` ``.

`allReferencesTracked` walks the file for identifier nodes whose text equals `bindingName`, skipping the declaration itself and any node under a scope that shadows the name — reusing `introducesShadowedBinding`, the hardened shadow detection already written for #2257 and already used by `findDeclaringScopeLine`. Every surviving reference must have a parent in `TRACKED_REFERENCE_PARENTS`; be the `value` field of a `variable_declarator` whose own `name` field is a plain `identifier` (a rebinding — `const u = T` — rejecting a destructuring `name` the same way `findEnclosingTableName` already does, since destructuring extracts a property rather than aliasing the reference); or be an `arguments`-position identifier whose callee is in `localNonExportedFns`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Alias uses bypass escape tracking

When T is aliased with const u = T and u is then passed to an imported or otherwise unmodeled function, allReferencesTracked accepts the declarator while scanning only references to T, so the site is marked local-closed despite escaping through u. Exclusive correlated evidence then omits external property invocations and can report a live object-literal value as dead.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 62606d0. The rebinding branch of allReferencesTracked now recurses: accepting const u = T additionally requires allReferencesTracked to also hold for u itself, capped at 6 hops (reusing findEnclosingTableName's existing bound as defense-in-depth against a pathological alias chain, since a chain of fresh const bindings can't actually cycle). So const u = T; importedFn(u) now correctly escapes. Added case (e) to WU-10's escape-fallback test list covering exactly this shape.

You're right that the same transitivity gap exists on the paramBindings branch (a bare-identifier argument to a local non-exported function) — verified against the real, already-shipped buildParamFlowConstraints in src/domain/graph/resolver/points-to.ts, which has no escape-safety check of its own. That's a separate, currently-unreported branch of the same condition, and fixing it needs to scope the recursive check into the callee's own body rather than reuse this same file-level walk, so I filed it separately as #2617 rather than folding it into this fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@_scratch_reply2.txt

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disregard the reply directly above this one (the literal text @_scratch_reply2.txt) — that was a broken tool call from an earlier round, posted to the wrong thread by mistake, not a real comment. This thread's actual answer is the reply already above it (Fixed in 62606d04...), which stands unchanged. The intended target of that broken reply was the separate "Property extraction bypasses escape tracking" thread on this same file, which now has a proper reply.

Greptile's re-review of the round-3 fix (899188d) caught a hole in it:
accepting a `const u = T` reference as tracked only checked that ONE
reference to T, never following where u itself goes afterward. So
`const u = T; importedFn(u); u.alpha();` could be classified local-closed
even though the site escapes through u -- the same shape of gap condition 1
already documents for a return-captured binding, recurring one hop later
for an alias-captured one.

Make the rebinding branch of allReferencesTracked recursive: accepting
`const u = T` now additionally requires condition 3 to hold, recursively,
for u itself, capped at 6 hops (reusing findEnclosingTableName's existing
bound) as defense-in-depth against a pathological alias chain. Add a
regression case to WU-10's escape-fallback test list covering exactly this
shape.

The bare-identifier-argument (paramBindings) branch has the identical
transitivity gap -- verified against the real, already-shipped
buildParamFlowConstraints in src/domain/graph/resolver/points-to.ts, which
has no escape-safety check of its own -- but fixing it requires scoping a
recursive check into the callee's own body rather than reusing the same
file-level walk, so it's filed separately as #2617 rather than folded in
here.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

Addressed in 62606d0 — see reply on the inline comment for the alias-transitivity fix and issue #2617 for the analogous param-flow branch filed separately.

Review of the plan at 62606d0 found a structural twin of the
alias-transitivity gap that commit fixed, on the parameter-passing
branch of condition 3: a bare-identifier argument to a locally-defined,
non-exported function was accepted as a tracked reference without ever
inspecting what the callee does with the parameter. The primitive this
relies on, buildParamFlowConstraints (points-to.ts), only adds
pts(callee::paramName) <- pts(argName) and is documented "Scope:
intra-module only" -- it has no escape check of its own.

Concretely: `const P = { beta: fnB }; function use(t) { register(t); }
use(P);` with `register` imported. P is unexported, its only reference
is the param-pass into `use`, and `use` is local and unexported, so the
old condition 3 read the site as local-closed. T1 became exclusive, T2
was skipped, and the real `t.beta()` inside the imported module
produces no site token, since cross-module SITE propagation doesn't
exist. fnB would be reported dead where today's exact predicate reports
it live -- the false-negative-to-false-positive conversion this plan's
own hard rule forbids.

Apply the conservative remedy instead of recursing into the callee body
(the recursive variant stays out of scope, per #2617): drop the
parameter-flow branch from condition 3 entirely, so a bare-identifier
argument always marks the site escaping and falls back to T2. Move
WU-10's correlation case 4 (which asserted escapes = 0 for this shape)
into the escape-fallback test as new case (f), asserting escapes = 1
and live via T2. Correct WU-4's "comes for free" claim and Success
Criteria bullet 3, both of which advertised param-flow as part of the
correlated set, and update the shape counts this ripples into (three
correlation shapes, six escape-fallback shapes) throughout the doc.

#2617 is re-scoped from a plan defect to a recall enhancement:
extending correlation to the param-flow branch by recursing into the
callee's own body remains a legitimate follow-up, just not required
for this plan to be sound.
Comment thread docs/plans/issue-2088.md
Comment on lines +421 to +425
const TRACKED_REFERENCE_PARENTS: ReadonlySet<string> = new Set([
'member_expression',
'subscript_expression',
'for_in_statement',
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Property extraction bypasses escape tracking

When a property value leaves through an expression such as const u = T; importedFn(u.alpha), allReferencesTracked accepts u because its direct parent is a member_expression without checking the enclosing expression's use. The site is consequently treated as local-closed, so exclusive correlated evidence can report alpha dead even though the recipient invokes it.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed, in the commit right after this comment was posted: 9156a889 ("gate tracked reference positions on T1 visibility in WU-2").

u's reference inside u.alpha in importedFn(u.alpha) is a bare (non-call) property read, structurally identical to const f = T.k; f()'s bare read of T.k — which 9156a889's isTrackedReferencePosition narrowing now excludes explicitly: a member-expression reference is tracked only when it is itself the object of a member expression that is in turn the function of an enclosing call_expression. In importedFn(u.alpha), u.alpha's parent is the call's argument list, not u.alpha itself being called — so the grandparent-is-call_expression-with-matching-function-field check fails, isTrackedReferencePosition returns false for u's reference, and (since this is a non-vacuous, genuinely failing reference) allReferencesTracked correctly returns false for u — so T is classified escaping via the round-4 rebinding recursion, exactly as intended.

WU-10's escape-fallback case (h) (const R = { beta: fnF }; const f = R.beta; f();) is the direct-owner regression gate for this exact shape; this specific alias variant (const u = T; importedFn(u.alpha)) is one recursion hop further through the round-4 rebinding branch, and is covered by the same fix since the call-position check applies uniformly to every reference allReferencesTracked walks, direct or recursive.

Re-confirmed unaffected by this PR's latest round (round 7, five further gaps closed — see the new top-level comment): round 7 adds an isArrayOwner short-circuit that runs before this call-position check, and T here is a direct (non-array) binding, so isArrayOwner is false and this reasoning is unchanged.

(Note: two replies landed on the wrong threads on this PR around this same time, each with a literal @_scratch_replyN.txt placeholder body instead of real content — a broken tool call from an earlier round, not a comment from a human. Please disregard those two; this reply is the actual answer to this thread.)

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

New head since your last pass: 515140a3 ("docs: treat param-flow positions as escaping in WU-2 condition 3").

Both of your earlier inline findings were answered against the heads that followed them, and each has a reply on its own thread:

  • "Alias references force coarse fallback" — addressed in 899188d7; condition 3 now recognises a variable_declarator rebinding (const u = T) as a tracked reference.
  • "Alias uses bypass escape tracking" (P1) — addressed in 62606d04; the rebinding branch now recurses into the alias's own references (depth-capped at 6), so const u = T; importedFn(u) correctly marks the site escaping. WU-10 escape case (e) is the regression gate.

What 515140a3 changes, from an independent review of the plan at 62606d04: the parameter-passing branch of condition 3 had the same transitivity gap the alias branch had just closed. It credited a bare-identifier argument to a local, non-exported function as tracked without inspecting what the callee does with that parameter, so const P = { beta: fnB }; function use(t) { register(t); } use(P); (with register imported) marked the site local-closed and could report fnB dead while it is live.

That branch is now dropped: a bare-identifier argument always marks the site escaping, falling back to T2 (today's exact bare-name predicate). WU-4's "parameter flow comes for free" claim is corrected, and WU-10's old correlation case 4 moved into the escape-fallback test as case (f) asserting escapes = 1 — it previously asserted escapes = 0, which would have locked the unsound classification into the test suite.

Extending correlation to the parameter-flow shape properly (recursing into the callee body) is tracked as a follow-up recall enhancement in #2617, not deferred plan work.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review. Commit 515140a fixes the parameter-flow branch of WU-2b's escape analysis (condition 3): a bare-identifier argument to a function is no longer treated as a tracked reference, so such sites always fall back to today's exact bare-name predicate instead of being credited as correlated evidence. See the inline replies on the two alias-branch threads for confirmation those fixes are unaffected, and the comment on #2617 re-scoping it from a plan defect to a follow-up recall enhancement.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

Commit 9156a88 fixes a new escape-analysis gap in WU-2 found in review after 515140a. TRACKED_REFERENCE_PARENTS was a bare tree-sitter node-type set, so parent-type membership alone let two more shapes through as wrongly "tracked" (non-escaping) even though T1 never actually gets correlated evidence for them:

  1. this.k() called on a sibling property from within the SAME literal (const T = { alpha: fnA, run() { return this.alpha(); } }; T.run();) — nothing binds this to the literal's site (the solver's only this key is ${callee}::this, seeded from thisCallBindings for .call(ctx) shapes only), so T1 finds zero evidence while the reference itself looked tracked.
  2. A bare (non-call) member/subscript read — T's parent is member_expression in const f = T.k; exactly as in T.k(), but only the latter is a call.

Fix, in order:

  • Stated an explicit invariant at the definition of the tracked set: a position may be listed only if EVERY invocation reachable through it is visible to T1 as a correlated call, not merely if the object's identity stays visible to the solver — identity-visibility-without-invocation-evidence is the root cause behind rounds 3, 5, and this one.
  • Replaced bare TRACKED_REFERENCE_PARENTS.has(parent.type) membership with a structural isTrackedReferencePosition check: a member/subscript reference is tracked only when it's the object of a member/subscript expression that is itself the callee of an enclosing call — a bare read now escapes.
  • While closing that gap, found the same relaxed check would still wrongly track a subscript call with a DYNAMIC key (T[k]()) anywhere, not only inside a loop — added a static-key requirement (string/template-string index only), mirroring collectComputedDispatchTableEvidence's own existing static/dynamic distinction.
  • Added condition 4 to computeObjectLiteralSiteEscapes: a literal defining a method/function whose body references this is now always escaping (conservative exclusion, not modeled — extending correlation to it would need per-call-site tracking the tier ladder doesn't do today).
  • Narrowed for_in_statement to the for...of variant only, reusing the exact of-keyword discriminator collectForOfBinding already applies. for...in enumerates keys, not values, and for (const k in T) T[k]() gets neither T1 evidence (dynamic key, no static name) nor T3 evidence (collectComputedDispatchTableEvidence requires the const x = T[expr]; x(...) declarator form, verified against its guard clauses — never a direct call).
  • Mirrored all of the above in WU-7's Rust TRACKED_REFERENCE_PARENT_KINDS/is_tracked_reference_position/literal_has_unmodeled_this_reference.
  • Added escape-fallback regression cases (g) this.k()-inside-literal and (h) bare const f = T.k; f(), each asserting escapes = 1 and live-only-via-T2, matching case (f)'s style.
  • Filed issue-2088 plan: same-literal this.k() calls excluded from correlation (conservative), not modeled #2618 (same-literal this correlation), issue-2088 plan: for...in enumeration and direct TABLE[computedExpr]() calls get no correlated or computed-dispatch evidence #2619 (for...in / direct computed-call dispatch evidence), issue-2088 plan: bare object-literal property reads assigned to a local (const f = T.k) have no alias-tracking #2620 (bare-read property-alias tracking) as deferred capabilities rather than silently narrowing recall, and reconciled Success Criteria, the Testing Strategy shape count, and the reviewer-checklist paragraph to match.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

Round 7. New head: e8efa37a ("docs: close five round-7 escape-analysis soundness gaps in WU-2"), on top of 9156a889. This round re-applied the doc's own binding invariant — a position is trackable only if EVERY invocation reachable through it is visible to T1 as a correlated call, not merely if the object's identity stays visible to the solver — to round 6's own result, and found five more places where it still didn't hold. All five are fixed, mirrored in WU-7's Rust plan, and gated by six new WU-10 escape-fallback cases.

  1. Array-owned sites treated a member/subscript call on the CONTAINER as tracked. const RESOLVERS = [{ matches: isFoo }]; RESOLVERS.forEach((r) => r.matches('foo')) — the plan's own headline Dispatch-table function references (resolve: fn) inconsistently flagged dead-unresolved depending on unrelated fanOut #1771 idiom — passed the old check (RESOLVERS is the object of a call-position member expression), but buildArrayCallbackConstraints only seeds a points-to fact for Array.from's callback, never for .forEach/.map/.find/.filter/.some, so r.matches(...) produces zero T1 evidence regardless. Fix: isTrackedReferencePosition now takes isArrayOwner (derived from owner.key !== owner.bindingName) and rejects the member/subscript branch outright for an array owner — only a for...of head over the container remains admissible. Follow-up: issue-2088 plan: array-owned sites correlate only through for...of - container-level array methods (.forEach/.map/.find/.filter/.some) are not modeled #2621.

  2. The for...of branch didn't recurse into the loop variable. for (const r of A) sink(r) (sink imported) accepted the reference to A but never checked what r itself does — the same alias-transitivity gap round 4 fixed for const u = T, recurring one binding later. Fix: allReferencesTracked now recurses into the loop variable exactly as it recurses into a rebinding alias (same depth-6 cap, isArrayOwner hardcoded false since a loop variable always denotes a single element) — and only when that loop variable is a single plain identifier, mirroring collectForOfBinding's own extraction shape. A destructuring loop variable (for (const { k } of A) k()) is now rejected outright, since collectForOfBinding never seeds a points-to fact for it at all. Re-verified: WU-10's existing handler-array shape (for (const r of RESOLVERS) if (r.matches(x)) return r.resolve(x);) still resolves correctly under this tightened rule. Follow-up (destructuring sub-case): issue-2088 plan: destructured for-of loop variables have no alias-tracking (array-element analogue of #2620) #2622.

  3. Condition 4 (literalHasUnmodeledThisReference) skipped identifier-valued properties. const T = { alpha: alphaImpl, run: runImpl }; T.run(); with function runImpl(){ return this.alpha(); } defined elsewhere in the file — round 6 only inspected a pair's value when written inline (method_definition, function_expression); an identifier value naming a same-file function was invisible to the check entirely. Fix: a plain-identifier pair value is now resolved against the file's own top-level function/variable definitions; a resolved non-arrow function's body is checked for this (fail-safe true if it can't be resolved in-file at all, matching this function's existing arrow-exclusion and fail-safe conventions).

  4. The static-key check accepted interpolated template indices. T[`al${part}`]() was accepted as a static subscript key because indexType === 'template_string' alone was checked — but extractSubscriptCallInfo only produces a named, receiver-carrying call when the text has no $; an interpolated template falls through to <dynamic:unresolved> with no receiver. Fix: the escape check now mirrors the extractor's own guard exactly (string, or template string containing no $) in both engines. Follow-up: issue-2088 plan: interpolated template-string subscript keys get no correlated evidence #2623.

  5. resolveSiteOwner's bindingName contract was unstated for the array case. If it ever returned the [*]-suffixed key instead of the bare identifier, allReferencesTracked would search the AST for literal text that can never exist, get a vacuous (zero-reference) result, and read that as non-escaping — silently bypassing condition 2's export check for every array-owned site. Fix: the contract is now stated explicitly (bindingName is always the bare declarator identifier; only key carries a structural suffix), with a dedicated export const A = [{…}] regression case.

On the vacuous-allReferencesTracked question (zero surviving references, for any reason): concluded it should stay true, not be forced to false — reasoned through explicitly in the doc. Every escape channel this design accounts for manifests as some concrete AST reference; an empty reference set isn't a gap in coverage, it's proof that channel doesn't exist for this binding. Forcing it to false would forfeit T1 correlation for legitimately fully-local dispatch tables without closing any actual soundness gap.

Also cleaned up on this PR: two inline replies from an earlier round landed on the wrong threads with a broken @_scratch_replyN.txt placeholder body instead of real content (a tool-call mistake, not a comment from anyone). Posted the actual answer on the thread it was meant for ("Property extraction bypasses escape tracking" — already fixed by 9156a889, confirmed unaffected by round 7) and a short correction on the two threads that got the misfire.

Reconciled throughout: the tracked-set doc comment, Testing Strategy's shape count (8 → 14 escape-fallback cases), the Success Criteria exclusion list, and the WU-7 Rust mirror section (is_tracked_reference_position, literal_has_unmodeled_this_reference + two new helpers, resolve_site_owner, all_references_tracked — all five fixes apply to both engines).

Comment thread docs/plans/issue-2088.md Outdated
Comment on lines +676 to +679
const isTrackedStaticKey =
indexType === 'string' ||
(indexType === 'template_string' && !indexNode!.text.includes('$'));
if (!isTrackedStaticKey) return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Quoted dollar keys lose evidence

When an object-literal binding is invoked as T['cos$t'](), this predicate accepts the string index as tracked, but both extractors classify names containing $ as unresolved and emit no receiver. The site can therefore become local-closed with no correlated invocation evidence, causing the live property value to be reported dead.

Fix in Claude Code

@carlos-alm carlos-alm Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0bdfb32. isTrackedReferencePosition's subscript branch now applies one check to both string and template_string index kinds identically: strip quote/backtick characters from the index text, then require the result non-empty and free of $ — mirroring extractSubscriptCallInfo/extract_call_info exactly, the same way round 7 already intended but only implemented for the template_string arm. T['co$t']() is now rejected on the string arm too (the template-string form of the same shape, a plain backtick-quoted key containing $, was already correctly rejected by round 7's fix) — so it correctly falls through to escaping/T2 instead of being accepted as a tracked reference with zero T1 evidence behind it.

Added WU-10 escape-fallback case (p) as the regression test: const V2 = { co$t: fnA4 }; V2['co$t'](); with a cross-file decoy via ordinary dot notation (otherObj.co$t();, which needs no bracket/quote at all since $ is a valid identifier character) — asserting live + escapes = 1, so the fix is checked against exactly this shape rather than trusted by inspection alone.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review. This commit fixes the round-22 fixture matrix's own test methodology, not the escape analysis — an independent audit (~1,127 executed combinations) confirmed the escape analysis is sound and the class_static_block fix is correct and load-bearing at this head.

What changed in docs/plans/issue-2088.md:

  • Oracle fix. The matrix compared the runtime oracle to escapes as a two-way invoked <=> !escapes check, which flags the round-22 fix itself as a new gap (escapes=1 on a genuinely invoked handler is correct T2/T3 fallback behavior, not a mismatch). Replaced with the three-term predicate the contract actually needs: invoked === true && escapes === 0 && !hasT1Evidence. escapes=1 on a live handler is now stated explicitly as a pass, never a finding.
  • Axes. Added an alias/hop-depth axis (direct, const/let/var alias, two-hop chain, for-of loop variable) and an object-literal content axis (identifier/function/shorthand/__proto__/getter/setter/spread/method/call-expression/nested value); split the owner-form and for-of-decoy axes by declarator/loop-head keyword. Verified all five of this plan's own historical blocking findings (const u = T; class_static_block; __proto__; a var-kind for-of head; a bare run => {} arrow parameter) are each reachable as a single generated combination now, not just the one the matrix happened to be built around.
  • Case (by) reshaped. Its inline for-of array literal (for (const r of [T])) made T's own reference a child of an array node, which TRACKED_REFERENCE_PARENTS doesn't recognise — the site escaped for that unrelated reason before the for-of-loop-variable recursion under test was ever reached. Self-ablation on the original form showed only 7 of the 8 claimed (bq)-(by) triggers actually flipped. Reshaped to the named array-element-owner form (bo)/(bd) already use; the reshaped case now flips correctly.
  • Count reconciliation. 77 escape-fallback + 27 correlation shapes = 104 (a stray "ninety-four" corrected); fixed a self-contradictory sentence naming both "twenty-six" and "1-27" shapes in the same breath, plus two further stale pre-shape-27 counts; completed a "(bk)-(bp), ten constructions" enumeration that was missing A10.
  • Misc. Fixed a misattribution (findEnclosingFunctionBody credited to round 22 in one spot, round 21 everywhere else it's named — round 21 is correct) and a severed sentence in the round-21 essay.

No change to WU-2/WU-2b's escape-analysis logic or any shared primitive. No Rust-mirror (WU-7/WU-8) update needed — everything here is WU-10 test-harness methodology, and fixture (by) already runs against both engines unchanged in shape.

- Scope hasT1Evidence to the handler property under test, not the
  site: a site-scoped reading silently passes 3 of 5 of this design's
  own historical findings (rounds 12, 17, 19), verified by
  constructing and executing all five.
- Add the missing control value to the escape-shape axis, a
  decoy-target sub-dimension to the for-of decoy, and a new
  literal-placement axis -- the three additions needed before the
  round-19/17/12 coverage claims actually reproduce; the previously
  claimed combinations do not, confirmed by execution.
- Recompute the nominal combination count (831,600 -> 2,332,800) and
  state the matrix's execution strategy, wall-clock budget, and
  cadence (in-process, nightly job, not a PR gate).
- Drop uncited, unverifiable figures (a 2,505-case delta, 36/231
  SyntaxError counts, a 43%/72% false-alarm rate) with no run log to
  back them.

All changes additive; the matrix is not shrunk, and the escape
analysis itself is untouched.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review. This commit fixes two documentation-level defects in the round-22 fixture matrix's own test apparatus (WU-10), found by an independent critic who ran the full 831,600-combination matrix twice at the prior head (1,099,956 executed combinations, zero under-escape paths under both readings of the oracle) — the escape analysis itself is confirmed sound and untouched, and the matrix is not shrunk.

What changed in docs/plans/issue-2088.md:

  • hasT1Evidence scoping fixed. The contract said hasT1Evidence is true "iff the walk identified a real, tracked reference as the invocation," which admits two readings: site-scoped (any tracked reference anywhere at the site) or property-scoped (a tracked reference to the specific handler property under test). Site-scoped is the natural first reading of the prior wording, and it's wrong: constructed and executed against this plan's own five named historical findings with each round's fix ablated, site-scoped wrongly reports PASS on 3 of 5 (rounds 12, 17, 19 — each has a genuine, tracked reference to an unrelated property at the same site, which site-scoping credits as "T1 evidence" for the property actually under test). Property-scoped catches all 5. The contract now defines hasT1Evidence(O, p) explicitly, parameterized by owner O and property p, and the mismatch predicate is restated as invoked(p) === true && escapes === 0 && !hasT1Evidence(O, p).

  • Three coverage-check claims didn't reproduce. Each was checked by constructing the exact combination the plan claimed exposes the finding, then executing it with the relevant round's fix ablated — none of the three flipped:

    • __proto__ (round 19): the escape-shape axis had no "no escape" control value, so every generated combination is forced to add a leak (e.g. sink(N)) that already forces escapes = 1 at condition 3 regardless of the proto fix. Added the missing control value.
    • a var-kind for-of head (round 17): the decoy axis can only target the owner's or an alias's name, never the content axis's own identifier (run41) — the one name this finding actually needs rebound. Added a decoy-target dimension for the for-of/kind values.
    • a bare run => {} arrow parameter (round 12): the owner-placement pin declares the object literal at MODULE scope unconditionally, but the finding needs the literal INSIDE the arrow for findResolvingScopeNode's walk to ever traverse the bare-parameter field. Added a literal-placement axis (module scope / inside the container).

    All three additions are additive. Re-verified all five coverage-check findings by constructing and executing the generated combination for each (not asserting) — all five now flip correctly.

  • Count reconciliation. 15 containers × 18 decoys × 12 escape shapes × 5 owner forms × 6 alias/hop-depth values × 12 content values × 2 literal-placement values = 2,332,800 nominal combinations, up from 831,600.

  • Execution strategy named. The plan never said how the matrix runs — a 25× wall-clock ambiguity between in-process (~22 min single-threaded, 176s across 8 shards) and one node spawn per combination (~9.2h). Stated that in-process is the intended reading, and that this is a nightly job, not a per-PR gate (too slow for the per-PR Verification Commands block); the existing named regression anchors ((a)-(by), correlation shapes 1-27) stay in the per-PR suite unchanged.

  • Three uncited figures dropped. A "2,505 constructed cases" delta, "36 and 231 SyntaxErrors in two independently-built implementations," and a "43%/72% false-alarm rate" (found adjacent to the hasT1Evidence rewrite) had no run log backing them in this document — removed rather than left as unverifiable precision.

No change to WU-2/WU-2b's escape-analysis logic or any shared primitive. No Rust-mirror (WU-7/WU-8) update needed — this is WU-10 test-apparatus methodology only.

Comment thread docs/plans/issue-2088.md Outdated
Comment on lines +7471 to +7472
>
> **The non-vacuous-coverage requirement (ROUND 8, #2088 finding 1 — the structurally important half).** `allReferencesTracked` returns `true` only when BOTH: (1) the walk is PROVEN exhaustive over the declaring scope's subtree — it did not truncate at `MAX_WALK_DEPTH` anywhere within it; AND (2) every reference the (proven-exhaustive) walk found satisfies `isTrackedReferencePosition`, or is accepted on a recursive bran

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Coverage recipes miss binding scope

When the round-17 recipe leaves the literal at module scope, its function-local var loop head rebinds a different identifier, while the round-12 recipe does not require its content identifier to match the parameter decoy. The generated cases therefore either flip through an unrelated name-based scan or do not flip at all, so the matrix can claim coverage without exercising the intended fixes.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Executed both recipes to check this. Both claims hold under the plan's own stated criterion, but the underlying precision gaps you're pointing at are real, and I've tightened both bullets in 0e3d3c4 rather than just asserting they're fine.

Round-12 recipe (run => {...} shadow): the bare-parameter decoy does shadow the content axis's own identifier as designed -- but you're right that the decoy axis's own TARGET sub-dimension is only defined explicitly for the for-of decoy, leaving the parameter decoy's own target implicit. It's defensible as written (the decoy axis's note already says the bare parameter is "the exact shape case (ad)/round 12 exercises," and case (ad) itself names the content identifier as what it shadows), but a generator implementing the axes literally could, in principle, pick a non-shadowing parameter name and silently miss this recipe. Flagged that gap explicitly in the doc rather than leaving it implicit.

Round-17 recipe (var-kind for-of head): this one flips escapes correctly in all fifteen containers -- the static analysis doesn't care whether the containing function is ever called. But the runtime oracle's invoked only reads true for module scope and the five block-shaped containers (bare block, if, try, switch case, loop body), not the nine function-shape containers the bullet named, because a function-shape container has to actually be invoked for its body to run, and nothing in the axis definitions auto-invokes every container value. So the three-term contract (invoked && escapes===0 && !hasT1Evidence) is only observable for six of the fifteen, not reachable-and-correct-but-unobserved for the other nine as the bullet implied. escapes itself is unaffected by this -- it's a gap in what the matrix can currently demonstrate, not in the fix. Corrected the bullet to name the six containers where this is actually observable and filed the container-invocation gap as a follow-up for the generator itself.

Both corrections are in the Coverage check section of WU-10 (search "GREPTILE (P1)" in the diff).

Condition 2 (the export check) ran exactly once, against the top-level
owner's own bindingName, before allReferencesTracked ever ran -- never
re-applied to any alias name the rebinding/for-of recursion introduces.
An alias can itself be exported while the table it aliases is not, and
an exported alias reaches the table cross-module exactly as an exported
table would. Verified end-to-end under real Node, two real ES modules:
`export const api = T` (T never exported); `api.alpha()` in the
importing module genuinely invokes the handler `T.run()` alone left as
this design's only in-file reference to T. escapes read false; fnAlpha
would be reported dead though api.alpha() invokes it on every import.

- Threads `exportedNames` through `allReferencesTracked` as a new
  parameter, unchanged across both recursions, and adds an unconditional
  `if (exportedNames.has(bindingName)) return false;` at the top of the
  function, re-run at every recursion level.
- Mirrored in WU-7's Rust (`all_references_tracked` gains the identical
  `exported_names` parameter and check); WU-8 needs no change, since
  `exported_names` never crosses the NAPI boundary this WU builds.
- Self-ablated against a standalone reference model (real
  tree-sitter-javascript parse, real Node): five flipping constructions
  (export const/let/var alias, a two-hop exported chain, an exported
  alias of an array owner via the for-of recursion) and four controls
  that correctly do not flip (export { u } / export default u escape by
  a different, pre-existing mechanism; a direct export and a wholly
  unexported alias chain are unaffected either way).
- Adds WU-10 escape-fallback cases (bz)/(ca) and correlation shape 28;
  recounts hand-written cases (104 -> 107) and correlation shapes
  (twenty-seven -> twenty-eight).
- Adds an export dimension to the fixture matrix's alias/hop-depth axis
  (an exported const/let/var alias, and an exported two-hop chain) --
  the matrix had no way to generate this bug's class before now.
  Recomputes the nominal combination count (2,332,800 -> 3,888,000).
- Re-derives #2610's own standing exception (Out of Scope, Success
  Criteria): its premise ("the table must itself be exported") was
  false whenever only an alias is exported, but the conclusion (the
  site escapes via condition 2 or condition 3, before condition 4 ever
  runs) survives under the corrected premise, so the exception is
  re-derived rather than withdrawn.
- Fixes the pre-round-22 combinatorial baseline (previously 5,445,
  which silently included two round-22-only additions under a
  "pre-round-22" label; corrected to 4,620) and the round-17/round-12
  coverage-check recipes flagged by Greptile (the round-17 recipe's own
  runtime oracle is only observable in six of fifteen containers; the
  round-12 recipe's parameter-decoy target was implicit, not a
  generatable axis choice).
- Relabels the prior commit's "ROUND 23" tags to "ROUND 24", matching
  the PR's own review-cycle count, with a note on the resulting
  non-contiguous 22/24 numbering.
- Adds the "EXECUTED" qualifier to the matrix's own per-combination
  timing figures, which reconcile only under a per-executed (not
  per-nominal) reading.

No change to any prior fix verified at this head, the shared
primitives, the oracle, or the matrix's own size beyond the one new
axis dimension.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

Round 25 (0e3d3c4) on top of 8bc1b0a: closes an UNDER-escape gap in condition 2's own recursion, independent of the "round 24" review pass this PR's history already confirmed clean.

Blocking finding closed: condition 2 (the export check) ran exactly once, against the top-level owner's own bindingName, before allReferencesTracked ever ran -- never re-applied to any alias name the rebinding/for-of recursion introduces. An alias can itself be exported while the table it aliases is not:

// a.mjs
function fnAlpha() { return 1; }
const T = { alpha: fnAlpha, run() { return 0; } };
export const api = T;                 // exported ALIAS, T itself never exported
T.run();
// b.mjs
import { api } from './a.mjs';
api.alpha();                          // fnAlpha IS invoked

Verified end-to-end under real Node: fnAlpha genuinely runs on every b.mjs import, but the pre-fix algorithm reads escapes = false for T's site (condition 2 only ever checked T, never api) -- fnAlpha would be reported dead though api.alpha() invokes it on every import.

Fixed by threading exportedNames through allReferencesTracked as a new parameter, unchanged across both the rebinding-alias and for-of recursions, and re-checking it -- the same check the caller already runs once, at the top -- at the start of every recursive call. Mirrored in WU-7's Rust; WU-8 needs no change (the parameter never crosses the NAPI boundary). Self-ablated against a standalone reference model (real tree-sitter-javascript parse, real Node): five flipping constructions and four controls that correctly do not flip.

Matrix gap closed alongside it: the fixture matrix had no export dimension on any axis, so four consecutive clean sweeps never had a way to generate this bug's class. Added an exported const/let/var alias and an exported two-hop chain to the alias/hop-depth axis; recomputed the nominal count (2,332,800 -> 3,888,000).

#2610 re-derived, not withdrawn: its standing exception argued "any table with a cross-file computed-access consumer must itself be exported" -- false whenever only an alias is exported. The conclusion (the site escapes via condition 2 or condition 3, before condition 4 ever runs) survives under the corrected premise, so the exception stands, re-derived on the repaired behavior.

Also in this push, all non-blocking: replied to and tightened the round-17/round-12 coverage-check recipes your own P1 flagged (both hold, with real precision gaps now stated explicitly rather than glossed over); corrected the pre-round-22 combinatorial baseline (5,445, which silently counted two round-22-only additions under a "pre-round-22" label -- corrected to 4,620); added the missing "executed" qualifier to the matrix's per-combination timing figures; relabeled the prior commit's "ROUND 23" tags to "ROUND 24" to match this PR's actual review-cycle count, with a note on the resulting non-contiguous 22/24 numbering.

No change to any prior fix, the shared primitives, the oracle, or the matrix's own size beyond the one new axis dimension.

Comment thread docs/plans/issue-2088.md Outdated
> inside `sink({ T })` — a genuine escape (`T` is forwarded into an imported
> function, exactly the shape escape-fallback case (b) already requires to
> escape for a PLAIN identifier argument). An `identifier`-only filter finds
> no reference to classify at

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Export matrix masks regression

When the exported-alias matrix case invokes the property through that alias in the same generated file, the call supplies T1 evidence and makes the stated invoked && escapes === 0 && !hasT1Evidence oracle false even with the pre-round-25 escape behavior. The nightly matrix therefore reports no gap for the cross-module under-escape this case is intended to guard.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed correct, thank you — fixed in the doc.

The three-term oracle (invoked && escapes === 0 && !hasT1Evidence) can't flag this family: the escape-shape axis's own in-file alias.prop(...) call is itself a tracked alias reference the points-to solver already correlates to the table's own site (condition 3's alias-flow support, per Success Criteria's own "direct binding, array element + for-of, and alias" list), so hasT1Evidence reads true unconditionally, regardless of what escapes says. The matrix can generate the combination and show escapes flip on inspection, but its own generate-and-check loop can never use that flip to raise a finding — which is exactly why round 25's own gap (and five further let/var/two-hop variants this round found) were caught by a hand-built, two-file real-Node oracle, never by the matrix's nightly run.

Changed in docs/plans/issue-2088.md:

  • The "Coverage check" section's header no longer claims the matrix's own oracle would flag all six historical findings — it now says plainly that 5 of 6 are matrix-self-detected and the 6th (this one) structurally cannot be with the matrix in its current single-file shape.
  • The round-25 entry in that same list now carries the full explanation above, with a pointer to this comment.
  • Filed WU-10 fixture matrix: extend generator/oracle to cross-file combinations #2646 to track extending the generator/oracle to cross-file combinations, which is what would actually close this gap — out of scope for this round's own fix (the exportedNames derivation bug this revision is otherwise about).

Round 25's own tightening of the round-17 coverage-check recipe (0e3d3c4)
described a real gap in the matrix generator's own invocation convention
(the runtime oracle is only observable for module scope and the five
block-shaped containers, not the nine function-shape ones, since nothing
guarantees a function-shape container's body ever runs) but left it as a
generic "filed as a follow-up" with no issue number. Filed as #2645 and
cited here, matching this plan's own established convention of citing a
specific issue number for every filed follow-up rather than leaving one
open-ended.
…2088)

Round 25's self-ablation harness computed exportedNames more permissively
than the real extractor (collectExportedDeclarations restricts a
non-function identifier/pattern export to const, #2070), which validated
condition-2 claims the real extractor falsifies for export let/var
object-valued bindings and aliases. Verified against the real parser,
a corrected reference-model harness, and real two-file Node runtime
oracles.

- Pin exportedNames' derivation: a new WU-2-local, keyword-agnostic
  collectExportedBindingNames, never a widening of the shared,
  regression-tested collectExportedDeclarations. Mirrored in WU-7/WU-8.
- Correct the two false "declarator keyword is irrelevant" claims in
  place (round-25 essay, fixture (bz) commentary, Risks table/Out of
  Scope/Success Criteria cross-references), add real lettered fixtures
  (cb)-(ch) for the let/var and two-hop shapes instead of bundled prose.
- Re-derive #2610's standing exception on the corrected behavior; it now
  holds for every export keyword, not only const.
- Add a fourth standing rule: a verification harness must derive its
  inputs the way the real pipeline derives them, not by an independent
  re-implementation.
- Fix inverted "before condition 4 ever runs" wording (condition 4 runs
  first, unconditionally) in the #2610 bullets.
- Normalize correlation shape 28's label to match the mechanically
  recountable "// N." format used by shapes 1-27.
- Correct the Coverage check section's overclaim that the fixture
  matrix's own in-file oracle would self-detect the round-25 finding
  (Greptile P1 3852228371): it cannot, since an in-file alias.prop()
  call always supplies T1 evidence regardless of escapes: this family
  needs the cross-file oracle the round-25/26 essays already use by
  hand. Files #2646 for extending the matrix to cross-file generation.
- Name the eval()/new Function() opacity residual in Out of Scope,
  cross-referencing the already-filed #2642.

Reconciled fixture/correlation-shape counts mechanically throughout
(79 -> 86 escape-fallback shapes, 107 -> 114 total).
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

This round (26) fixes a bug in round 25's own verification: its self-ablation harness computed exportedNames more permissively than the real extractor does (collectExportedDeclarations restricts a non-function identifier/pattern export to const, #2070), which validated condition-2 claims the real extractor falsifies for export let/export var object-valued bindings and aliases — confirmed against the real parser, a corrected reference-model harness, and real two-file Node runtime oracles.

Summary of changes:

  • Pinned exportedNames' derivation to a new WU-2-local, keyword-agnostic collectExportedBindingNames, never a widening of the shared, regression-tested collectExportedDeclarations. Mirrored in WU-7/WU-8's Rust.
  • Corrected the two false "declarator keyword is irrelevant" claims in place, and added real lettered fixtures (cb)-(ch) for the let/var and two-hop shapes instead of the bundled prose that hid the bug.
  • Re-derived computedDispatchTableEvidence is in-memory only — scoped incremental builds can report live dispatch-table properties as dead #2610's standing exception on the corrected behavior; it now holds for every export keyword, not only const.
  • Added a fourth standing rule: a verification harness must derive its inputs the way the real pipeline derives them, not by an independent re-implementation.
  • Addressed the outstanding P1 (comment 3852228371, "Export matrix masks regression") — replied inline; corrected the "Coverage check" section's overclaim and filed WU-10 fixture matrix: extend generator/oracle to cross-file combinations #2646 for the underlying capability gap.
  • Fixed inverted "before condition 4 ever runs" wording, normalized correlation shape 28's label, and named the eval()/new Function() opacity residual in Out of Scope.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

This round (27) fixes three bugs an independent critic found in round 26's own plan text — two of them in code this plan proposes to ship, not in test apparatus — verified against the real, freshly-built pipeline (dist/domain/parser.js + dist/domain/graph/resolver/points-to.js) rather than argued from reading alone.

Summary of changes:

  • B1 (blocking). collectInvokedPropertySites was going to be called with raw symbols.calls, so every call.callerName would read undefined and resolveReceiverSites could only ever probe the bare pts key — but buildForOfConstraints (unchanged, pre-existing) only ever writes a scoped key (${enclosingFunc}::${varName}, e.g. pick::r). Executed against the plan's own headline Dispatch-table function references (resolve: fn) inconsistently flagged dead-unresolved depending on unrelated fanOut #1771 idiom through the real extractor and solver: the site token lands at pts.get('pick::r'), never pts.get('r') — so this would have silently broken correlation for every for-of-bound handler array in the codebase, the exact case follow-up: receiver-type-aware invoked-property matching to reduce dead-code false negatives #2088 exists to fix. Fixed by deriving callerName via findCaller, the same way the existing edge-resolution pass already does, with null mapped to the '<module>' sentinel emitPtsNoReceiverEdges already uses. Mirrored in the Rust WU (find_enclosing_caller + the existing caller_name.is_empty()"<module>" conversion). Correlation shape 2 gets a self-ablation note proving the flip.
  • B2 (blocking). buildPointsToMapForFile's null guard checks nine legacy binding arrays but was never going to gain the new objectLiteralSites field, so a file whose only pts-relevant content is a parenthesised / as const / satisfies / non-null-asserted object literal would return null, and the new site-seeding constraints would never run for that file — even though the site provably doesn't escape. Verified against the real extractor across all four wrapper spellings (table in WU-4). Fixed by adding objectLiteralSites to the guard on both engines; four new correlation shapes (29–32) fixture each spelling with self-ablation notes.
  • B3. The definitionNames derivation this plan's own standing rule (round 26) used as its own worked example was itself wrong: it claimed new Set(definitions.map((d) => d.name)) "exactly as points-to.ts already builds it," but the real points-to.ts filters to kind === 'function' || 'method' first — the unfiltered form belongs to an unrelated native-FFI payload. Corrected to the filtered form (confirmed to match the real Rust mirror too), and pinned findTopLevelFunctionNodeByName's existing (verified, not assumed) behavior of returning null for a non-function-shaped declarator value.
  • Corrected a stale escape-fallback shape count (79 → 86, missed when round 26 added seven cases), a "sixteen total readings" arithmetic error (seven cases × two dimensions is fourteen), and softened an overstated typeMap-reuse claim. Counts reconciled: 86 escape-fallback + 32 correlation shapes = 118.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

This round (28) closes a gap in round 27's own B1 fix: deriving callerName via findCaller was necessary but not sufficient, because it silently assumed findCaller's callerName always equals buildForOfConstraints's own enclosingFunc scope prefix. Executed end-to-end against the real, unmodified pipeline (dist/domain/parser.js + dist/domain/graph/resolver/points-to.js + dist/domain/graph/builder/call-resolver.js), that equality is false for three shapes ordinary in this very (TypeScript) codebase — round 27's own single-probe fix leaves all three reporting the plan's #1771 idiom's handlers dead again.

Summary of changes:

  • B1 completion (blocking). For a TS class method (instance/static/getter/async, all four confirmed independently), buildForOfConstraints writes the bare method name (run::r) while findCaller resolves the qualified one (C.run::r) — TS class names parse as type_identifier, and the context-collector's class-name qualification is identifier-only. For a class-field arrow or an object-literal arrow-valued property, buildForOfConstraints writes <module>::r while findCaller resolves C.run/obj.run — the context-collector's dispatch has no case for field_definition/public_field_definition or a pair at all, on either engine. Both root causes verified independently in the Rust extractor too (enclosing_func_context, whose own doc comment already names the TS-class caveat) and filed as #2647 rather than fixed at the source — it's a shared primitive that also scopes param-flow/array-elem/spread-arg/array-callback constraints on both engines, well outside this plan's stated scope. Also confirmed (execution, not hypothesis): two same-named TS methods in different classes collapse onto the identical bare key, so a bare/normalised fallback inherits a bounded, pre-existing collision risk — bounded to over-crediting an unrelated method's own array as a possible target, never to hiding a real one. Considered and rejected widening WU-4's seeding instead (the plan's own "option 2"): buildObjectLiteralSiteConstraints never produces a ${enclosingFunc}::${varName}-shaped key in the first place, so no version of "WU-4 also writes a normalised key" can rescue a key WU-4 never writes. Fixed by widening resolveReceiverSites's probe set (callerName, its own last-dot segment, and the '<module>' sentinel, unioned) rather than the derivation — a documented tolerance for the divergence, not a claim it doesn't exist. Three new correlation shapes (33–35, one per previously-uncovered enclosing-scope shape) fixture this with self-ablation showing the one probe that rescues each. Mirrored into WU-7/WU-8 for the Rust engine.
  • Four smaller fixes found in the same pass: ptsMapsByFile's Pass 1/3 typing (buildPointsToMapForFile genuinely returns PointsToMap | null; the map was typed as if it couldn't be), the misspelled buildNativeFilesInputbuildNativeFileInputs, the round-10 Builder note's shape count left at "twenty-eight" after round 27 added four shapes without updating this line, and callAssignments's "exactly two fields" overclaim (it carries two more, unused here).

Counts reconciled: 86 escape-fallback + 35 correlation shapes = 121.

Comment thread docs/plans/issue-2088.md
Comment on lines +5317 to +5323

`resolveSiteOwner` reuses the existing walk SHAPE of `findEnclosingTableName` (variable-declarator lookup through `TABLE_NAME_PASSTHROUGH_TYPES`) — the traversal only, never `findEnclosingTableName`'s return-value construction — extended with two extra cases — `array` parent → `` `${arrayVarName}[*]` `` (the pts key `buildArrayElemConstraints` already produces), and `return_statement` parent → `` `${enclosingFnName}::return` ``.

> **`resolveSiteOwner`'s return contract, stated explicitly** (round-7 critic finding, #2088 finding 5 — the previous draft left this implicit, which is itself the bug; ROUND 8, #2088 finding 2, extends it one guarantee further): `resolveSiteOwner(objectNode): { key: string; bindingName: string | null } | null`.
> - `key` is the pts-constraint LHS `buildObjectLiteralSiteConstraints` (WU-4) flows this site's pts fact into, and is also what `entry.owner` is set to, verbatim, regardless of owner kind: the bare variable name for a direct binding (`const T = {…}` → `"T"`), the array-element wildcard key for an array element (`const A = [{…}]` → `"A[*]"`), or the scoped return key for a returned literal (`return {…}` inside `f` → `"f::return"`). This is the ONLY field the points-to solver (WU-4) or T1's evidence matching (WU-5b) ever reads — both work purely in terms of site tokens and pts facts, never by textually matching a binding name.
> - `bindingName` is **always the bare declarator identifier** that `allReferencesTracked` walks the binding's declaring scope for, and **never** carries a `[*]` or `::return` suffix (round-7, finding 5) — **nor, round 8 (finding 2), a `#${scopeLine}` disambiguating suffix either**: it is always `nameNode.text` read directly off the `variable_declarator`'s `name` field, never the string `findEnclosingTableName` itself would return for that same declarator. `findEnclosingTableName` (`src/extractors/javascript.ts:4513-4528`) appends exactly that suffix — `` `${nameNode.text}#${scopeLine}` `` — for any declaration scoped inside a block, via `findDeclaringScopeLine`; `resolveSiteOwner` must stop at `nameNode.text` and never call through to that suffix-appending return construction, precisely because `bindingName` is consumed as an AST SEARCH TARGET (`allReferencesTracked` looks for `identifier` AND `shorthand_property_identifier` nodes — ROUND 19, #2088 finding 3, corrects this parenthetical's own pre-round-19 wording, which named only `identifier`; see `allReferencesTracked`'s own doc comment for why the omission is load-bearing, not editorial — whose `.text` equals it), not as a human-readable disambiguating label the way `findEnclosingTableName`'s result is. Concretely: `"T"` for a direct binding regardless of what scope it's declared in, `"A"` (never `"A[*]"`) for an array element, or `null` for a return-owner (there is no binding at all to scan — condition 1 already makes every return-owner escape unconditionally, before condition 3 ever runs `allReferencesTracked`). This is unrelated to, and must never be confused with, `Call.receiver` (set by the pre-existing, UNCHANGED `collectObjectLiteralValueRefCall` as `findEnclosingTableName(pairNode)` — see WU-2's implementation above): `receiver` is #2260's own T3 (`computedDispatchTableEvidence`) matching key and is EXPECTED to keep its `#line` suffix, since T3 disambiguates by exactly that string; `bindingName` is WU-2b's own escape-analysis input and must NOT carry it. Two different fields, two different consumers, two different rules — round 8 exists because an earlier draft let them blur.
> - The two fields are EQUAL (`key === bindingName`) for exactly one owner kind — direct binding — and differ for exactly one other — array element (`"A[*]" !== "A"`). This is what `isArrayOwner = owner.key !== owner.bindingName` (condition 3, above) relies on, and it is why `bindingName` cannot be left to be "whatever seems natural": were it ever `"A[*]"` instead of `"A"`, or ever `"T#7"` instead of `"T"`, `allReferencesTracked` would search the AST for an identifier literally spelled `A[*]` or `T#7` — text that can never appear as identifier syntax — find zero surviving references for every affected site, and read that vacuous walk as non-escaping. Round 8 (see the withdrawal of round 7's vacuous-truth conclusion, above) is precisely why this can no longer be waved away as "vacuous truth is always fine": a search that structurally can never match anything is not an exhaustive, PROVEN-COVERED search — it is a broken one, and must be treated as unproven, not as a trivial pass. This would silently bypass condition 2's export check for every affected site, exported or n

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Fallback scopes cross-credit sites

When a file has multiple for-of bindings with the same receiver name, candidateScopesFor unconditionally probes the caller's unqualified name and module scope, then unions every matching site. The call consequently supplies T1 evidence to unrelated object-literal sites, preserving their same-named handlers as live and losing the intended dead-code recall improvement.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — you're right on the mechanism, and this was independently found in the same round: candidateScopesFor unions the scoped, last-dot, and <module> probes unconditionally, never short-circuiting once an earlier probe already resolved a call correctly, so a same-named receiver elsewhere in the file (another for-of, or a same-bare-named module-level function) does get cross-credited. Fixed in the doc at 8830670, not in the probe set — this is a deliberate, bounded, measured trade, not an oversight, and the analysis is now written up in full in WU-5(a)'s collision note plus WU-10 correlation shape 36.

Measured cost, from executing the widened set against codegraph's own src/: 113 files contain a for-of, 577 for-of bindings, 68 non-value-ref member calls on a for-of-bound receiver. Round 27's single (scoped-only) probe: 28 hits / 2 collisions. The round-28 widened set: 31 hits / 3 collisions — +3 resolving call sites against +1 new colliding one, 1.5% of the 68 candidates. Per probe: the last-dot probe alone contributed 1 genuine rescue and 0 collisions in this repo; the <module> probe alone contributed 0 rescues here and 3 leaks.

The proved bound: T1's over-credit is a subset of T2's own admit set. collectInvokedPropertySites and the real collectInvokedPropertyNames share the same collection predicate (call.receiver && call.dynamicKind !== 'value-ref') and the same key source (call.name) — the only difference is T2's set is accumulated globally across every file before any file's points-to map exists, while T1's correlation is per-file. Any property name a collision could ever attribute to an unrelated site is, by that identical predicate, already a member of T2's global invokedNames. So the worst case is byte-identical to pre-#2088 behaviour for the affected pairs: it can never mark live what #1895 already marked dead, and it never marks anything dead — it can only make T1 credit, for the wrong structural reason, a property T2 would already have credited anyway.

Why the probes stay rather than narrowing back down: removing the <module> probe reopens shapes 34 and 35 (class-field arrow, object-literal arrow prop — enclosingFunc never gets a context for either node kind and falls through to <module> unconditionally); removing the last-dot probe reopens shape 33 (TS class method — a TS class name parses as type_identifier, so enclosingFunc stays unqualified while findCaller resolves the qualified name). Each was independently verified dead under that probe's own single-probe ablation before this round. The actual fix — reconciling funcStack with findCaller at the extractor so the two stop diverging — is out of this plan's scope and is filed at #2647.

Shape 36 (WU-10, above the escape-fallback shapes) is the fixture covering this specific cost: two same-named TS class methods, each with its own for-of array bound to the same receiver name, colliding on one scoped pts key via the last-dot probe — asserting the over-credited-but-still-live outcome directly, with a self-ablation showing the collision (and, inseparably, the correct resolution) both disappear when that one probe is removed. The write-side collision and the extractor/solver output it depends on were re-executed against the real, unmodified pipeline rather than only argued.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

Round 29 (8830670) on top of 59bd575 — documentation-only, per an independent critic's PASS at the prior head: the escape analysis, the probe set, the axes, the oracle, and every fixture's behaviour are unchanged. Four items, all in docs/plans/issue-2088.md:

  1. Collision write-up expanded, one claim corrected. WU-5(a)'s B1-completion note named one collision family (two same-named TS class methods' arrays colliding on a shared pts key) and said the widened probe set introduces "not a new risk" — false as written, true only of that one family. Replied to directly on your own "Fallback scopes cross-credit sites" thread, which independently found the same mechanism. Rewritten to name all three families (the pre-existing write-side collision; a correctly-resolving call additionally picking up a same-bare-named module-level function via the last-dot probe; the unconditional <module> probe merging any module-scope for-of into every same-named-receiver call in the file), state the actual bound (T1's over-credit is a subset of T2's own admit set — same collection predicate, same key source, so the worst case is byte-identical to pre-follow-up: receiver-type-aware invoked-property matching to reduce dead-code false negatives #2088 behaviour and can never mark anything dead), and quantify it against codegraph's own src/ (68 candidate calls; 28→31 hits, 2→3 collisions).
  2. Correlation shape 36 added (WU-10): a minimal two-array fixture of Family 1, asserting the over-credited-but-still-live outcome directly rather than only arguing it, with a self-ablation isolating the collision to the one responsible probe. The write-side pts collision and the extractor output it depends on were re-executed against the real, unmodified pipeline. Shape/case counts reconciled everywhere they're enumerated (35→36 correlation shapes, 121→122 total).
  3. Noted the widened probe set reaches beyond for-of receivers: a this receiver inside a method also gets probed against thisCallBindings' ${callee}::this keys via the same last-dot/<module> mechanism — same T2 bound, inert in every fixture tested, no probe-set change.
  4. Two real citations added and corrected against current main (src/domain/parser.ts:1181, src/domain/wasm-worker-entry.ts:646) illustrating the <module>-probe mechanism concretely — verified before use; the second site's receiver turned out to be a same-named parameter, not itself the _extToLang.get(ext) local, and the credited array is independently exported/escaping, so T2 already governs its own outcome regardless of the collision. Both corrections are reflected in the write-up.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

…s citation (round 27, #2088)

B1 — collectInvokedPropertySites fed raw symbols.calls into resolveReceiverSites,
so call.callerName was always undefined and the scoped pts key
buildForOfConstraints actually writes (`${enclosingFunc}::${varName}`, e.g.
`pick::r`) was never reachable, only the bare key, which for-of aliases never
populate. Verified end-to-end against the real pipeline (dist/domain/parser.js +
dist/domain/graph/resolver/points-to.js) on the plan's own #1771 idiom: the site
token lands at pts.get('pick::r'), never pts.get('r'). Fixed by deriving
callerName via findCaller, the same way Pass 3 already does, with null mapped to
the '<module>' sentinel emitPtsNoReceiverEdges already uses. Mirrored in WU-8
(Rust find_enclosing_caller + the caller_name.is_empty() -> "<module>"
conversion). Correlation shape 2 gets a self-ablation note; shapes 29-32 add
coverage for B2.

B2 — buildPointsToMapForFile's null guard checks nine legacy binding arrays but
never objectLiteralSites, so a file whose only pts-relevant content is a
parenthesised/`as const`/`satisfies`/non-null-asserted object literal returns
null and WU-4's new constraint-seeding never runs, even though the site
provably does not escape. Verified against the real extractor across all four
wrapper spellings (table in WU-4). Fixed by adding objectLiteralSites to the
guard on both engines; new correlation shapes 29-32 fixture each spelling with
self-ablation notes.

B3 — the definitionNames worked example claimed
`new Set(definitions.map((d) => d.name))` "exactly as points-to.ts already
builds it." The real points-to.ts (541-545) filters to
kind === 'function' || 'method' first; the unfiltered form is build-edges.ts:559,
an unrelated native-FFI payload. Adopts the filtered form (matches the real
Rust mirror too, build_edges.rs:1368-1373) and pins
findTopLevelFunctionNodeByName's existing null-for-non-function-shaped-value
behavior, verified by reading its actual implementation rather than assuming.

Also: corrects a stale escape-fallback-shape count (79 -> 86, missed in round
26), a "sixteen total readings" arithmetic error (seven cases x two dimensions
is fourteen), and softens an overstated typeMap-reuse claim. Counts reconciled:
86 escape-fallback + 32 correlation shapes = 118.
…nd 28, #2088)

Round 27's callerName derivation (via findCaller) was necessary but not
sufficient: it silently assumed findCaller's callerName always equals
buildForOfConstraints's own enclosingFunc scope prefix. Executed against the
real pipeline, that equality is false for a TS class method (instance,
static, getter, async), a class-field arrow, and an object-literal
arrow-valued property - all three wrongly report the plan's own #1771
idiom's handlers as dead, in a codebase that is itself TypeScript.

Two pre-existing, symmetric (both engines) extractor root causes, filed as
#2647 and left unfixed here (shared primitive, out
of scope for this plan): TS class names parse as type_identifier and are
invisible to funcStack's identifier-only qualification check, and the
context-collector has no dispatch case for a class-field arrow or an
object-literal arrow-valued property at all.

Fixes resolveReceiverSites to probe every candidate scope a for-of receiver
could actually have been written under (callerName, its own last-dot
segment, and the '<module>' sentinel) instead of the single scoped key round
27 tried - a documented tolerance for the divergence, not a claim it doesn't
exist. Confirmed against the real, unmodified pipeline that the widened set
resolves all three previously-missing shapes with no regression on the two
that already worked, and that WU-4's "seeding also writes a normalised key"
alternative cannot work regardless, since WU-4 never touches
buildForOfConstraints in the first place.

Adds WU-10 correlation shapes 33-35 (one per previously-uncovered shape) with
executed self-ablation, mirrors the same widened probe set into WU-7/WU-8 for
the Rust engine (enclosing_func_context carries the identical divergence,
verified independently), and reconciles shape/case counts throughout.

Also fixes four unrelated issues found in the same pass: ptsMapsByFile's
Pass 1/3 typing (buildPointsToMapForFile genuinely returns PointsToMap |
null; the Map was typed as if it couldn't), the misspelled
buildNativeFilesInput -> buildNativeFileInputs, the round-10 Builder note's
shape count left at "twenty-eight" after round 27 added four shapes without
updating this line, and callAssignments's "exactly two fields" overclaim
(it carries two more, unused here).
…er reach (round 29, #2088)

The plan passed review at 59bd575 on structural grounds; this is a
documentation-only follow-up addressing four review findings, including a
Greptile P1 that independently converged on the same mechanism as the first
item below.

- Collision write-up (WU-5(a)) named one collision family and claimed the
  widening introduces "not a new risk" — false as written, true only of
  that one family. Rewrites the bound to name all three families (write-side
  collision between two TS classes; a correctly-resolving call picking up a
  same-bare-named module-level function via the last-dot probe; the
  unconditional <module> probe merging any module-scope for-of into every
  same-named-receiver call), states the T2-bounded consequence precisely,
  and quantifies it against codegraph's own src/ (113 files with a for-of,
  577 bindings, 68 candidate calls; round 27 baseline 28 hits/2 collisions,
  round 28 widened 31 hits/3 collisions).
- Adds correlation shape 36 (WU-10): two same-named TS class methods whose
  arrays collide on one scoped pts key, fixturing the over-credited-but-
  still-live outcome the T2 bound argues, with a self-ablation isolating
  the collision to the one responsible probe. Write-side collision and
  extractor/solver output re-executed against the real, unmodified
  pipeline. Reconciles shape/case counts throughout (35->36 correlation
  shapes, 121->122 total fixtures) everywhere they are enumerated.
- Notes the widened probe set is not for-of-specific: a `this` receiver
  inside a method also picks up the afterLastDot/<module> probes via
  thisCallBindings' `${callee}::this` keys, same T2 bound, inert in every
  fixture tested.
- Cites two real call sites (parser.ts:1181, wasm-worker-entry.ts:646)
  illustrating the <module>-probe mechanism concretely, corrected against
  current main during verification (the second site's receiver is a
  same-named parameter, not itself a `_extToLang.get(ext)` local; the
  credited array is independently exported/escaping, so T2 already governs
  its outcome regardless of the collision).

No behavioural change to any fixture, predicate, probe set, or the escape
analysis. Plan-doc only.
@carlos-alm
carlos-alm force-pushed the docs/plan-issue-2088 branch from 8830670 to 2183bea Compare August 26, 2026 09:24
Comment thread docs/plans/issue-2088.md Outdated
Comment on lines +1410 to +1417
// Top-level call — `declaringScope` is omitted, so `allReferencesTracked`
// computes it once via `findDeclaringScopeNode(objectNode, owner.bindingName)
// ?? root` (round 8, #2088 finding 1) and threads that fixed node through
// its own recursive calls unchanged. See `allReferencesTracked`'s own doc
// comment below for why the boundary must be computed exactly once, here,
// rather than re-derived per recursive call. `exportedNames` is threaded
// through as of ROUND 25 (#2088, blocking) so every recursive call can
// re-apply condition 2 to its own subject — see that round's own essay,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale recursive scope instructions

When an implementer follows these instructions for an alias or loop binding whose name collides in the original scope, allReferencesTracked reuses the fixed outer boundary instead of deriving the recursion subject's scope. The walk can then miss an escaping use such as sink(r), mark the site local-closed, and cause exclusive T1 evidence to report a live property dead.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 8aced6d. The Top-level call comment above computeObjectLiteralSiteEscapes's call to allReferencesTracked (lines 1410-1420) still described the pre-round-21 contract verbatim — "threads that fixed node through its own recursive calls unchanged" / "the boundary must be computed exactly once, here, rather than re-derived per recursive call" — even though round 21's own essay (this file, further down) explicitly supersedes that: each recursion (rebinding alias, for-of loop variable) recomputes its OWN declaringScope, seeded from the recursion subject's own lexical position, falling back to the enclosing call's scope (never straight to root) only when nothing shadows the subject between its own position and that boundary. The essay text itself already carried inline "corrected by ROUND 21" annotations at every point it was originally stated (lines 3806, 3825, and the Rust mirror at 5514), but this specific comment — the one sitting directly above the actual call site, the most likely place an implementer reads literally rather than archaeologizing through the full round history — never got the same correction and flatly contradicted it.

Rewrote the comment to state the round-21-corrected contract directly (per-level recomputation, with the enclosing-call fallback and the for-of kind === 'var'" split's own #2643 pointer), rather than the superseded round 8-20 one, while still pointing to allReferencesTracked`'s own doc comment and the round-21 essay for the full mechanism and counter-example. No behavior or code changes — this PR is docs-only — just correcting the one instructional comment that was out of sync with the design it's supposed to be summarizing.

The 'Top-level call' comment above computeObjectLiteralSiteEscapes's
call to allReferencesTracked still described the pre-round-21
behavior -- the outer declaringScope threaded unchanged through every
recursive call. Round 21 replaced that with per-level recomputation
(each recursion seeds its own declaringScope from the recursion
subject's own position, falling back to the enclosing call's scope,
never straight to root), but this comment was never updated to match
and would mislead an implementer into reintroducing the round-21
self-shadow bug. Flagged by Greptile.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

carlos-alm added a commit that referenced this pull request Sep 6, 2026
…-literal value-refs (#2654)

* docs(plan): receiver-correlated invoked-property evidence for issue-2088

Delivery plan for #2088: replace the bare-property-name liveness check for
object-literal value-refs with allocation-site correlation, gated on an
escape check so escaping sites keep today's exact (conservative) behavior.

Reconciles the apparent tension with ROADMAP 8.3's field-based points-to
choice: field sensitivity and allocation-site abstraction are orthogonal
axes, and 8.3's own Approach block already commits to the latter.

Part of #2088

* docs: fix WU-8's native NAPI-threading file and precedent citation (#2612)

WU-8 (and the Dual-Engine Impact table) named
src/domain/graph/builder/stages/native-orchestrator.ts and a
computedDispatchTableEvidence precedent there for threading
objectLiteralSites across the native NAPI boundary. Neither exists:
native-orchestrator.ts has zero occurrences of that field and takes no
part in NAPI payload construction — it only runs tryNativeOrchestrator's
post-build JS passes (CHA expansion, this-dispatch, structure,
dataflow-vertices), which execute after Rust's own full-pipeline build
already extracted and consumed that evidence entirely in Rust memory.
FileEdgeInput, the Rust struct WU-8 cited, is Rust-only and never
appears under src/; its actual TS-side counterpart is NativeFileEntry
in build-edges.ts, which already carries computedDispatchTableEvidence.

Corrected WU-8's Files/Input contract/Implementation to name
NativeFileEntry/buildNativeFileEntry (build-edges.ts) and FileEdgeInput
(build_edges.rs) explicitly, added the matching row and a seam
paragraph to the Dual-Engine Impact table, and closed the verification
gap the wrong citation created: a plain full-build engine comparison
never reaches buildCallEdgesNative because tryNativeOrchestrator's fast
path returns early first. Documented the exact mechanism by which the
plan's existing --engine wasm -> --engine native verification pair
already forces that path (an engine-mismatch-triggered
forceFullRebuild), and marked that command order load-bearing so it
isn't silently broken by a future reordering.

* docs: fix WU-2b's return-owner escape gap and WU-5a's fabricated citation (#2612)

WU-2b's computeObjectLiteralSiteEscapes marked a return-statement-owned
site (`function f() { return {...} }`) local-closed whenever the
factory function itself was not exported, via
exportedNames.has(owner.enclosingFn). That checks the wrong binding:
the value a factory returns is captured by a call-assignment
(`const X = f()`) that can land in any binding, anywhere, and WU-4's
buildObjectLiteralSiteConstraints already flows the site into that
binding's pts set unconditionally, with no escape check of its own. A
return-owned site could therefore be marked non-escaping while a
capturing binding it has no visibility into is exported or otherwise
untracked, making T1 exclusive and letting a live property be reported
dead -- the exact failure direction #2088's soundness requirement
exists to rule out. Independently corroborated by Greptile's review
comment on the PR (id 5390203404, last updated after the round-1 fix
commit, so it reflects the current text, not a stale one).

Fixed the branch to always mark a return-owned site escaping --
consistent with the fail-safe default this analysis already uses
everywhere else -- and corrected condition 1 of the docstring, which
had listed the return-statement shape as one that could reach
non-escaping. Added WU-10 escape-fallback shape (d), covering
`function factory(){ return {...} } export const X = factory();
X.zeta();`, asserting both liveness and escapes === 1, as the
regression gate for this branch.

WU-5a also justified resolveReceiverSites' caller-scoped-then-bare pts
lookup by citing a function, resolveReceiverPtsAliases, that does not
exist anywhere in the repo, at incremental.ts:1436 (which is a
different function, emitIncrementalPtsReceiverEdges, doing a single
compound-key lookup, not the two-step pattern claimed). Replaced the
citation with real precedents: resolveReceiverEdge
(call-resolver.ts:773-775, same file), which build-edges.ts:2123
already mirrors by name, and the ptsMap-specific scopedPtsKey fallback
in emitPtsNoReceiverEdges (build-edges.ts:1965) / its incremental
mirror emitIncrementalPtsNoReceiverEdges (incremental.ts:1350).

* docs: fix WU-2's alias escape-tracking gap and strengthen WU-10 assertions (#2612)

const u = T; u.alpha() previously fell through the escape allowlist (no
variable_declarator case), so the alias site was marked escaping and the
correlation test could only ever pass via T2's bare-name fallback -- never
actually exercising the T1 correlated-evidence path the alias test case
claims to cover.

Add a variable_declarator case to allReferencesTracked/condition 3, guarded
on the declarator's name field being a plain identifier (not a destructuring
pattern) -- mirroring the existing name?.type !== 'identifier' guard in
findEnclosingTableName -- so const u = T is recognized as the alias shape
fnRefBindings already models, while const { k } = T (property extraction,
not aliasing) still escapes, matching the escape-fallback test's own
destructuring case.

Also strengthen WU-10's correlation test to assert escapes = 0 for each of
the four cases, not just the liveness outcome, so a future regression in the
escape guard cannot pass silently through T2.

* docs: fix alias-transitivity gap in WU-2's rebinding branch (#2612)

Greptile's re-review of the round-3 fix (899188d7) caught a hole in it:
accepting a `const u = T` reference as tracked only checked that ONE
reference to T, never following where u itself goes afterward. So
`const u = T; importedFn(u); u.alpha();` could be classified local-closed
even though the site escapes through u -- the same shape of gap condition 1
already documents for a return-captured binding, recurring one hop later
for an alias-captured one.

Make the rebinding branch of allReferencesTracked recursive: accepting
`const u = T` now additionally requires condition 3 to hold, recursively,
for u itself, capped at 6 hops (reusing findEnclosingTableName's existing
bound) as defense-in-depth against a pathological alias chain. Add a
regression case to WU-10's escape-fallback test list covering exactly this
shape.

The bare-identifier-argument (paramBindings) branch has the identical
transitivity gap -- verified against the real, already-shipped
buildParamFlowConstraints in src/domain/graph/resolver/points-to.ts, which
has no escape-safety check of its own -- but fixing it requires scoping a
recursive check into the callee's own body rather than reusing the same
file-level walk, so it's filed separately as #2617 rather than folded in
here.

* docs: treat param-flow positions as escaping in WU-2 condition 3 (#2612)

Review of the plan at 62606d04 found a structural twin of the
alias-transitivity gap that commit fixed, on the parameter-passing
branch of condition 3: a bare-identifier argument to a locally-defined,
non-exported function was accepted as a tracked reference without ever
inspecting what the callee does with the parameter. The primitive this
relies on, buildParamFlowConstraints (points-to.ts), only adds
pts(callee::paramName) <- pts(argName) and is documented "Scope:
intra-module only" -- it has no escape check of its own.

Concretely: `const P = { beta: fnB }; function use(t) { register(t); }
use(P);` with `register` imported. P is unexported, its only reference
is the param-pass into `use`, and `use` is local and unexported, so the
old condition 3 read the site as local-closed. T1 became exclusive, T2
was skipped, and the real `t.beta()` inside the imported module
produces no site token, since cross-module SITE propagation doesn't
exist. fnB would be reported dead where today's exact predicate reports
it live -- the false-negative-to-false-positive conversion this plan's
own hard rule forbids.

Apply the conservative remedy instead of recursing into the callee body
(the recursive variant stays out of scope, per #2617): drop the
parameter-flow branch from condition 3 entirely, so a bare-identifier
argument always marks the site escaping and falls back to T2. Move
WU-10's correlation case 4 (which asserted escapes = 0 for this shape)
into the escape-fallback test as new case (f), asserting escapes = 1
and live via T2. Correct WU-4's "comes for free" claim and Success
Criteria bullet 3, both of which advertised param-flow as part of the
correlated set, and update the shape counts this ripples into (three
correlation shapes, six escape-fallback shapes) throughout the doc.

#2617 is re-scoped from a plan defect to a recall enhancement:
extending correlation to the param-flow branch by recursing into the
callee's own body remains a legitimate follow-up, just not required
for this plan to be sound.

* docs: gate tracked reference positions on T1 visibility in WU-2 (#2612)

* docs: close five round-7 escape-analysis soundness gaps in WU-2 (#2612)

* docs: close three round-8 soundness gaps in WU-2's escape analysis

Round 8 finding 1 (headline): allReferencesTracked's file-wide walk
reused introducesShadowedBinding without exempting the site's own
declaring scope. For any non-module-scope table, that scope's
statement_block directly contains the table's own declaration, so
introducesShadowedBinding sees it and self-shadows the entire block,
pruning every reference inside it before the walk ever runs. The
surviving-reference set is then empty and Array.prototype.every
reads that as vacuously tracked, silently defeating the
parameter-flow exclusion (round 5, #2617) for every function- or
block-scoped table specifically because the walk never reaches the
disqualifying reference at all. Fixed two ways: the walk is now
rooted at, and exempts from the shadow-prune, only the one scope
findDeclaringScopeNode (new, refactored out of the existing
findDeclaringScopeLine) returns for the binding, mirroring the exact
carve-out hasLaterReferenceInEnclosingBlock already documents for its
own narrower single-block search; and allReferencesTracked now
returns true only when it can prove the walk was exhaustive over that
scope, so a truncated walk (MAX_WALK_DEPTH, or the existing depth-6
alias/for-of recursion cap) forces escaping unconditionally rather
than silently passing. This withdraws round 7's "a vacuous result is
always safe" argument, which held for the raw AST but not for a
walk that can itself discard real references.

Round 8 finding 2: resolveSiteOwner's bindingName can inherit the
same #${scopeLine} suffix findEnclosingTableName appends for any
non-module-scope declaration. allReferencesTracked would then search
for identifier text like "T#7", which cannot exist in the grammar,
compounding finding 1 for every non-module-scope site even after the
shadow-prune fix. bindingName is now always nameNode.text verbatim;
the contract is extended to state which of key/bindingName the
solver and escape walk each consume, and that Call.receiver (T3's
own, pre-existing, correctly-suffixed key) is a separate field that
must not be confused with it.

Round 8 finding 3: isTrackedReferencePosition's subscript static-key
check mirrored the extractor's `$`-exclusion onto the template_string
arm only, leaving the string arm unconditional. extractSubscriptCallInfo
and its Rust mirror extract_call_info apply one stripped-text/no-`$`
check to both index kinds identically, so a quoted key such as
T['co$t']() produces no named Call in either engine. The escape check
now applies that same unified check to both index kinds. Greptile
flagged this exact gap on this PR ("Quoted dollar keys lose evidence")
against the round-7 code.

Adds two correlation-test shapes (function-scoped and block-scoped
tables used correctly) and two escape-fallback cases (a function-scoped
table forwarded to an imported callee, and a `$`-bearing quoted static
key) to close the testing blind spot that let finding 1 survive seven
rounds: every existing WU-10 fixture declared its table at module
scope, the one scope introducesShadowedBinding never self-shadows.
Mirrors all three findings into the planned Rust WU-7/WU-8 work
(find_declaring_scope_node, all_references_tracked's declaring_scope
parameter, resolve_site_owner's suffix rule, and the unified subscript
guard). Reconciles shape counts, the Testing Strategy table, Success
Criteria, and the Risks table accordingly. No new follow-up issues:
all three findings are soundness fixes to what earlier rounds already
claimed the design covers, not new accepted recall exclusions.

* docs: close round-9 gaps in WU-2 escape analysis and WU-5 ordering

Round 9 finding 1 (headline): literalHasUnmodeledThisReference
(condition 4 of computeObjectLiteralSiteEscapes) was a positive-only
detector. It returned true for a closed set of recognised
this-using shapes (a method_definition, an inline
function_expression/function-valued pair, or a same-file-resolved
identifier-valued pair) and silently returned false - voting
non-escaping - for every other pair-value or object-member shape,
the exact inversion of every other condition's fail-closed default.
A spread source (const T = { alpha: fnA, ...mixin }, where
mixin = { run() { return this.alpha(); } }) matched neither the
pair nor the method_definition branch, so the loop skipped it
without comment; T.run() is condition 3's only reference to T and a
genuine tracked call, so the site read as local-closed while
mixin.run's this.alpha() - reached only because the spread copies
run onto T - produced zero correlated evidence, reporting fnA dead
though T.run() calls it every time. A call-expression-valued pair
(run: makeRunner()) and a parenthesized function expression (run:
(function () { return this.alpha(); })) reach the identical gap
with no second literal needed. Fixed by rewriting the function to
enumerate only the shapes positively proven this-free - an
arrow_function, a non-function literal or primitive (including a
nested array/object, which cannot itself be invoked as T.key()), an
inline function/method whose subtree was searched clean, or an
identifier or shorthand property resolved in-file to a non-arrow,
this-free function - and escaping on everything else, including
spread_element, which previously matched no branch at all. Adding a
shorthand_property_identifier branch was necessary, not incidental:
without it the inverted default would make every literal using a
shorthand property escape unconditionally, since shorthand is a
distinct node type from pair. The line-991 "restrict to the
simplest syntactic shape" precedent (#1771/#1784) previously cited
to justify this silence governs edge emission, never a safety
predicate; this function no longer cites it for that purpose.

The fail-closed contract this closes is generalised, not patched:
computeObjectLiteralSiteEscapes's doc comment now states, as a
standing rule, that every predicate it consults must return
escaping for any shape it does not positively recognise as safe -
not just allReferencesTracked's own coverage (round 8) - so a
future predicate added to this function inherits the contract
automatically rather than needing its own round to be caught.
Inverting condition 4's default genuinely narrows recall for the
shapes named above, unlike round 7's identifier-valued-pair fix,
which closed a detection gap in an already-sound exclusion; filed
as a follow-up rather than silently narrowed - #2624.

Round 9 finding 2: WU-5's collectInvokedPropertySites needs a
completed per-file points-to map, but its doc comment framed it as
a direct sibling of collectInvokedPropertyNames, which is called
once, globally, before the per-file loop that builds each file's own
points-to map - because collectInvokedPropertyNames is a pure
name/file aggregation needing no points-to information at all. A
builder following that framing literally has no map to consult for
any file at that point, and a plausible fix that resolves every
file's calls against one arbitrary file's map would compile, pass
every existing single-file WU-10 fixture, and silently
under-populate T1 for every other file - the exact false-dead class
this plan is gated on. Fixed by restructuring buildCallEdgesJS into
three explicit passes (build every file's points-to map first,
assemble the correlated and non-escaping-sites evidence sets, then
run the existing per-file edge-resolution loop against the cached
maps) and by keying collectInvokedPropertySites's own signature by
file rather than a flattened call list, so a caller cannot wire it
up without supplying the right map per call. Verified the identical
ordering exists in the Rust engine (EdgeContext::new aggregates
invoked_property_names/computed_dispatch_table_evidence globally
before build_points_to_map runs per file inside process_file) and
mirrored the same three-pass note into WU-8.

Adds two new correlation-test shapes (a mixed data/handler table
proving the condition-4 rewrite does not over-escape, and a two-file
fixture proving the pass-ordering fix does not resolve one file's
calls against another's map) and five new escape-fallback cases
covering the spread, call-expression, and parenthesized-function
shapes across module, function, and block scope, per the Testing
Strategy's own module/function/block trio requirement. Mirrors both
findings into the planned Rust WU-7/WU-8 work. Reconciles shape
counts, the Testing Strategy table, the Interface Definitions
section's previously-inconsistent copy of collectInvokedPropertySites's
signature, the Risks table, and Success Criteria accordingly.

* docs: close round-10 identifier-resolution gaps in WU-2 condition 4

Round 10 finding 1 (Greptile, "Shadowed handler resolves incorrectly"):
findTopLevelFunctionNodeByName searches only the module root's direct
children, so an identifier-valued pair or shorthand property whose
name is ALSO declared at module level resolved to that unrelated
module-level declaration with full confidence whenever the name was
actually shadowed, at the object literal's own lexical position, by a
closer, non-module declaration. function run() { return 0; } at
module scope, shadowed by function install() { function run() {
return this.alpha(); } const T = { alpha: fnA, run }; T.run(); } -
condition 4 resolved run to the outer, this-free version, voted safe,
and fnA was reported dead though T.run() invokes it via the inner,
this-using run on every call. This is worse than an unresolved
identifier. The pre-round-10 doc comment claimed the module-level-only
search was backstopped by its own null-return fail-safe for exactly
this case; that backstop only fires when no module-level declaration
exists at all, never when one exists but is shadowed. Fixed by making
resolveIdentifierValueThisReference resolve outward from the object
literal's own position first, via findDeclaringScopeNode (round 8's
own helper), and fail safe whenever it finds a scope strictly between
the literal and the module root that also declares the name - before
findTopLevelFunctionNodeByName's module-level search ever runs.
findTopLevelFunctionNodeByName's own body is unchanged; only its doc
comment's backstop claim is corrected. Failing safe on any shadow,
rather than resolving into the shadowing scope itself, costs recall
for a shadowing declaration that happens to be genuinely this-free;
filed as a follow-up rather than silently accepted - #2625.

Round 10 finding 2: the shorthand arm's BUILTIN_GLOBALS guard,
!BUILTIN_GLOBALS.has(child.text) && resolveIdentifierValueThisReference(...),
short-circuited to a silent non-escaping vote for any builtin-named
property - Stream, Buffer, process, document, URL, and everything
else BUILTIN_GLOBALS lists - with no check for whether this file
itself shadows that name. function Stream() { return this.alpha(); }
alongside const T = { alpha: fnA, Stream }; T.Stream() voted safe
without ever calling resolveIdentifierValueThisReference, and fnA was
reported dead though T.Stream() invokes it every call. The pair arm
did not share this specific hole - a builtin-named identifier value
there fell through to the unconditional fail-closed default - which
made the doc comment's claim that the two arms get identical
identifier-resolution treatment false, and also meant the pair arm
never credited a genuinely unshadowed builtin as safe either. Fixed
by replacing the bare guard in both arms with one shared
isUnshadowedBuiltinGlobal(name, definitionNames) helper: skip
resolution only when the name is a builtin AND this file defines no
same-file symbol by that name at all. This closes the shorthand arm's
soundness hole and, as a side effect, lets the pair arm correctly
credit an unshadowed builtin as safe instead of always escaping -
a recall improvement, not a new exclusion.

Adds five new escape-fallback fixtures: a function-scoped and a
block-scoped shadow of a module-level, this-free sibling (finding 1 -
no module-scope member exists, since the bug requires a scope
strictly between the literal and the module root, which cannot exist
for a module-scope literal), and a module-, function-, and
block-scoped builtin-name shadow (finding 2, which is scope-independent
since definitionNames is a flat, file-wide set). Adds a builder note
clarifying that every correlation shape's identifier-valued handler
must be a same-file, top-level declaration - an imported or
nested-only handler fails resolveIdentifierValueThisReference's own
checks and flips escapes to 1, failing that shape's own assertion
outright rather than merely proving nothing. Mirrors both fixes into
WU-7's Rust implementation notes (resolve_identifier_value_this_reference
gains the object_node parameter and the same shadow check;
is_unshadowed_builtin_global mirrors the TS helper verbatim in both
arms) and calls out the parity risk of porting the guard to only one
arm. Reconciles the escape-fallback shape count (21 -> 26), the
Testing Strategy's scope-coverage note, the Risks table, and Success
Criteria accordingly, and fixes the Folder Structure table's stale
"three canonical shapes" (now seven) count.

* docs: fix round-10 regression and for-in gap in WU-2 condition 4

Round 11 fixes two blocking findings in condition 4's identifier
resolution, one of which is a regression the previous round introduced
into previously-verified-sound code:

- Finding 1: findDeclaringScopeNode's ancestor walk cannot see a
  for...of/for...in loop-head binding, since SCOPE_NODE_TYPES
  deliberately excludes for_in_statement for a different concern
  (#2260's own reference-walk boundary). A loop variable could
  therefore shadow a same-named module-level decoy without the
  shadow ever being detected, resolving to the wrong (harmless)
  declaration with full confidence. Fixed with a new, resolution-path
  -only wrapper, findResolvingScopeNode, that ORs the existing shadow
  check with a for_in_statement-head test, without touching
  findDeclaringScopeNode/SCOPE_NODE_TYPES or allReferencesTracked's
  own, already-verified-sound use of them.

- Finding 2 (regression): isUnshadowedBuiltinGlobal, introduced last
  round to unify the pair and shorthand arms' builtin-name guard,
  treats a builtin-named IMPORT as a genuine unshadowed global,
  because definitionNames excludes imports by construction. This made
  the pair arm's previously always-sound builtin handling unsound.
  Reverted: both arms now escape unconditionally on any
  BUILTIN_GLOBALS name, exactly as the pair arm always did through
  round 9; isUnshadowedBuiltinGlobal is deleted. The "credit a
  genuinely unshadowed builtin as safe" improvement is filed as its
  own follow-up (#2627) to be designed and reviewed as its own round.

Both fixes are mirrored in the Rust WU-7 section with matching
parameter order. Adds three new escape-fallback fixtures ((aa)-(ac)),
reconciles the Testing Strategy, Risks, and Success Criteria sections,
and fixes an unrelated sign error in the WU-10 Builder note.

* docs: close round-12 arrow-parameter shadow gap in WU-2 condition 4

findResolvingScopeNode's for-in disjunct (round 11) left a second gap in
the same underlying primitive: introducesShadowedBinding's shared
function-shape case reads only the plural `parameters` field, so a bare,
unparenthesized single-identifier arrow parameter (`run => {...}`) --
carried in a separate, singular `parameter` field per
tree-sitter-javascript's own node-types.json -- was invisible to the
shadow check. A same-named module-level decoy resolved with full
confidence instead of failing safe.

Closes it the same way round 11 did: one more disjunct ORed onto
findResolvingScopeNode alone, introducesShadowedBinding/SCOPE_NODE_TYPES
left untouched. Adds escape-fallback case (ad), mirrors the fix in WU-7's
Rust plan, and reconciles the escape-fallback count (29 -> 30), Testing
Strategy, Risks, and Success Criteria sections.

introducesShadowedBinding's own blind spot to this field (affecting
allReferencesTracked's reference walk, safely) is filed separately as
#2629 rather than widened inline. A parenthesized for-of/for-in
loop-head target (`for ((x) of arr)`) is filed as #2630.

* docs: note for-await coverage and the two-walk divergence risk

Two small clarifications requested during round-12 review, both purely
additive:

- One sentence on findResolvingScopeNode's for_in_statement disjunct
  noting it also covers `for await (... of ...)`, since tree-sitter
  represents all three (for-in, for-of, for-await-of) with the same node
  type, distinguished only by the `kind` field -- there is no separate
  for_await_statement node.
- A new standing row in Risks & Mitigations naming the divergence risk
  between findDeclaringScopeNode (condition 3's reference-walk boundary)
  and findResolvingScopeNode (condition 4's resolution question): two
  near-identical ancestor walks sharing a base check but not the same
  disjuncts. States which walk owns which question so a future round
  does not "unify" them and silently change condition 3.

* docs: fix issue-2088 plan's condition-4 reassignment gap (round 13)

Round 13 of the #2088 plan review: resolveIdentifierValueThisReference
resolved an identifier-valued property to its module-level declaration
via findTopLevelFunctionNodeByName, which deliberately accepts a
let/var binding as well as const -- but nothing asked whether that
binding is ever reassigned. A module-level `let run = () => {}` later
reassigned to a this-using function resolved to the arrow's own
trivially safe body regardless, reporting a live handler dead. This is
also a regression against today's (pre-#2088) behavior.

Closed with a new subtreeContainsReassignmentOf check, run after the
module-level search resolves a node but before the arrow-function
branch trusts it -- not the const-only shortcut, which would also have
excluded every plain `function foo() {}` declaration the design's
correlation shapes rely on. Mirrored in the Rust WU-7 description.
Costs recall for a binding reassigned only to other provably this-free
values -- filed as #2631 rather than resolved inline, matching round
10's own precedent for a shadowing declaration.

New WU-10 escape-fallback case (ae); count 30 -> 31. Corrected two
Success-Criteria/Risks-table claims that no longer held once a binding
can be reassigned. Filed #2632 for a using_declaration gap in
introducesShadowedBinding's statement_block case, found but out of
scope for this round. Corrected #2630's own rationale in a comment --
its parenthesized for-in target is never a shadow (a declaring for-in
head cannot parenthesize its target), so it is unrelated to the
arrow-bare-parameter gap it cites, though the same patternBindsName fix
still resolves it. Added a note to findResolvingScopeNode's doc
comment recording why a class expression's own name is deliberately
not a disjunct.

* docs: close round-14 write-scan and duplicate-declaration gaps in WU-2 condition 4

Widens subtreeContainsReassignmentOf's assignment-expression branch to route
through patternBindsName instead of a bare identifier check, so a
destructuring reassignment target is no longer invisible to the write-scan
(Greptile-flagged: "Destructuring writes bypass reassignment tracking").
Makes findTopLevelFunctionNodeByName fail safe when more than one top-level
declaration of a name exists, rather than confidently returning the first
and ignoring a later one that actually wins at runtime (var redeclaration,
duplicate function declarations) -- the first round in which this
function's own body, not only its caller's, changes.

Also corrects two overstated doc claims surfaced by the same review pass:
the round-13 Success Criteria bullet's "enforced structurally... never
REASSIGNED" wording, and a stale "restrict to the simplest syntactic
shape" precedent citation used to justify the now-fixed narrower scan.
Adds a complexity note on the intended per-file pre-pass implementation,
discloses the eval/with/globalThis-write residual gaps, and adds four
escape-fallback fixtures plus the Rust mirror for both fixes.

Files #2633 (duplicate-declaration fail-safe recall cost) and #2634
(globalThis-write residual gap) as follow-ups.

* docs: close round-15 var/Annex-B hoist-through-blocks gap in WU-2 condition 4

`findTopLevelFunctionNodeByName` only counted declarations that were
direct children of `root`, but `var` is function-scoped, not
block-scoped: a `var name` hoisted from inside a bare block, `if`,
`for`, `try`, or `switch` body at module level is the SAME binding a
direct top-level `var name` would be, and sloppy-mode Annex B extends
the identical hazard to a block-level `function` declaration. Both
were invisible to the round-14 count, which resolved to the FIRST
declaration with full confidence instead of failing safe once a
second, hoisted one existed - confirmed by running both shapes under
real Node.

This also corrects the round-14 scope-coverage note, which claimed no
function- or block-scoped variant of "two top-level declarations"
could exist at all - true of the pre-round-15 implementation, false of
JS semantics.

Widens the count via a new countHoistedVarScopeDeclarations helper
that reuses functionScopeDeclaresVar's traversal rule, deliberately
excluding let/const (a genuinely different, block-scoped binding
already handled by the shadow axis) - verified by a new correlation
shape proving that exclusion holds rather than merely stating it.
Adds escape-fallback cases (aj)/(ak), mirrors the fix in WU-7's Rust
notes, and reconciles the Testing Strategy, Risks, and Success
Criteria sections accordingly. Two non-blocking items are also
addressed: patternBindsName's fail-open depth-cap asymmetry gets a
documented caveat, and the Testing Strategy section now spells out
that cases (ai)/(ak) need a non-ESM CommonJS fixture file.

Recall costs specific to this round's own fix (it never resolves a
sole hoisted-only declaration, and doesn't gate the Annex-B branch on
the file's strict/sloppy/module parse goal) are filed as a follow-up
rather than silently accepted.

* docs: fix countHoistedVarScopeDeclarations's own traversal order (round 15 self-review)

The round-15 helper I just pushed had a real bug: it pre-filtered a
CHILD's node type against FUNCTION_SCOPE_NODE_TYPES before ever
recursing into it, reusing functionScopeDeclaresVar's traversal shape
verbatim. That shape is safe there because the only node kind it
recognizes, variable_declaration, is never itself a member of that
set. It is not safe here, because this helper also recognizes
function_declaration - which IS itself a member - so a nested Annex-B
function_declaration would be skipped before its own name-match check
ever ran, silently returning 0 instead of 1. Traced this by hand against
case (ak) and confirmed it with a small simulation script before
shipping the fix: case (ak) would have gone uncounted and the fix would
not have fired for the one shape it exists to catch, while case (aj)
and the new correlation shape's let/const exclusion would have
appeared to pass regardless.

Fixed by checking each node's own type for a match first, then gating
recursion on that same node's type - self-check, then decide whether
to descend - rather than filtering a child's type before ever visiting
it. Updated the doc comment (both engines' notes) to state why this
can't be a literal one-for-one reuse of the existing helper's shape,
and added this as a third, concrete parity-risk hazard for WU-7 since
it is exactly the kind of thing a hand-written port would compile and
half-pass without ever noticing.

Also files #2636: found while re-verifying that the same
'function_declaration' string match (in both the pre-existing round-14
loop and this round's own extension) does not recognize
generator_function_declaration as a distinct grammar kind, so a
generator function redeclaration is invisible to the count in both
places. Fail-safe already, not confidently wrong, but a real detection
gap - filed rather than widened inline, since fixing it means touching
round 14's own already-settled test.

* docs: close round-16 under-escape gaps in WU-2 condition 4 (#2630/#2632/#2634/#2636)

An audit of the exclusion ledger found four tracked gaps mixed in
among fourteen accepted over-escape recall trade-offs, but wrongly
framed as the same kind of thing: #2630 (parenthesized_expression
invisible to patternBindsName, three call sites), #2632
(using_declaration invisible to introducesShadowedBinding's
statement_block case), #2634 (a script-scope var reassigned via
globalThis.name = ... invisible to subtreeContainsReassignmentOf),
and #2636 (a generator function declaration invisible to
findTopLevelFunctionNodeByName's redeclaration count). All four are
under-escape: a real invocation goes undetected and live code is
reported dead, not a recall cost the design accepts on purpose.

Close all four locally, inside condition 4's own helpers, without
widening the shared patternBindsName/introducesShadowedBinding
primitives other verified consumers depend on:

- a small local unwrapParens() helper, called at the three
  parenthesized_expression call sites in subtreeContainsReassignmentOf
  and findResolvingScopeNode
- a fourth using_declaration disjunct on findResolvingScopeNode's own
  walk (not on introducesShadowedBinding, since condition 3's
  consumer of that primitive does not need this fix)
- a new isGlobalObjectQualifiedWrite check ORed onto
  subtreeContainsReassignmentOf's assignment branch
- a generator_function_declaration branch in
  findTopLevelFunctionNodeByName's direct-children loop, deliberately
  not extended to countHoistedVarScopeDeclarations's own recursive
  hoisting walk (verified empirically that Annex B never hoists a
  generator declaration, so a nested one does not redeclare the outer
  binding)

Mirrors all four fixes into WU-7/WU-8's Rust, verified against the
real, already-shipped Rust source rather than assumed. Corrects the
false framing in #2630 ("the safe direction" holds for exactly one of
its four consumers, not all), #2634 (misfiled alongside genuine
fail-safe trade-offs; it was a missed write, not a detected-and-
declined-to-resolve-further condition), and #2636 ("fail-safe-already"
holds only when every redeclaration of the name is a generator, and is
confidently wrong the moment it is mixed with a plain declaration).

Adds an explicit OVER-escape/UNDER-escape direction label to every
tracked exclusion in Success Criteria, and a standing rule: an
under-escape gap may never be filed as an accepted limitation, it
must be fixed in the round that finds it. Records that #2610 is the
one pre-existing exception (inherited from #2260, out of this plan's
own scope, and verifiably not worsened by it).

Adds six new escape-fallback fixtures (one per closed path) and four
new correlation-shape guards (one per fix, proving none of them
over-escapes the legitimate neighbouring shape), reconciles the
Testing Strategy, Risks, and Success Criteria counts, and rewords
WU-7's own round-enumeration sentence to describe the current round
rather than requiring a hand-extended list every time.

* docs: cross-reference #2637 (switch_body using_declaration residual) in WU-2

Round 16's using_declaration disjunct in findResolvingScopeNode is
scoped to a statement_block ancestor only, matching #2632's own
repro. introducesShadowedBinding's switch_body case carries the
identical enumeration (no using_declaration case) that
statement_block's did before this round's fix, but this was not
verified either way while closing #2632 itself. Filed as #2637
rather than assumed safe, with a cross-reference from
findResolvingScopeNode's own round-16 essay and from Out of Scope.

* docs: close round-17 under-escape gaps in WU-2 condition 4 (#2637 + 3 new)

Closes #2637 (introducesShadowedBinding's switch_body case carries the
identical missing-using_declaration gap its statement_block case did
before round 16) rather than carrying it further. Auditing every other
SCOPE_NODE_TYPES member for the same gap, instead of stopping at
switch_body alone, found one more instance: for_statement's own case
has the identical omission, verified runnable under Node 22.18 with
--js-explicit-resource-management (a using declaration in a C-style
for-loop's own init clause shadows an outer decoy exactly like the
switch_body case does). Both closed the same way round 16 closed
statement_block: a disjunct on findResolvingScopeNode alone, never on
the shared introducesShadowedBinding primitive.

Two further gaps, neither previously filed, found and closed in this
same round per the standing rule (an under-escape gap must be fixed
in the round that finds it, never filed as an accepted limitation):

- a var-kind for-of/for-in loop head (`for (var name of iter)`)
  rebinds the SAME module-scope binding a direct top-level var
  declaration created, since var is function-scoped, not
  block-scoped. Two independent gates both missed it:
  subtreeContainsReassignmentOf's for-in gate excluded ANY head
  carrying a kind field, var included, rather than only
  let/const/using (which alone create a genuinely new binding); and
  countHoistedVarScopeDeclarations had no case recognizing a
  for_in_statement as a hoisted declaration site at all, since the
  grammar places its kind/left fields directly under for_in_statement,
  never wrapped in a variable_declaration node. Both fixed
  independently, closing the same construct via two separate
  mechanisms.

- isGlobalObjectQualifiedWrite (round 16, #2634) recognized only the
  dot spelling of a global-object-qualified write (globalThis.name =
  ...); the identical write spelled with bracket-subscript notation
  (globalThis['name'] = ...) is a subscript_expression, invisible for
  the identical reason the dot spelling was before round 16. Closed
  by a new subscript_expression arm reusing
  isTrackedReferencePosition's own static-key normalization verbatim.

Also adds a new, unconditional with_statement disjunct to
findResolvingScopeNode: no case existed anywhere in the shadow chain
for a sloppy-mode `with (obj) { ... }` block, so a same-named
module-level decoy resolved through it with full, unearned
confidence. Corrects the Risks table's own prior framing, which
grouped `with` alongside `eval` as something "no static analysis can
see through" since round 14 - true of with's RESOLUTION target, false
of its mere PRESENCE as an ordinary, detectable AST node. eval remains
correctly Category F; with did not need to be.

All five fixes are closed WITHOUT widening patternBindsName,
introducesShadowedBinding, or SCOPE_NODE_TYPES themselves - the same
discipline rounds 11-16 established. Mirrors all five into WU-7's Rust
description, with a new round-17 parity-risk paragraph covering each
fix's own porting hazard, including the two-function var-for-in gap's
"passes every fixture while being half wrong on one engine" risk.

Adds five new escape-fallback fixtures (cases (ar)-(av), one per
closed path, (ar)/(av) both crediting #2637) and five new
correlation-shape guards (13-17, one per fix, proving none of them
over-escapes the legitimate neighbouring shape). Strengthens guard
shape 9, which never actually invoked unwrapParens on its own source
(no assignment or for-in left to read) - adds a real parenthesized
write to a different name so the guard reaches the helper it names.

Fixes the standing rule's own wording gap: it said "every bullet
below," which scoped it to Success Criteria's own list and let #2637
(filed in Out of Scope, physically above) go unlabelled in the very
commit that wrote the rule. The rule now explicitly spans both lists.
Removes #2637's own Out of Scope bullet now that it is closed rather
than carried.

Reconciles counts throughout: 43 -> 48 escape-fallback shapes, twelve
-> seventeen correlation shapes, across the Testing Strategy table,
the "what no tier catches" reviewer-audit paragraph, the Risks table
(all three affected rows), and Success Criteria's own contract bullet
list.

* docs: apply Greptile's update_expression unwrapParens fix in WU-2

Greptile flagged (PR #2612, comment on subtreeContainsReassignmentOf's
update_expression branch) that a parenthesized update target such as
(run)++ is compared directly against the identifier check instead of
being routed through unwrapParens first - the one branch round 16's
own #2630 fix left untouched (the assignment and for-in branches both
already got it).

Verified the branch does read argument without unwrapParens, and
fixed it the same way: route argument through unwrapParens before the
identifier comparison, matching its two siblings exactly.

Also verified, empirically rather than assumed, that this gap carries
no soundness cost unlike every other fix landed this round: an update
expression performs ECMAScript's own ToNumeric coercion on its operand,
so (name)++/(name)-- can never reassign name to an arbitrary new
function value the way an assignment or a for-in rebind can. Confirmed
directly - `let run = () => {}; (run)++;` leaves run as NaN, never a
callable this-using function. So there is no construction through this
branch alone where a genuinely this-using handler was ever wrongly
read as this-free because of the missing unwrapParens call. Fixed for
structural consistency with the sibling branches and to close the
finding, not because a live-reported-dead repro exists - and none is
fabricated to manufacture one where none can exist.

Adds correlation shape 18 (a parenthesized update to a different name
must not perturb an unrelated table's own correlation) but no matching
escape-fallback case, since no soundness repro is possible here.
Mirrors the fix into WU-7's Rust description. Reconciles the
correlation-shape count (seventeen -> eighteen) and adds a short,
accurate note to the Risks table and Success Criteria explaining why
this one closes without the under-escape framing every other round-17
fix carries.

Replied to the Greptile thread with this same reasoning before
triggering a re-review.

* docs: close round-18 gaps in WU-2 condition 3/4, reopen #2637

Round 17's for_statement disjunct in findResolvingScopeNode scanned for
a using_declaration node that tree-sitter-javascript@0.25.0's grammar can
never produce as a for_statement initializer (verified against
grammar.js, node-types.json, and the real parser directly, which
surfaces the broken text as an ERROR node instead) - the disjunct was
dead code, and #2637 was never actually closed for that half. Reopened
and re-closed by keying on the actual ERROR shape (both the plain and
await-using spellings) and failing safe unconditionally, mirroring
with_statement. Adds a standing rule: every fixture must be parsed with
the real grammar, and the node type a fix keys on confirmed present in
the tree, not inferred from runtime behavior under Node.

Also closes three further under-escape gaps found while auditing this
round's own scope: a getter can smuggle a this-using function through
its return value with no this token in its own body
(literalHasUnmodeledThisReference); allReferencesTracked's reuse of
introducesShadowedBinding treats a method_definition's bare property
name as a binding, spuriously pruning a genuine reference when a nested
method happens to share the tracked binding's name; and a single paren
layer around a global-object identifier defeats
isGlobalObjectQualifiedWrite in both arms.

Rebuilds correlation shapes 16 and 17 (their round-17 originals never
exercised the disjuncts they claimed to guard) and adds three new
correlation shapes plus four new escape-fallback cases for this round's
fixes. Mirrors all changes in WU-7's Rust section. Reconciles counts,
Testing Strategy, Risks table, and Success Criteria. Fixes two
non-blocking nits (isTrackedReferencePosition's for-of discriminator,
enclosingObjectLiteral's stale doc prose) and files #2638/#2639 for a
new getter over-escape exclusion and a pre-existing collectForOfBinding
bug found along the way.

* docs: close two outstanding Greptile findings from this PR in WU-2

Two Greptile review comments on this PR were left unreplied: "Parenthesized
global writes go undetected" and "Var aliases escape the scope walk".

The first is the whole-target-parenthesized variant of the paren-wrapped
globalThis gap the previous commit already fixed one layer differently
((globalThis).run vs (globalThis.run)) - the same call-site unwrapParens
fix in isGlobalObjectQualifiedWrite's caller closes both, confirmed and
extended with Greptile's own repro as a second property on case (az).

The second is new: allReferencesTracked's rebinding recursion reuses the
outer call's declaringScope unconditionally for a recursive alias check.
That reuse is sound only when the alias is lexically (let/const) scoped,
since only then is the alias's own visibility guaranteed to stay inside
the boundary that already contains it. A var-declared alias is
function-scoped, so `var u = T` inside a block narrower than the
enclosing function makes u referenceable outside that block - a region
the reused boundary never reaches, so a genuine downstream reference is
silently missed and the site reads local-closed though it can still
escape through u. Closed by widening the recursive call's own boundary to
the alias's nearest enclosing function (or root) specifically when its
declarator is var-kind; a let/const alias is unaffected. Mirrored in
WU-7's Rust section. Adds escape-fallback case (ba) and correlation shape
22, and reconciles counts throughout.

* docs: close round-19 under-escape gaps in WU-2 conditions 3/4

Three new under-escape findings, all fixed this round per the standing
rule (docs/plans/issue-2088.md):

- finding 1: a non-computed `__proto__` pair sets the table's own
  [[Prototype]] (ECMA-262 Annex B.3.1) rather than an ordinary own
  property, so a method reached through it binds `this` to the table
  directly, with none of the extra property hop
  isPositivelyThisFreeLiteral's object/array arms rely on. Fixed by a
  caller-side, key-shape check in literalHasUnmodeledThisReference,
  ahead of any value-shape reasoning; a computed ['__proto__'] key is
  deliberately excluded, since it creates an ordinary own property and
  is not given special meaning by the spec.
- finding 2: allReferencesTracked's for-of recursion reused the outer
  call's declaringScope unconditionally for a var-kind loop variable,
  the same gap round 18 closed for the rebinding recursion but did not
  close here; round 18's own essay asserted a for-of loop variable was
  "always block-scoped" without checking, which is false for `var`.
  Fixed by widening the for-of recursion's own boundary the identical
  way for a var-kind head, and the false parenthetical is corrected.
- finding 3: allReferencesTracked's reference-matching walk matched
  `identifier` nodes only, so a binding forwarded by shorthand property
  (`sink({ T })`) was invisible to the walk entirely rather than
  classified untracked. Fixed by widening the filter to also match
  shorthand_property_identifier; a pair's own property_identifier key
  is deliberately excluded, since a key is never itself a
  value-producing reference.

Also, applying the same ablation discipline to round 18's own
fixtures rather than only to this round's new ones:

- correlation shape 17 (the for_statement/malformed-using guard,
  rebuilt round 18) was still vacuous after ablating the disjunct it
  claims to guard: its other property resolved to a handler declared
  only in a nested scope, which independently fails safe regardless of
  the disjunct. Rebuilt again so the disjunct is the only thing that
  can make the site escape.
- correlation shape 19's EXPECT asserted `escapes = 0` for a literal
  containing a getter, contradicting this design's own U2 rule (a
  get-flavoured method_definition escapes unconditionally) and its own
  Success Criteria. Corrected to `escapes = 1`, and its prose ("each
  property is still judged on its own shape") is corrected to reflect
  that literalHasUnmodeledThisReference is a whole-literal predicate.

Adds a new standing rule alongside the existing fail-closed,
direction-label, and fixture-parse rules: every fix must be shown
load-bearing by ablation (removing it must flip its own
escape-fallback case from 1 to 0), and every guard/correlation shape
must flip the opposite way when the fix it guards is removed. This is
how both fixture defects above were found.

Non-blocking cleanup while in there:

- subtreeContainsThisKeyword is exactly as blind to
  eval('this.alpha()') as subtreeContainsReassignmentOf already
  discloses being to eval('name = fn'); the Category F acceptance is
  now stated against both consumers, not only one.
- resolveIdentifierValueThisReference compared declaringScope to root
  by object identity instead of by .id, the one node-identity
  comparison in this file that did not follow the established
  convention; corrected for consistency, no behavior change.

Files GH issue #2640 for a related, deliberately-not-fixed gap: a
classic-script `globalThis.T.alpha()` read is invisible to
allReferencesTracked's walk the same way the pre-round-16 write side
was. This is under-escape in direction and does not fit the plan's own
#2610-style exception (allReferencesTracked is new machinery this plan
introduces, and no other condition already bounds the cost) — recorded
in Out of Scope as a flagged departure from the DIRECTION-labels
standing rule pending explicit human sign-off, not as a quiet
exception to it.

All three findings, both fixture corrections, and the .id consistency
fix are mirrored in WU-7's Rust section one-for-one, with their own
parity-risk paragraph; the Testing Strategy, Risks & Mitigations, and
Success Criteria sections are reconciled to match. No shared primitive
(patternBindsName, introducesShadowedBinding, SCOPE_NODE_TYPES) is
widened by any of this.

* docs: close round-20 escape-analysis gaps in WU-2, apply ablation discipline retroactively

Round 20 closes five blocking soundness gaps found in review, applies the
round-19 ablation-verification standing rule retroactively to two of round
19's own fixtures (which had never actually been run through it), and closes
two further non-blocking consistency gaps.

- B1: literalHasUnmodeledThisReference's __proto__-key check compared raw
  source text, evading a unicode-escaped spelling that cooks to the same
  dangerous key. Fixed by additionally fail-safing on any backslash in a
  non-computed key's own raw text.
- B2: allReferencesTracked's var-boundary widening (rounds 18/19, for the
  rebinding-alias and for-of-loop-variable recursions) targeted the enclosing
  function-shape node itself, reopening round 8's self-shadow bug one level
  up when a same-named function/class declaration sits at that function's own
  top level. Fixed by targeting that node's own `body` field instead, reusing
  round 8's existing declaringScope exemption unchanged.
- B3/B4: correlation shapes 23 and 25 (round 19) were vacuous -- ablating what
  each claimed to guard left `escapes` unchanged in both cases. Shape 23 is
  rebuilt with an escaped computed key; shape 25 needed no source change,
  only the B2 fix, after which its existing assertion is finally load-bearing.
- B5: closes #2640 (a classic-script `globalThis.T.alpha()` read, the
  symmetric read-side gap to round 16's own write-side #2634), which round
  19's review flagged as an explicit, tracked departure from the standing
  rule that an under-escape gap must be fixed the round it is found rather
  than carried. allReferencesTracked's candidate-matching walk gains a third,
  structural way to recognise a reference, reusing isGlobalObjectQualifiedWrite
  verbatim from its existing write-side role.
- G1 (Greptile): isGlobalObjectQualifiedWrite's subscript arm did not unwrap
  a parenthesized INDEX, only a parenthesized object.
- UE-C (non-blocking): isTrackedReferencePosition now rejects a member call
  whose property is call/apply/bind, since the general call extractor already
  strips the receiver from any such call regardless of context.

Every fix above was ablated against a standalone model of its own predicate
chain (real tree-sitter-javascript grammar) before this doc was written, per
round 19's own standing rule -- applied here for the first time to a round's
own fixtures as they were built, not discovered missing after the fact.

Mirrored in WU-7's Rust section. New escape-fallback cases (be)-(bj) and
correlation shape 26; correlation shapes 23 and 25 rebuilt with corrected
commentary. Two new follow-up issues filed for narrower, deliberately
out-of-scope capabilities: #2641 (a full string/identifier unescaper for the
B1 check, rather than the coarser backslash fail-safe) and #2642 (an optional,
broader file-level eval/new Function fail-safe, UE-D).

Closes #2640.

* docs: fix round-8 self-shadow bug recurring at every allReferencesTracked recursion level (round 21)

Round 8's self-shadow exemption is keyed to the top-level call's
declaringScope alone. Every recursive call (rebinding alias, for-of loop
variable) reused that one node unchanged, or widened it to a single further
node (rounds 18/19, var-kind only) also reused unchanged thereafter -- so
the recursion subject's own declaring block, reached during its own
recursive walk but never itself exempt, self-shadow-pruned exactly as the
original site's declaring scope did pre-round-8. Ten executed constructions
confirm it: the simplest trigger, six container-shape variants (bare block,
if, switch, try, for-init, arrow body -- collapsed to one guarding fixture
per the plan's own established discipline), a two-hop alias chain, a for-of/
sibling-declaration collision, and A10 (closing the false claim that
declaringScope is always block-shaped).

Fixed by having each recursion level compute and exempt its OWN
declaringScope, reseeded from that level's own subject via the same
findDeclaringScopeNode the top-level call already uses. Verified by
ablation, not merely argued:
- Fully subsumes round 18's var-kind-gated alias widening (removed) and
  round 20's B2 body-vs-node correction for that recursion (proven
  structurally unreachable through this walk, not merely untested).
- Does NOT subsume the for-of recursion's own kind==='var' gate or its B2
  correction, which stay -- reattributed to a pre-existing gap in the
  shared, unmodified functionScopeDeclaresVar primitive (no case recognises
  a `for (var x of y)` head as a hoisting shape at all), filed as #2643
  rather than fixed by widening that primitive.

Built and calibrated an executable model (web-tree-sitter +
tree-sitter-javascript@0.25.0, shared primitives transcribed verbatim from
src/extractors/javascript.ts) against all 88 published fixtures before
trusting any result; six new escape-fallback cases (bk)-(bp) added, four
existing cases/shapes (ba/bd/bf/bg, 22/25) re-verified with corrected
commentary, zero regressions.

Non-blocking, also addressed this round:
- UE-C's stated cost corrected: rejecting a .bind/.call/.apply reference
  escapes the whole site, not "nothing this design could otherwise have
  credited."
- B5 disclosed to over-fire on a local binding named
  self/window/global/globalThis shadowing the real global object
  (recall-direction only).
- WU-10 standing assertions added on cases (av)/(aw)/shape 17's own
  ERROR-node dependency, so a future grammar update that starts parsing
  `using` cleanly there fails loudly instead of silently going vacuous.
- Inline same-file-declaration note added to correlation shapes 1-7 and
  escape-fallback cases (a)-(p), whose elision made four ablations read as
  vacuous in the critic's first pass.
- Round 18's alias-widening kind==='var' gate no longer exists to lack a
  guarding fixture, since the gate itself is removed this round.

Reconciled fixture counts (26 correlation shapes, 68 escape-fallback cases),
Testing Strategy, Risks & Mitigations, and the WU-7 Rust-mirror sections
(including their own copy of the now-corrected claim and a new round-21
parity-risk paragraph).

* docs: fix class_static_block var-scope gap in WU-2, correct round-21 miscounts (round 22)

Blocking finding: class_static_block is absent from FUNCTION_SCOPE_NODE_TYPES
in both engines, so functionScopeDeclaresVar attributes a var hoisted inside
a static block to the enclosing function, spuriously shadowing it and
pruning a genuine reference (UNDER-escape). Fixed via a new WU-2-local
functionScopeDeclaresVarExcludingStaticBlocks re-derivation, substituted in
allReferencesTracked (extended from round 18's method_definition-only
carve-out to all six FUNCTION_SCOPE_NODE_TYPES kinds) and in
findDeclaringScopeNode's function-shape ancestor test - the shared,
multi-consumer functionScopeDeclaresVar/FUNCTION_SCOPE_NODE_TYPES stay
untouched. Mirrored in the Rust extractor. Verified against the real
tree-sitter-javascript@0.25.0 grammar and a real Node runtime oracle across
eight trigger constructions and two controls; ablation confirms the fix is
load-bearing and does not over-widen. Nine new escape-fallback fixtures
(bq)-(by) and correlation shape 27 added to WU-10. The residual gap in the
shared primitive's other, already-shipped consumer is filed separately as
#2644 (UNDER-escape, contrasting with round 21's #2643, which is
OVER-escape/recall-only).

Report-integrity corrections to round 21's own text: the ablation-flip count
("flips nine...") is corrected to what actually reproduces (6 fixtures / 8 of
10 constructions); the dangling (bk)-(bq) case-range citation is corrected to
(bk)-(bp), the highest case round 21 actually added; findEnclosingFunctionBody
and isVarKindDeclarator are now honestly labeled as this round's own
exposition-only coinages rather than implied pre-existing names.

Structural change: WU-10 gains a mechanically generated fixture matrix
(container x decoy x escape-shape x owner-form, executed under Node against
the design's own escapes verdict) as the primary mechanism for finding new
gaps going forward, since the last two blocking findings (round 21's alias
shape, this round's static-block shape) were both ordinary constructions
found by executing generated combinations, not by reading the prose - the
94 hand-written cases are retained as named regression anchors, not
superseded.

* docs: fix WU-10 fixture-matrix oracle, axes, and case (by); reconcile counts

An independent review of the round-22 fixture matrix found its own test
apparatus unsound, while confirming the class_static_block fix it guards is
correct and load-bearing (ablating it reproduces the pre-fix failure). This
commit fixes the matrix, not the escape analysis.

Oracle: the matrix's runtime-vs-escapes comparison was a two-way
`invoked <=> !escapes` check, which endorses an under-escape bug and flags
its own fix as a regression (escapes=1 on a genuinely invoked handler is
correct fallback behavior, not a mismatch). Replaced with the three-term
predicate the contract actually needs: `invoked === true && escapes === 0
&& !hasT1Evidence`. Only that combination is a genuine, mechanically-found
gap; escapes=1 on a live handler is now stated explicitly as a pass.

Axes: the matrix generated only the class_static_block decoy and none of
four other axes the round history shows matter. Added: an alias/hop-depth
axis (direct, const/let/var alias, two-hop chain, for-of loop variable) so
round 21's own `const u = T` finding is generatable; an object-literal
content axis (identifier/function/shorthand/__proto__/getter/setter/spread/
method/call-expression/nested value) so condition 4's shape-recognition
chain is exercised at all, not just the decoy/container surroundings; split
the owner-form and for-of-decoy axes by declarator/loop-head keyword
(const/let/var/using) since round 21's A10 and round 17's var-kind for-of
finding each depend on that specific keyword. Pinned down where the owner
sits relative to the container (always module-scope; container places the
decoy and the reference), and added the missing detection rule for
unconstructible combinations: execution is the detector, a SyntaxError
means skip-and-record, not fail. Verified all five of this plan's own
historical blocking findings (const u = T; class_static_block; __proto__;
var-kind for-of head; bare arrow parameter) are each reachable as a single
generated combination once the new axes exist.

Case (by): reshaped from an inline for-of array literal (`for (const r of
[T])`) to the named array-element-owner form (bo)/(bd) already use. The
inline form made T's own reference a child of an `array` node, which
TRACKED_REFERENCE_PARENTS does not recognise, so the site escaped for that
unrelated reason before the for-of-loop-variable recursion under test was
ever reached -- self-ablation confirmed only 7 of the 8 claimed (bq)-(by)
triggers actually flipped. The reshaped case flips correctly (escapes 1 ->
0 when the fix is ablated), restoring the round-22 essay's "eight of eight"
claim to something that reproduces.

Also: fixed a severed sentence in the round-21 essay (WU-2b); corrected the
findEnclosingFunctionBody attribution in case (bg)'s commentary from round
22 to round 21, matching the name's real origin (4179/6951) and this same
comment's own "corrected ROUND 21" header; reconciled the escape-fallback +
correlation case counts to a mechanical 104 (77 + 27), fixed the
self-contradictory "twenty-six ... 1-26 ... and one more, 27" naming-
convention sentence and two other stale "twenty-six" counts that predated
correlation shape 27's own addition; completed the "(bk)-(bp), ten
constructions" enumeration, which was missing A10.

No change to the escape analysis, WU-2/WU-2b's fixes, or any shared
primitive. WU-7/WU-8 (the Rust mirror) need no update: nothing here
changes engine behavior -- the oracle and axes are WU-10 test-harness
methodology, and the JS fixture (by) already runs against both engines
unchanged in shape.

* docs: fix hasT1Evidence scoping and WU-10 coverage-claim gaps (round 23)

- Scope hasT1Evidence to the handler property under test, not the
  site: a site-scoped reading silently passes 3 of 5 of this design's
  own historical findings (rounds 12, 17, 19), verified by
  constructing and executing all five.
- Add the missing control value to the escape-shape axis, a
  decoy-target sub-dimension to the for-of decoy, and a new
  literal-placement axis -- the three additions needed before the
  round-19/17/12 coverage claims actually reproduce; the previously
  claimed combinations do not, confirmed by execution.
- Recompute the nominal combination count (831,600 -> 2,332,800) and
  state the matrix's execution strategy, wall-clock budget, and
  cadence (in-process, nightly job, not a PR gate).
- Drop uncited, unverifiable figures (a 2,505-case delta, 36/231
  SyntaxError counts, a 43%/72% false-alarm rate) with no run log to
  back them.

All changes additive; the matrix is not shrunk, and the escape
analysis itself is untouched.

* docs: fix under-escape gap in condition 2's alias recursion (round 25)

Condition 2 (the export check) ran exactly once, against the top-level
owner's own bindingName, before allReferencesTracked ever ran -- never
re-applied to any alias name the rebinding/for-of recursion introduces.
An alias can itself be exported while the table it aliases is not, and
an exported alias reaches the table cross-module exactly as an exported
table would. Verified end-to-end under real Node, two real ES modules:
`export const api = T` (T never exported); `api.alpha()` in the
importing module genuinely invokes the handler `T.run()` alone left as
this design's only in-file reference to T. escapes read false; fnAlpha
would be reported dead though api.alpha() invokes it on every import.

- Threads `exportedNames` through `allReferencesTracked` as a new
  parameter, unchanged across both recursions, and adds an unconditional
  `if (exportedNames.has(bindingName)) return false;` at the top of the
  function, re-run at every recursion level.
- Mirrored in WU-7's Rust (`all_references_tracked` gains the identical
  `exported_names` parameter and check); WU-8 needs no change, since
  `exported_names` never crosses the NAPI boundary this WU builds.
- Self-ablated against a standalone reference model (real
  tree-sitter-javascript parse, real Node): five flipping constructions
  (export const/let/var alias, a two-hop exported chain, an exp…
@carlos-alm carlos-alm closed this Sep 6, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant