Skip to content

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

Open
carlos-alm wants to merge 25 commits into
mainfrom
docs/plan-issue-2088
Open

[Plan] issue-2088: receiver-correlated invoked-property evidence for object-literal value-refs#2612
carlos-alm wants to merge 25 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)

⛔ Do NOT approve. This design currently reports live code as dead.

Six concrete paths exist at this head where a genuinely-invoked function is classified
dead. Each was verified by executing it under Node, not by argument. Round 16 is
closing all six; this gate will be updated when it lands.

What the audit found

Every round has hunted one property: 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.

Along the way the plan accumulated twenty tracked "exclusion" issues, and the review
process had been treating "documented in the plan and tracked in an open issue" as making a
residual gap acceptable. That criterion was wrong, and auditing it by direction rather
than by disclosure is what surfaced the problem:

Direction Count Meaning
OVER-escape 14 Site escapes when it needn't. Costs recall only. Genuinely fine.
Pre-existing 1 (#2610) Inherited; verified this plan does not worsen it.
UNDER-escape 4 #2630, #2632, #2634, #2636live code reported dead.

An under-escape gap is not a recall trade-off. It is the exact failure class this plan calls
"a regression against today" every time it has fixed one inline — which it did in rounds
13, 14, and 15. Fixing three instances while filing four more as accepted limitations is
self-inconsistent, and no issue number makes a false positive acceptable.

Three of the four were misfiled. #2630's own issue is headed "why it's the safe
direction"
— true for one of its three consumers, false for both condition-4 consumers.
#2636 claims fail-safety on reasoning that only covers the all-generator case, and the plan
repeats that non-sequitur verbatim. #2634 sits alongside genuine fail-safe trade-offs but is
a missed write. #2632 is correctly diagnosed in its own issue as "confidently wrong" —
and is absent from the plan entirely.

Where the design actually stands

Two axes have been enumerated rather than probed case-by-case:

  • Lexical shadow — 34 binding constructs parsed against the real grammar and run through
    the plan's own walk logic. Closed, and re-verified across three independent rounds.
  • Value divergence — 24 mechanisms by which a resolved binding's runtime value can
    differ from its declaration. All classified; the remaining unsound ones are the four above.

Two structural rules came out of this and are why the findings narrowed from architectural
to mechanical: a non-vacuous coverage rule (a walk must be proven exhaustive or it
fails closed) and a fail-closed contract at the escape decision itself (every predicate
must return "escaping" for any shape it does not positively recognise, inherited
automatically by predicates added later).

What the plan gets right

ADR-002's "no new subsystem" binding is honoured — the Andersen solver loop is untouched.
Dual-engine parity is named for every added function. One DEFAULTS key, no stray magic
numbers, clean v32 migration. The correlation tests genuinely discriminate: shape 1 asserts a
symbol dead against a cross-file same-named decoy, an outcome the bare-name fallback
structurally cannot produce, and shape 8 proves a tightening did not over-escape rather than
merely asserting it. All 37 escape-fallback cases assert the escaping outcome rather than
certifying a bug.

The scope question, which is yours and not the reviewers'

Setting soundness aside, this plan is now considerably narrower than its first draft, and
says so. T1 correlation fires for a deliberately small core. The remaining recall gain, on an
advisory command (roles --role dead), has to be weighed against 10 work units, a dual-engine
mirror, and a v32 DB migration. That trade is a judgement no critic can make for you.

Reviewing this plan also turned up #2628 — a suspected bug in shipped product code,
unrelated to #2088.


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, #2610 without a closing keyword (Closes #N / Fixes #N). If this PR fully resolves #2260, #2087, #2610, 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 execution plan for allocation-site-correlated object-literal invocation evidence, including TypeScript/Rust parity, persistence, escape analysis, and regression verification.

  • Defines a three-tier evidence model with conservative fallback for escaping sites.
  • Plans extractor, points-to solver, database, worker/native boundary, configuration, and documentation changes.
  • Adds extensive correlation, escape-fallback, parity, and ablation-verification requirements.

Confidence Score: 4/5

The plan is not yet safe to approve for execution because parenthesized global-object subscript writes remain invisible to the proposed reassignment scan.

The planned isGlobalObjectQualifiedWrite implementation checks the subscript index node without unwrapping parentheses, so globalThis[('run')] = fn is missed and stale handler information can still cause a live property value to be classified dead.

Files Needing Attention: docs/plans/issue-2088.md

Important Files Changed

Filename Overview
docs/plans/issue-2088.md Comprehensive implementation plan covering allocation-site correlation, conservative escape handling, dual-engine parity, persistence, and verification.

Reviews (30): Last reviewed commit: "docs: close round-19 under-escape gaps i..." | 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.

Comment thread docs/plans/issue-2088.md Outdated
Comment on lines +2024 to +2026
if (node.type === 'assignment_expression' || node.type === 'augmented_assignment_expression') {
const left = node.childForFieldName('left');
if (left?.type === 'identifier' && left.text === name) return true;

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 Destructuring writes bypass reassignment tracking

When a mutable module-level handler is replaced through a destructuring assignment such as [run] = [function () { return this.alpha(); }], this branch ignores the write because the left side is a pattern rather than a bare identifier. Identifier resolution then trusts the original arrow as this-free, allowing exclusive correlated evidence to report a live sibling handler 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 752405f. subtreeContainsReassignmentOf's assignment-expression branch now routes through patternBindsName(left, name) instead of the bare left?.type === 'identifier' && left.text === name check, so a destructuring target -- [run] = […], ({ run } = …), and nested/defaulted variants -- is caught, the same way blockContainsIdentifierExcluding's own assignment branch and killsBinding's already handle this exact field. Added escape-fallback cases (af)/(ag) (array- and object-destructuring reassignment respectively) to gate it, and mirrored the fix in the Rust extractor (WU-7).

One residual gap, tracked rather than silently accepted: patternBindsName itself has no case for a parenthesized_expression, so (run) = fn is still invisible to this branch -- the same gap already open at #2630 for the for-in arm, whose scope this commit extends to name the assignment arm as a fourth affected consumer. #2630 stays open.

…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.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

Round 14. New head: 752405f0 ("docs: close round-14 write-scan and duplicate-declaration gaps in WU-2 condition 4"), on top of 34a2f7a2.

Two blocking findings, both surfaced by an independent critic pass that ran the plan's helpers against the real tree-sitter-javascript@0.25.0 grammar:

Finding 1 (also independently flagged by Greptile on this PR -- see my reply on that inline comment) -- the write-scan was narrower than the question it answers. subtreeContainsReassignmentOf's assignment-expression branch tested only left?.type === 'identifier', but assignment_expression.left also permits array_pattern/object_pattern/parenthesized_expression/subscript_expression -- every destructuring reassignment ([run] = [fn], ({ run } = o)) slipped past undetected, letting a stale, already-resolved initial value be trusted as this-free. Fixed by routing that branch through patternBindsName, the same primitive this function's own for-in branch, blockContainsIdentifierExcluding, and killsBinding already use for the identical question. A parenthesized target ((run) = fn) remains invisible -- patternBindsName itself has no case for one -- tracked at #2630, whose scope this commit extends to a fourth affected call site. I also posted a correction on #2630 today: an earlier comment of mine there wrongly claimed under-detection in this exact spot was conservative rather than unsound.

Finding 2 -- findTopLevelFunctionNodeByName returned the FIRST of several top-level declarations of a name, not the one actually in effect. A var name = () => {}; var name = function () { return this.alpha(); }; redeclaration (legal in both ESM and CJS) or a duplicate sibling function name(){} (legal in CJS) resolved to the FIRST, this-free declaration with full confidence, though the runtime binding is always the LAST -- the identical "confidently wrong rather than honestly unresolved" failure class rounds 10-13 already closed elsewhere in this same chain. Fixed by making this function count every top-level declaration and fail safe (null) once more than one exists -- the first round in which this function's own body, not only its caller's, changes. Filed as a follow-up (#2633) for the recall this costs, matching round 10's and round 13's own precedent.

Also this round: corrected two overstated doc claims (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 pre-fix narrower scan); added a complexity note on the intended per-file pre-pass implementation (O(sites x properties x file size) as specified, versus a single whole-file pre-pass into a Set<string>); disclosed the eval/with/globalThis-write residual gaps, the last one filed as #2634; added four new escape-fallback fixtures, (af)-(ai); and mirrored both fixes in the Rust extractor (WU-7), including a new round-14 parity-risk note.

…dition 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.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

Round 15. New head: 63c63c3f ("docs: close round-15 var/Annex-B hoist-through-blocks gap in WU-2 condition 4"), on top of 752405f0.

This round found that findTopLevelFunctionNodeByName's round-14 fix (#2633's own duplicate-declaration fail-safe) was itself incomplete: it only ever counted a declaration that is a direct child of program, but var is function-scoped, not block-scoped — a var name re-declared inside a bare if/for/try/switch body at module level hoists to the SAME binding a direct top-level var name would, and sloppy-mode Annex B extends the identical hazard to a block-level function name(){}. Neither shape was visible to the round-14 count, which resolved to the FIRST (this-free) declaration with full confidence instead of failing safe. Verified under real Node — both the ESM var-in-block shape and the CommonJS Annex-B shape resolve to the SECOND, this-using declaration at runtime.

This also corrects the round-14 scope-coverage note itself, 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.

Fixed by generalising the count to the module's own var scope: a new countHoistedVarScopeDeclarations helper reuses functionScopeDeclaresVar's existing traversal rule (skip a nested function's own scope), widened from a boolean to a count, and covering a var declarator or block-level function_declaration reachable without crossing a function boundary. lexical_declaration (let/const) is deliberately excluded — that's a genuinely different, block-scoped binding already owned by the shadow axis (findResolvingScopeNode), not this one — and a new correlation shape (8) proves that exclusion holds rather than merely asserting it. Two new escape-fallback cases, (aj) and (ak), cover the var-in-block and Annex-B shapes respectively. Mirrored in the Rust engine's notes (find_top_level_function_node_by_name / count_hoisted_var_scope_declarations).

Also reconciled: the Testing Strategy table's shape counts (eight correlation shapes, 37 escape-fallback cases), the Risks & Mitigations and Success Criteria sections' round-by-round narratives, and a spelled-out note that cases (ai)/(ak) need a genuine non-ESM CommonJS fixture file (round 14 stated this in case (ai)'s own comment but never surfaced it at the Testing Strategy level).

Two recall costs specific to this round's own fix are disclosed rather than silently accepted, and filed as a follow-up (#2635): it never resolves to a declaration that exists only inside a nested block (always fails safe even when that's the sole, unambiguous declaration), and it doesn't gate the Annex-B branch on the file's own strict/sloppy/module parse goal, so it over-counts where Annex B couldn't actually apply.

Also replied to and closed an older outstanding thread from this PR ("Loop shadows bypass resolution guard") that round 11's findResolvingScopeNode fix (521ab3a) already addressed but had never gotten an explicit closing reply.

…nd 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.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review — new head since my last comment: 81b1be1a.

While re-verifying the round-15 fix by hand against case (ak) after pushing 63c63c3f, found a real bug in my own countHoistedVarScopeDeclarations: it pre-filtered a child's node type against FUNCTION_SCOPE_NODE_TYPES before ever recursing into it, copying functionScopeDeclaresVar's traversal shape verbatim. That's safe in the original function because the only kind it recognizes, variable_declaration, is never itself a member of that set. It's 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. Confirmed with a small simulation 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.

Fixed in 81b1be1a by checking each node's own type for a match first, then gating recursion on that same node's type, rather than filtering a child's type before ever visiting it. Also added this as a third, concrete parity-risk hazard in WU-7 for the Rust mirror, and filed #2636 for an unrelated, narrower gap found in the same pass (generator function declarations aren't recognized by the pre-existing 'function_declaration' string match, in either round 14's original loop or this round's extension).

#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.
Comment thread docs/plans/issue-2088.md Outdated
Comment on lines +2710 to +2712
} else if (node.type === 'update_expression') {
const arg = node.childForFieldName('argument');
if (arg?.type === 'identifier' && arg.text === name) return true;

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 Parenthesized updates evade reassignment tracking

When a mutable handler is updated through a parenthesized target such as (run)++, this branch compares the wrapped argument directly instead of applying unwrapParens. The scan therefore trusts stale information about the handler’s original value, allowing exclusive correlated evidence to classify a genuinely invoked sibling property as dead.

Suggested change
} else if (node.type === 'update_expression') {
const arg = node.childForFieldName('argument');
if (arg?.type === 'identifier' && arg.text === name) return true;
} else if (node.type === 'update_expression') {
const arg = node.childForFieldName('argument');
const target = arg ? unwrapParens(arg) : undefined;
if (target?.type === 'identifier' && target.text === name) return true;

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 f709b31. subtreeContainsReassignmentOf's update_expression branch now routes argument through unwrapParens before the identifier comparison, exactly matching the suggested diff and its two sibling branches.

One refinement to the framing, verified empirically rather than assumed: an update_expression performs ECMAScript's own ToNumeric coercion on its operand before writing back, 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 unlike the destructuring-write and parenthesized-assignment/for-in gaps this same function's history has closed, there's 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 — a call through the reassigned binding would throw, not silently invoke the wrong function. Fixed for structural consistency with the sibling branches (and because it's cheap and correct to do), not because a live-reported-dead repro exists — I didn't want to claim one in the plan doc's own commentary where I couldn't verify it.

Added correlation shape 18 (a parenthesized update to a different name must not perturb an unrelated table's own correlation) but no escape-fallback case, since none would demonstrate anything real. Mirrored into the Rust engine description (WU-7). Full details in docs/plans/issue-2088.md's subtreeContainsReassignmentOf doc comment and the Risks table's round-17 entry.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

Round 16 closes four tracked under-escape gaps that an audit found had been drifting into the same "accepted recall trade-off" framing as this plan's genuine over-escape exclusions, even though all four are live-code-reported-dead bugs, not design boundaries:

All four are mirrored into WU-7/WU-8's Rust, verified against the real, already-shipped Rust source rather than assumed to match. Six new escape-fallback fixtures (cases (al)-(aq)) and four new correlation-shape guards (9-12, one per fix, proving none of them over-escapes a legitimate neighbouring shape) are added; Testing Strategy, Risks, and Success Criteria counts are reconciled.

Also corrects three misfilings this round's own audit found: #2630's issue header called its gap uniformly "the safe direction," true for only one of its four consumers; #2634 was narrated alongside genuine fail-safe recall trade-offs in this plan's own Risks table, when it was actually a missed write; #2636's issue body claimed its gap was "fail-safe-already," true only when every redeclaration of a name is a generator, not when it's mixed with a plain declaration. Corrected via comments on the respective issues and in the plan doc itself.

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, exactly as this round does for all four. 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).

…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.
… 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.
Comment thread docs/plans/issue-2088.md

> **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 branch that ALSO satisfies this same two-part contract. Either an unproven walk OR a disqualifying reference makes the result `false` (escaping) — there is no third, "we're not sure, but let's call it safe" outcome. This is a STANDING RULE about the function's return contract, not a special case bolted onto the vacuous-empty-set scenario specifically: it applies identically whether the surviving set is empty, has one reference, or has a hundred. Getting this wrong toward "unproven ⇒ escapes" costs recall — the same asymmetry every other fail-safe default in this design accepts; getting it wrong the other way is precisely the class of bug this rule exists to catch structurally, in every FUTURE change to this walk, not only in the one instance found this round.

> **The rebinding branch recurses — accepting the `const u = T` reference is not enough on its own** (round-4 critic finding). `allReferencesTracked` must additionally hold, recursively, for the new alias name, with `isArrayOwner` UNCHANGED and `declaringScope` UNCHANGED (`allReferencesTracked(root, aliasName, objectNode, isArrayOwner, declaringScope)`) — an alias of the CONTAINER is still the container, not a single element, so it must keep whatever `isArrayOwner` value the site already has, and (round 8) the search boundary established for the original binding, since `u`'s own declaration is necessarily somewhere inside that same subtree (see the `findDeclaringScopeNode` note above) — or a site reads as local-closed while it can still escape through `u` — e.g. `const u = T; importedFn(u)`. The `name` field of the `variable_declarator` must itself be a plain `identifier`; a destructuring `name` such as `const { k } = T` is rejected the same way `findEnclosingTableName` already does, since destructuring extracts a property rather than aliasing the reference. The first cut of this branch (round-3) checked only the reference to `T` and never followed where `u` goes; that is exactly the same shape of gap condition 1 already documents for a return-captured binding, one alias hop later. The recursion depth is capped at 6, reusing `findEnclosingTableName`'s own `hops` bound rather than inventing a new one — a chain of `const a = T; const b = a; const c = b; …` cannot cycle (each step names a fresh `const` binding), so the cap is defense-in-depth, not a correctness requirement. A recursive call returning `false` — including by hitting the cap, and, as of round 8, by failing its own non-vacuous-coverage requirement — makes that reference, and so the whole site, escaping; it is not a partial result the other branches paper over. Coverage composes the same way: the OUTER call is proven-covered only if every recursive call it makes is also proven-covered.

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 Var aliases escape the scope walk

When a block-scoped table is assigned to a function-scoped alias such as var u = T, the recursive alias check searches only the original table binding's block and misses later uses of u in the enclosing function. The site is consequently treated as local-closed, allowing exclusive correlated evidence to report an invoked property value 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. allReferencesTracked's rebinding recursion reused the outer call's declaringScope unconditionally for the recursive alias check — sound for a let/const alias (every case this branch had been verified against through round 17), since a lexically-scoped alias's own visibility never extends past whatever block already contains it, but not for a var one: var is function-scoped, so var u = T declared inside a block narrower than the enclosing function makes u referenceable outside that block, a region the reused boundary never reaches.

Closed by widening the recursive call's own boundary — to the alias's nearest enclosing FUNCTION_SCOPE_NODE_TYPES member (or module root), computed from the alias's own declaration position — specifically when its declarator is var-kind; a let/const alias (or a for-of loop variable) keeps reusing the outer call's boundary exactly as before. SCOPE_NODE_TYPES/introducesShadowedBinding/findDeclaringScopeNode are untouched.

Mirrored in WU-7's Rust section. New escape-fallback case (ba) and correlation shape 22 (proving the widening is a no-op when the alias's nearest enclosing function already coincides with the table's own declaring scope). Full details in allReferencesTracked's own doc-comment essay (round 18), the Risks table, and Success Criteria.

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.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

Round 17 closes #2637 — the residual round 16 itself left standing, filed but not fixed — and closes two further under-escape gaps this round's own audit found, per the standing rule that an under-escape gap must be fixed in the round that finds it:

  • issue-2088 plan: introducesShadowedBinding's switch_body case may carry the identical using_declaration gap #2632 closed for statement_block #2637introducesShadowedBinding's switch_body case carries the identical missing-using_declaration gap its statement_block case did before round 16. Confirmed against the real, shipped source rather than assumed, and closed the same way round 16 closed statement_block: a disjunct on findResolvingScopeNode alone, not on the shared primitive. Auditing every other SCOPE_NODE_TYPES member for the identical gap, rather than stopping at switch_body, found one more instance — for_statement's own case has the same omission (a using-declared C-style for-loop init clause shadows an outer decoy exactly like switch_body does, verified runnable under Node 22.18 with --js-explicit-resource-management). Closed the same way and credited to the same issue.
  • Finding 1 — 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; and countHoistedVarScopeDeclarations had no case recognizing a for_in_statement as a hoisted declaration site at all (the grammar places kind/left directly under for_in_statement, never wrapped in a variable_declaration node). Both fixed independently, closing the same construct via two separate mechanisms.
  • Finding 2isGlobalObjectQualifiedWrite (round 16, issue-2088 plan: a script-scope var reassigned via globalThis.name = … is invisible to subtreeContainsReassignmentOf #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. Fixed with a new subscript_expression arm reusing isTrackedReferencePosition's own static-key normalization verbatim.

Also adds a new, unconditional with_statement disjunct to findResolvingScopeNode (finding 3): no case existed anywhere in the shadow chain for a sloppy-mode with (obj) { ... } block. This also corrects this plan's own Risks table, which had grouped with alongside eval since round 14 as something "no static analysis can see through" — true of with's resolution target, false of its mere presence as an ordinary, detectable AST node; eval remains correctly Category F.

All five fixes are mirrored into WU-7's Rust description, verified against the real, already-shipped Rust source rather than assumed, with a new round-17 parity-risk paragraph. Five new escape-fallback fixtures (cases (ar)-(av)) and five new correlation-shape guards (13-17) are added; the standing rule's own wording is fixed to span both the Success Criteria and Out of Scope lists (it previously only covered the former, which is how #2637 slipped past it unlabelled in the same commit that wrote it). Also strengthens correlation shape 9, which never actually invoked unwrapParens on its own source.

Separately, replied to and fixed the "Parenthesized updates evade reassignment tracking" comment: subtreeContainsReassignmentOf's update_expression branch was the one branch round 16's own #2630 fix didn't reach. Fixed for consistency, but verified empirically (not assumed) that it carries no soundness cost — an update expression's own ToNumeric coercion means (name)++/(name)-- can never reassign a handler to a new function value, so no live-reported-dead repro exists for it, unlike every other fix this round. Added correlation shape 18 as a guard; no escape-fallback case, since none would demonstrate anything real.

Testing Strategy, Risks, and Success Criteria counts are reconciled throughout (43 → 48 escape-fallback shapes, twelve → eighteen correlation shapes).

Comment thread docs/plans/issue-2088.md Outdated
Comment on lines +3060 to +3063
if (left && patternBindsName(unwrapParens(left), name)) return true;
// ROUND 16 (#2088, #2634) — a script-scope `var` is also exposed as a
// property of the global object; see this function's own doc comment.
if (left && isGlobalObjectQualifiedWrite(left, name)) return true;

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 Parenthesized global writes go undetected

When a module-level handler is reassigned through a parenthesized target such as (globalThis.run) = function () { return this.alpha(); }, patternBindsName receives the unwrapped target but isGlobalObjectQualifiedWrite receives the original parenthesized_expression and rejects it. The analysis then trusts the handler’s original this-free value, allowing exclusive correlated evidence to report a live sibling 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 — this is the exact call-site gap: patternBindsName(unwrapParens(left), name) already unwrapped left before this comment was filed, but the very next line, isGlobalObjectQualifiedWrite(left, name), passed the ORIGINAL, still-wrapped left straight through, so (globalThis.run) = … reached isGlobalObjectQualifiedWrite as a bare parenthesized_expression, matching neither of its two arms (member_expression/subscript_expression) at all.

Closed by routing left through unwrapParens at this call site too — isGlobalObjectQualifiedWrite(unwrapParens(left), name) — matching the identical fix unwrapParens(object) now applies inside the function's own two arms for the one-layer-shallower gap ((globalThis).run = …, parens around just the identifier rather than the whole target) that a different investigation into this same function surfaced independently. Both shapes are now covered by one combined fixture: escape-fallback case (az) exercises the object-only paren first, then extends with a second property using your exact repro ((globalThis.run) = …) for the whole-target paren. Mirrored in WU-7's Rust section.

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.
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.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

Round 18 reopens #2637: the for_statement half of round 17's fix was never actually reachable, and closes it properly this time, plus three further under-escape gaps this round's own audit found and two more flagged by Greptile directly on this PR — per the standing rule that an under-escape gap must be fixed in the round that finds it.

  • issue-2088 plan: introducesShadowedBinding's switch_body case may carry the identical using_declaration gap #2632 closed for statement_block #2637 (reopened) — round 17's findResolvingScopeNode disjunct for for_statement scanned for a using_declaration node that tree-sitter-javascript@0.25.0's grammar can never produce as a for_statement initializer at all (verified against grammar.js:375-390, node-types.json's own field schema, and — the check that was missing — actually parsing the fixture with the real, installed grammar via web-tree-sitter: the broken text surfaces as an ERROR node instead, with rootNode.hasError === true). The disjunct's own type check could never match anything; issue-2088 plan: introducesShadowedBinding's switch_body case may carry the identical using_declaration gap #2632 closed for statement_block #2637 was never closed for this half. Round 17's "grammar-valid, verified runnable under Node" claim was true of V8's own experimental Explicit Resource Management implementation, never of tree-sitter, and the two disagree here. Re-closed by keying on the actual ERROR shape (both the plain and await using spellings, the latter nesting one level deeper inside a misparsed assignment_expression) and failing safe unconditionally, mirroring with_statement. Adds a standing rule: every fixture in this plan must be parsed with the real grammar, and the node type a fix keys on confirmed present in the tree — a snippet "running under Node" is not evidence for a claim about what tree-sitter produces. Correlation shapes 16 and 17 (round 17's own originals for the with_statement and for_statement disjuncts) are rebuilt for the same reason: both were no-ops, never putting either disjunct's own ancestor node on a table's chain at all.
  • U2literalHasUnmodeledThisReference's method_definition arm proved safety only via subtreeContainsThisKeyword, sound for a plain method but not a getter: a getter's own body can be entirely this-free while RETURNING a value that, once accessed then called (T.k()), binds this to the receiver regardless of how the callee was obtained. Fixed by escaping unconditionally on a get-flavoured method_definition; a plain method and a setter are unaffected (a setter's return value is never called). Filed as a new, deliberate over-escape exclusion — issue-2088 plan: a get-flavoured method_definition can smuggle a this-using function through its return value #2638.
  • U3allReferencesTracked reuses introducesShadowedBinding's shared function-shape case, whose method_definition alternative treats the node's bare property NAME as a binding, the same way a function/class declaration's name field genuinely is. It is not one: a method's property key is not a lexical binding, so a nested method merely named the same as the tracked binding spuriously prunes its entire body, hiding a genuine reference — the same "walk is exhaustive over the wrong boundary and doesn't know it" shape as round 8's own headline bug, via a different mechanism (a false-positive shadow at a nested scope, not a self-shadow at the declaring one). Closed entirely in this consumer, re-deriving introducesShadowedBinding's own two genuine sub-checks (parameter binding, hoisted var) for this one node kind, without touching the shared primitive. Audited every other arm of introducesShadowedBinding for the identical false-positive direction; found no other instance.
  • U4isGlobalObjectQualifiedWrite required object.type === 'identifier' in both arms, so a paren layer around the global-object identifier ((globalThis).run = …) defeated it entirely. Fixed by routing object through the existing unwrapParens in both arms.

Two further items came directly from unreplied comments on this PR:

  • "Var aliases escape the scope walk" — confirmed and fixed. allReferencesTracked's rebinding recursion reused the outer call's declaringScope unconditionally, sound only for a lexically-scoped (let/const) alias, since a var-declared one is function-scoped and can be genuinely referenced past whatever narrower block the table itself sits in. Fixed by widening the recursive call's own boundary to the alias's nearest enclosing function (or root) specifically when its declarator is var-kind.
  • "Parenthesized global writes go undetected" — confirmed and fixed, and it turned out to be the identical call-site gap the U4 fix above needed one layer over: isGlobalObjectQualifiedWrite's own call site in subtreeContainsReassignmentOf passed left straight through unwrapped, so (globalThis.run) = … (the whole target parenthesized) matched neither of the function's two arms at all. Fixed by unwrapping at that call site too. One combined fixture (case (az)) now exercises both paren placements.

All fixes are mirrored into WU-7's Rust description with new round-18 parity-risk entries. Six new escape-fallback fixtures ((aw)-(ba)) and six new correlation-shape guards (16/17 rebuilt, 19-22 new) are added. Also fixes two non-blocking nits found in passing: isTrackedReferencePosition's for-of discriminator scanned all children for text 'of' rather than reading the operator field (a binding literally named of in a for-in loop would misclassify it) — filed the identical pre-existing gap in already-shipped collectForOfBinding separately as #2639, since it's unrelated to this plan's own scope; and enclosingObjectLiteral's surrounding prose said "or grandparent for shorthand," which was never true of the code.

Testing Strategy, Risks, and Success Criteria counts are reconciled throughout (48 → 53 escape-fallback shapes, eighteen → twenty-two correlation shapes).

Comment thread docs/plans/issue-2088.md
Comment on lines +3293 to +3295
const index = node.childForFieldName('index');
const indexType = index?.type;
if (indexType !== 'string' && indexType !== 'template_string') 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 Parenthesized indexes hide global writes

When a mutable module-level handler is reassigned through globalThis[('run')] = fn, the subscript receiver is unwrapped but its index remains a parenthesized_expression, so the write is missed and stale handler information can make correlated evidence report a genuinely invoked sibling property dead.

Suggested change
const index = node.childForFieldName('index');
const indexType = index?.type;
if (indexType !== 'string' && indexType !== 'template_string') return false;
const rawIndex = node.childForFieldName('index');
const index = rawIndex ? unwrapParens(rawIndex) : undefined;
const indexType = index?.type;
if (indexType !== 'string' && indexType !== 'template_string') return false;

Fix in Claude Code

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.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review.

Round 19 closes three further under-escape gaps found by re-auditing WU-2's condition 3/4 machinery, rebuilds one of round 18's own fixtures that turned out to still be vacuous, corrects one of round 18's own fixtures whose expectation contradicted this design's own rule, and adds a new standing rule (ablation verification) that is how both of those fixture defects were actually found.

  • Finding 1 — a non-computed __proto__ pair defeats isPositivelyThisFreeLiteral. Its object/array arms conclude "not callable as T.key()" from the value's own node type, reasoning that reaching a nested function needs an extra property hop that rebinds the receiver away from T. That reasoning does not hold for __proto__:, the one key ECMA-262 Annex B.3.1 gives special meaning: it sets T's own [[Prototype]], not an ordinary own property, so a method on the assigned object is reachable as T.method() with no extra hop and this bound directly to T. Verified runnable: const T = { alpha: fnA, __proto__: { run() { return this.alpha(); } } }; T.run() invokes fnA with this === T. Fixed by a caller-side check on the pair's own key field, ahead of any value-shape branching — a non-computed property_identifier/string key spelled __proto__ escapes unconditionally, regardless of value. A computed ['__proto__'] key is deliberately excluded and stays safe: Annex B.3.1's magic applies only to the non-computed spelling, and ['__proto__']: {...} creates an ordinary own property like any other computed key (verified: no prototype change, Object.getOwnPropertyNames lists it as a normal key). isPositivelyThisFreeLiteral itself is untouched.
  • Finding 2 — allReferencesTracked's for-of recursion never got round 18's own var-boundary widening. Round 18 widened the rebinding recursion's search boundary for a var-declared alias (function-scoped, so its true visibility can extend past whatever block the table sits in), and closed with: "a let/const alias (or a for-of loop variable, always block-scoped) is unaffected." That parenthetical is false — for (var r of A) binds r at the enclosing function scope, and this file's own round-17 fixes (countHoistedVarScopeDeclarations, subtreeContainsReassignmentOf) already encode exactly that fact by testing for_in_statement's kind === 'var'. The for-of recursion (a sibling of the rebinding one, in the same function) still reused the outer call's un-widened declaringScope unconditionally. Verified runnable: a var-kind for-of loop variable declared inside an if-block, then referenced via an imported-style local sink() outside that block but inside the enclosing function, is genuinely reachable — the pre-fix recursion finds zero references within its too-narrow boundary and reads the site as local-closed. Fixed by widening the for-of recursion's own boundary the identical way, keyed on the identical kind === 'var' field test; the false parenthetical is corrected in place. A let-kind for-of head is unaffected (correlation shape 25 guards this).
  • Finding 3 — shorthand-property references are invisible to allReferencesTracked's walk. The walk matches identifier nodes whose text equals the tracked name; shorthand_property_identifier is a distinct tree-sitter node kind (this same file already relies on that distinction elsewhere), so sink({ T }) — forwarding T into an external function by shorthand — was never visited by the walk at all, not merely classified untracked. Executed both ways: identifier-only filter → escapes = false; adding shorthand_property_identifierescapes = true (control), with runtime confirming the forwarded table's method is genuinely invoked. Fixed by widening the walk's own node-type filter to match both kinds. A property_identifier (an ordinary object-literal key, e.g. { T: 5 }) is deliberately not added — a key is never itself a value-producing reference — correlation shape 24 guards this by putting an unrelated key of the same spelling in scope and confirming it does not get swept up.
  • Shape 17 rebuilt again. Round 18's own rebuild fixed shape 17's vacuousness (round 17's original never exercised the for_statement/malformed-using disjunct at all) but introduced a second, independent one: its run50: disposable50 pair resolved to a const declared only inside the enclosing function, which fails resolveIdentifierValueThisReference on its own, disjunct-independent first principle (no module-level declaration to resolve to). Ablating the disjunct entirely left this shape's escapes unchanged at 1, proving nothing about the disjunct specifically. Rebuilt so run50 resolves cleanly to the pre-existing module-level, this-free function run50() via shorthand, making the disjunct the only remaining thing able to make the site escape.
  • Shape 19 corrected. Its EXPECT asserted escapes = 0 for a literal containing a getter, and its prose claimed "each property is still judged on its own shape" — both wrong: literalHasUnmodeledThisReference is a whole-literal predicate (one escaping child fails the whole literal), and this design's own Success Criteria already says a co-located getter "now also makes its literal escape." Corrected to escapes = 1; the setter is reordered before the getter so the walk actually visits it (proving it safe) before the getter trips the unconditional rule, rather than leaving it unexercised dead weight in the fixture. The setter's own description elsewhere ("this-using-bodied") is reconciled against its actual, deliberately this-free body.
  • New standing rule: ablation verification, added alongside the existing fail-closed, DIRECTION-label, and fixture-parse rules — every fix must be shown load-bearing by ablation (remove it, and its own escape-fallback case must flip from escapes = 1 back to 0), and every guard/correlation shape must flip the opposite way when the fix it guards is removed. This is literally how both fixture defects above were found: applying this discipline to round 18's own fixtures, not only to this round's new ones.
  • Non-blocking, fixed 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 now names 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 didn't follow the established convention (isTrackedReferencePosition's own .id-based checks) — corrected for consistency, no behavior change.
  • Filed, not fixed: escape analysis: classic-script globalThis-qualified READS invisible to allReferencesTracked (#2088) #2640 — a classic-script globalThis.T.alpha() read is invisible to allReferencesTracked the same way the pre-round-16 write side was, before issue-2088 plan: a script-scope var reassigned via globalThis.name = … is invisible to subtreeContainsReassignmentOf #2634 closed it. This is under-escape in direction and doesn't fit the plan's own computedDispatchTableEvidence is in-memory only — scoped incremental builds can report live dispatch-table properties as dead #2610-style exception (allReferencesTracked is new machinery this plan introduces, and no other condition already bounds the cost the way computedDispatchTableEvidence is in-memory only — scoped incremental builds can report live dispatch-table properties as dead #2610's export-check argument does) — recorded in Out of Scope as an explicitly flagged departure from the plan's own DIRECTION-labels standing rule, pending human sign-off, rather than folded in quietly as a clean exception.

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. Testing Strategy, Risks & Mitigations, and Success Criteria are reconciled — correlation shapes 22 → 25, escape-fallback cases 53 → 56. No shared primitive (patternBindsName, introducesShadowedBinding, SCOPE_NODE_TYPES) is widened by any of this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant