[Plan] issue-2088: receiver-correlated invoked-property evidence for object-literal value-refs - #2612
[Plan] issue-2088: receiver-correlated invoked-property evidence for object-literal value-refs#2612carlos-alm wants to merge 37 commits into
Conversation
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
Greptile SummaryThe PR adds a docs-only delivery plan for allocation-site-correlated invoked-property evidence while preserving conservative fallback behavior.
Confidence Score: 5/5This docs-only planning PR appears safe to merge. No blocking failure remains. Important Files Changed
Reviews (48): Last reviewed commit: "docs(plan): correct stale declaringScope..." | Re-trigger Greptile |
| if (owner.bindingName === null) { // `return { … }` — no binding to scan | ||
| entry.escapes = exportedNames.has(owner.enclosingFn); | ||
| continue; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| const TRACKED_REFERENCE_PARENTS: ReadonlySet<string> = new Set([ | ||
| 'member_expression', | ||
| 'subscript_expression', | ||
| 'for_in_statement', | ||
| ]); |
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@_scratch_reply1.txt
There was a problem hiding this comment.
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.
|
@greptileai please re-review. Addressed in 899188d:
|
|
@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 Summary of what changed in 899188d, also detailed in my replies on both inline threads:
|
|
|
||
| `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`. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@_scratch_reply2.txt
There was a problem hiding this comment.
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.
|
@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.
| const TRACKED_REFERENCE_PARENTS: ReadonlySet<string> = new Set([ | ||
| 'member_expression', | ||
| 'subscript_expression', | ||
| 'for_in_statement', | ||
| ]); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.)
|
@greptileai please re-review. New head since your last pass: Both of your earlier inline findings were answered against the heads that followed them, and each has a reply on its own thread:
What 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 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. |
|
@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. |
|
@greptileai please re-review. Commit 9156a88 fixes a new escape-analysis gap in WU-2 found in review after 515140a.
Fix, in order:
|
|
@greptileai please re-review. Round 7. New head:
On the vacuous- Also cleaned up on this PR: two inline replies from an earlier round landed on the wrong threads with a broken 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 ( |
| const isTrackedStaticKey = | ||
| indexType === 'string' || | ||
| (indexType === 'template_string' && !indexNode!.text.includes('$')); | ||
| if (!isTrackedStaticKey) return false; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…miscounts (round 22) Blocking finding: class_static_block is absent from FUNCTION_SCOPE_NODE_TYPES in both engines, so functionScopeDeclaresVar attributes a var hoisted inside a static block to the enclosing function, spuriously shadowing it and pruning a genuine reference (UNDER-escape). Fixed via a new WU-2-local functionScopeDeclaresVarExcludingStaticBlocks re-derivation, substituted in allReferencesTracked (extended from round 18's method_definition-only carve-out to all six FUNCTION_SCOPE_NODE_TYPES kinds) and in findDeclaringScopeNode's function-shape ancestor test - the shared, multi-consumer functionScopeDeclaresVar/FUNCTION_SCOPE_NODE_TYPES stay untouched. Mirrored in the Rust extractor. Verified against the real tree-sitter-javascript@0.25.0 grammar and a real Node runtime oracle across eight trigger constructions and two controls; ablation confirms the fix is load-bearing and does not over-widen. Nine new escape-fallback fixtures (bq)-(by) and correlation shape 27 added to WU-10. The residual gap in the shared primitive's other, already-shipped consumer is filed separately as #2644 (UNDER-escape, contrasting with round 21's #2643, which is OVER-escape/recall-only). Report-integrity corrections to round 21's own text: the ablation-flip count ("flips nine...") is corrected to what actually reproduces (6 fixtures / 8 of 10 constructions); the dangling (bk)-(bq) case-range citation is corrected to (bk)-(bp), the highest case round 21 actually added; findEnclosingFunctionBody and isVarKindDeclarator are now honestly labeled as this round's own exposition-only coinages rather than implied pre-existing names. Structural change: WU-10 gains a mechanically generated fixture matrix (container x decoy x escape-shape x owner-form, executed under Node against the design's own escapes verdict) as the primary mechanism for finding new gaps going forward, since the last two blocking findings (round 21's alias shape, this round's static-block shape) were both ordinary constructions found by executing generated combinations, not by reading the prose - the 94 hand-written cases are retained as named regression anchors, not superseded.
|
@greptileai please re-review. Round 22 (3e92903) on top of e8ad327:
|
… counts An independent review of the round-22 fixture matrix found its own test apparatus unsound, while confirming the class_static_block fix it guards is correct and load-bearing (ablating it reproduces the pre-fix failure). This commit fixes the matrix, not the escape analysis. Oracle: the matrix's runtime-vs-escapes comparison was a two-way `invoked <=> !escapes` check, which endorses an under-escape bug and flags its own fix as a regression (escapes=1 on a genuinely invoked handler is correct fallback behavior, not a mismatch). Replaced with the three-term predicate the contract actually needs: `invoked === true && escapes === 0 && !hasT1Evidence`. Only that combination is a genuine, mechanically-found gap; escapes=1 on a live handler is now stated explicitly as a pass. Axes: the matrix generated only the class_static_block decoy and none of four other axes the round history shows matter. Added: an alias/hop-depth axis (direct, const/let/var alias, two-hop chain, for-of loop variable) so round 21's own `const u = T` finding is generatable; an object-literal content axis (identifier/function/shorthand/__proto__/getter/setter/spread/ method/call-expression/nested value) so condition 4's shape-recognition chain is exercised at all, not just the decoy/container surroundings; split the owner-form and for-of-decoy axes by declarator/loop-head keyword (const/let/var/using) since round 21's A10 and round 17's var-kind for-of finding each depend on that specific keyword. Pinned down where the owner sits relative to the container (always module-scope; container places the decoy and the reference), and added the missing detection rule for unconstructible combinations: execution is the detector, a SyntaxError means skip-and-record, not fail. Verified all five of this plan's own historical blocking findings (const u = T; class_static_block; __proto__; var-kind for-of head; bare arrow parameter) are each reachable as a single generated combination once the new axes exist. Case (by): reshaped from an inline for-of array literal (`for (const r of [T])`) to the named array-element-owner form (bo)/(bd) already use. The inline form made T's own reference a child of an `array` node, which TRACKED_REFERENCE_PARENTS does not recognise, so the site escaped for that unrelated reason before the for-of-loop-variable recursion under test was ever reached -- self-ablation confirmed only 7 of the 8 claimed (bq)-(by) triggers actually flipped. The reshaped case flips correctly (escapes 1 -> 0 when the fix is ablated), restoring the round-22 essay's "eight of eight" claim to something that reproduces. Also: fixed a severed sentence in the round-21 essay (WU-2b); corrected the findEnclosingFunctionBody attribution in case (bg)'s commentary from round 22 to round 21, matching the name's real origin (4179/6951) and this same comment's own "corrected ROUND 21" header; reconciled the escape-fallback + correlation case counts to a mechanical 104 (77 + 27), fixed the self-contradictory "twenty-six ... 1-26 ... and one more, 27" naming- convention sentence and two other stale "twenty-six" counts that predated correlation shape 27's own addition; completed the "(bk)-(bp), ten constructions" enumeration, which was missing A10. No change to the escape analysis, WU-2/WU-2b's fixes, or any shared primitive. WU-7/WU-8 (the Rust mirror) need no update: nothing here changes engine behavior -- the oracle and axes are WU-10 test-harness methodology, and the JS fixture (by) already runs against both engines unchanged in shape.
|
@greptileai please re-review. This commit fixes the round-22 fixture matrix's own test methodology, not the escape analysis — an independent audit (~1,127 executed combinations) confirmed the escape analysis is sound and the class_static_block fix is correct and load-bearing at this head. What changed in
No change to WU-2/WU-2b's escape-analysis logic or any shared primitive. No Rust-mirror (WU-7/WU-8) update needed — everything here is WU-10 test-harness methodology, and fixture (by) already runs against both engines unchanged in shape. |
- Scope hasT1Evidence to the handler property under test, not the site: a site-scoped reading silently passes 3 of 5 of this design's own historical findings (rounds 12, 17, 19), verified by constructing and executing all five. - Add the missing control value to the escape-shape axis, a decoy-target sub-dimension to the for-of decoy, and a new literal-placement axis -- the three additions needed before the round-19/17/12 coverage claims actually reproduce; the previously claimed combinations do not, confirmed by execution. - Recompute the nominal combination count (831,600 -> 2,332,800) and state the matrix's execution strategy, wall-clock budget, and cadence (in-process, nightly job, not a PR gate). - Drop uncited, unverifiable figures (a 2,505-case delta, 36/231 SyntaxError counts, a 43%/72% false-alarm rate) with no run log to back them. All changes additive; the matrix is not shrunk, and the escape analysis itself is untouched.
|
@greptileai please re-review. This commit fixes two documentation-level defects in the round-22 fixture matrix's own test apparatus (WU-10), found by an independent critic who ran the full 831,600-combination matrix twice at the prior head (1,099,956 executed combinations, zero under-escape paths under both readings of the oracle) — the escape analysis itself is confirmed sound and untouched, and the matrix is not shrunk. What changed in
No change to WU-2/WU-2b's escape-analysis logic or any shared primitive. No Rust-mirror (WU-7/WU-8) update needed — this is WU-10 test-apparatus methodology only. |
| > | ||
| > **The non-vacuous-coverage requirement (ROUND 8, #2088 finding 1 — the structurally important half).** `allReferencesTracked` returns `true` only when BOTH: (1) the walk is PROVEN exhaustive over the declaring scope's subtree — it did not truncate at `MAX_WALK_DEPTH` anywhere within it; AND (2) every reference the (proven-exhaustive) walk found satisfies `isTrackedReferencePosition`, or is accepted on a recursive bran |
There was a problem hiding this comment.
Coverage recipes miss binding scope
When the round-17 recipe leaves the literal at module scope, its function-local var loop head rebinds a different identifier, while the round-12 recipe does not require its content identifier to match the parameter decoy. The generated cases therefore either flip through an unrelated name-based scan or do not flip at all, so the matrix can claim coverage without exercising the intended fixes.
There was a problem hiding this comment.
Executed both recipes to check this. Both claims hold under the plan's own stated criterion, but the underlying precision gaps you're pointing at are real, and I've tightened both bullets in 0e3d3c4 rather than just asserting they're fine.
Round-12 recipe (run => {...} shadow): the bare-parameter decoy does shadow the content axis's own identifier as designed -- but you're right that the decoy axis's own TARGET sub-dimension is only defined explicitly for the for-of decoy, leaving the parameter decoy's own target implicit. It's defensible as written (the decoy axis's note already says the bare parameter is "the exact shape case (ad)/round 12 exercises," and case (ad) itself names the content identifier as what it shadows), but a generator implementing the axes literally could, in principle, pick a non-shadowing parameter name and silently miss this recipe. Flagged that gap explicitly in the doc rather than leaving it implicit.
Round-17 recipe (var-kind for-of head): this one flips escapes correctly in all fifteen containers -- the static analysis doesn't care whether the containing function is ever called. But the runtime oracle's invoked only reads true for module scope and the five block-shaped containers (bare block, if, try, switch case, loop body), not the nine function-shape containers the bullet named, because a function-shape container has to actually be invoked for its body to run, and nothing in the axis definitions auto-invokes every container value. So the three-term contract (invoked && escapes===0 && !hasT1Evidence) is only observable for six of the fifteen, not reachable-and-correct-but-unobserved for the other nine as the bullet implied. escapes itself is unaffected by this -- it's a gap in what the matrix can currently demonstrate, not in the fix. Corrected the bullet to name the six containers where this is actually observable and filed the container-invocation gap as a follow-up for the generator itself.
Both corrections are in the Coverage check section of WU-10 (search "GREPTILE (P1)" in the diff).
Condition 2 (the export check) ran exactly once, against the top-level
owner's own bindingName, before allReferencesTracked ever ran -- never
re-applied to any alias name the rebinding/for-of recursion introduces.
An alias can itself be exported while the table it aliases is not, and
an exported alias reaches the table cross-module exactly as an exported
table would. Verified end-to-end under real Node, two real ES modules:
`export const api = T` (T never exported); `api.alpha()` in the
importing module genuinely invokes the handler `T.run()` alone left as
this design's only in-file reference to T. escapes read false; fnAlpha
would be reported dead though api.alpha() invokes it on every import.
- Threads `exportedNames` through `allReferencesTracked` as a new
parameter, unchanged across both recursions, and adds an unconditional
`if (exportedNames.has(bindingName)) return false;` at the top of the
function, re-run at every recursion level.
- Mirrored in WU-7's Rust (`all_references_tracked` gains the identical
`exported_names` parameter and check); WU-8 needs no change, since
`exported_names` never crosses the NAPI boundary this WU builds.
- Self-ablated against a standalone reference model (real
tree-sitter-javascript parse, real Node): five flipping constructions
(export const/let/var alias, a two-hop exported chain, an exported
alias of an array owner via the for-of recursion) and four controls
that correctly do not flip (export { u } / export default u escape by
a different, pre-existing mechanism; a direct export and a wholly
unexported alias chain are unaffected either way).
- Adds WU-10 escape-fallback cases (bz)/(ca) and correlation shape 28;
recounts hand-written cases (104 -> 107) and correlation shapes
(twenty-seven -> twenty-eight).
- Adds an export dimension to the fixture matrix's alias/hop-depth axis
(an exported const/let/var alias, and an exported two-hop chain) --
the matrix had no way to generate this bug's class before now.
Recomputes the nominal combination count (2,332,800 -> 3,888,000).
- Re-derives #2610's own standing exception (Out of Scope, Success
Criteria): its premise ("the table must itself be exported") was
false whenever only an alias is exported, but the conclusion (the
site escapes via condition 2 or condition 3, before condition 4 ever
runs) survives under the corrected premise, so the exception is
re-derived rather than withdrawn.
- Fixes the pre-round-22 combinatorial baseline (previously 5,445,
which silently included two round-22-only additions under a
"pre-round-22" label; corrected to 4,620) and the round-17/round-12
coverage-check recipes flagged by Greptile (the round-17 recipe's own
runtime oracle is only observable in six of fifteen containers; the
round-12 recipe's parameter-decoy target was implicit, not a
generatable axis choice).
- Relabels the prior commit's "ROUND 23" tags to "ROUND 24", matching
the PR's own review-cycle count, with a note on the resulting
non-contiguous 22/24 numbering.
- Adds the "EXECUTED" qualifier to the matrix's own per-combination
timing figures, which reconcile only under a per-executed (not
per-nominal) reading.
No change to any prior fix verified at this head, the shared
primitives, the oracle, or the matrix's own size beyond the one new
axis dimension.
|
@greptileai please re-review. Round 25 (0e3d3c4) on top of 8bc1b0a: closes an UNDER-escape gap in condition 2's own recursion, independent of the "round 24" review pass this PR's history already confirmed clean. Blocking finding closed: condition 2 (the export check) ran exactly once, against the top-level owner's own // a.mjs
function fnAlpha() { return 1; }
const T = { alpha: fnAlpha, run() { return 0; } };
export const api = T; // exported ALIAS, T itself never exported
T.run();
// b.mjs
import { api } from './a.mjs';
api.alpha(); // fnAlpha IS invokedVerified end-to-end under real Node: Fixed by threading Matrix gap closed alongside it: the fixture matrix had no export dimension on any axis, so four consecutive clean sweeps never had a way to generate this bug's class. Added an exported const/let/var alias and an exported two-hop chain to the alias/hop-depth axis; recomputed the nominal count (2,332,800 -> 3,888,000). #2610 re-derived, not withdrawn: its standing exception argued "any table with a cross-file computed-access consumer must itself be exported" -- false whenever only an alias is exported. The conclusion (the site escapes via condition 2 or condition 3, before condition 4 ever runs) survives under the corrected premise, so the exception stands, re-derived on the repaired behavior. Also in this push, all non-blocking: replied to and tightened the round-17/round-12 coverage-check recipes your own P1 flagged (both hold, with real precision gaps now stated explicitly rather than glossed over); corrected the pre-round-22 combinatorial baseline (5,445, which silently counted two round-22-only additions under a "pre-round-22" label -- corrected to 4,620); added the missing "executed" qualifier to the matrix's per-combination timing figures; relabeled the prior commit's "ROUND 23" tags to "ROUND 24" to match this PR's actual review-cycle count, with a note on the resulting non-contiguous 22/24 numbering. No change to any prior fix, the shared primitives, the oracle, or the matrix's own size beyond the one new axis dimension. |
| > inside `sink({ T })` — a genuine escape (`T` is forwarded into an imported | ||
| > function, exactly the shape escape-fallback case (b) already requires to | ||
| > escape for a PLAIN identifier argument). An `identifier`-only filter finds | ||
| > no reference to classify at |
There was a problem hiding this comment.
Export matrix masks regression
When the exported-alias matrix case invokes the property through that alias in the same generated file, the call supplies T1 evidence and makes the stated invoked && escapes === 0 && !hasT1Evidence oracle false even with the pre-round-25 escape behavior. The nightly matrix therefore reports no gap for the cross-module under-escape this case is intended to guard.
There was a problem hiding this comment.
Confirmed correct, thank you — fixed in the doc.
The three-term oracle (invoked && escapes === 0 && !hasT1Evidence) can't flag this family: the escape-shape axis's own in-file alias.prop(...) call is itself a tracked alias reference the points-to solver already correlates to the table's own site (condition 3's alias-flow support, per Success Criteria's own "direct binding, array element + for-of, and alias" list), so hasT1Evidence reads true unconditionally, regardless of what escapes says. The matrix can generate the combination and show escapes flip on inspection, but its own generate-and-check loop can never use that flip to raise a finding — which is exactly why round 25's own gap (and five further let/var/two-hop variants this round found) were caught by a hand-built, two-file real-Node oracle, never by the matrix's nightly run.
Changed in docs/plans/issue-2088.md:
- The "Coverage check" section's header no longer claims the matrix's own oracle would flag all six historical findings — it now says plainly that 5 of 6 are matrix-self-detected and the 6th (this one) structurally cannot be with the matrix in its current single-file shape.
- The round-25 entry in that same list now carries the full explanation above, with a pointer to this comment.
- Filed WU-10 fixture matrix: extend generator/oracle to cross-file combinations #2646 to track extending the generator/oracle to cross-file combinations, which is what would actually close this gap — out of scope for this round's own fix (the
exportedNamesderivation bug this revision is otherwise about).
Round 25's own tightening of the round-17 coverage-check recipe (0e3d3c4) described a real gap in the matrix generator's own invocation convention (the runtime oracle is only observable for module scope and the five block-shaped containers, not the nine function-shape ones, since nothing guarantees a function-shape container's body ever runs) but left it as a generic "filed as a follow-up" with no issue number. Filed as #2645 and cited here, matching this plan's own established convention of citing a specific issue number for every filed follow-up rather than leaving one open-ended.
…2088) Round 25's self-ablation harness computed exportedNames more permissively than the real extractor (collectExportedDeclarations restricts a non-function identifier/pattern export to const, #2070), which validated condition-2 claims the real extractor falsifies for export let/var object-valued bindings and aliases. Verified against the real parser, a corrected reference-model harness, and real two-file Node runtime oracles. - Pin exportedNames' derivation: a new WU-2-local, keyword-agnostic collectExportedBindingNames, never a widening of the shared, regression-tested collectExportedDeclarations. Mirrored in WU-7/WU-8. - Correct the two false "declarator keyword is irrelevant" claims in place (round-25 essay, fixture (bz) commentary, Risks table/Out of Scope/Success Criteria cross-references), add real lettered fixtures (cb)-(ch) for the let/var and two-hop shapes instead of bundled prose. - Re-derive #2610's standing exception on the corrected behavior; it now holds for every export keyword, not only const. - Add a fourth standing rule: a verification harness must derive its inputs the way the real pipeline derives them, not by an independent re-implementation. - Fix inverted "before condition 4 ever runs" wording (condition 4 runs first, unconditionally) in the #2610 bullets. - Normalize correlation shape 28's label to match the mechanically recountable "// N." format used by shapes 1-27. - Correct the Coverage check section's overclaim that the fixture matrix's own in-file oracle would self-detect the round-25 finding (Greptile P1 3852228371): it cannot, since an in-file alias.prop() call always supplies T1 evidence regardless of escapes: this family needs the cross-file oracle the round-25/26 essays already use by hand. Files #2646 for extending the matrix to cross-file generation. - Name the eval()/new Function() opacity residual in Out of Scope, cross-referencing the already-filed #2642. Reconciled fixture/correlation-shape counts mechanically throughout (79 -> 86 escape-fallback shapes, 107 -> 114 total).
|
@greptileai please re-review. This round (26) fixes a bug in round 25's own verification: its self-ablation harness computed Summary of changes:
|
|
@greptileai please re-review. This round (27) fixes three bugs an independent critic found in round 26's own plan text — two of them in code this plan proposes to ship, not in test apparatus — verified against the real, freshly-built pipeline ( Summary of changes:
|
|
@greptileai please re-review. This round (28) closes a gap in round 27's own B1 fix: deriving Summary of changes:
Counts reconciled: 86 escape-fallback + 35 correlation shapes = 121. |
|
|
||
| `resolveSiteOwner` reuses the existing walk SHAPE of `findEnclosingTableName` (variable-declarator lookup through `TABLE_NAME_PASSTHROUGH_TYPES`) — the traversal only, never `findEnclosingTableName`'s return-value construction — extended with two extra cases — `array` parent → `` `${arrayVarName}[*]` `` (the pts key `buildArrayElemConstraints` already produces), and `return_statement` parent → `` `${enclosingFnName}::return` ``. | ||
|
|
||
| > **`resolveSiteOwner`'s return contract, stated explicitly** (round-7 critic finding, #2088 finding 5 — the previous draft left this implicit, which is itself the bug; ROUND 8, #2088 finding 2, extends it one guarantee further): `resolveSiteOwner(objectNode): { key: string; bindingName: string | null } | null`. | ||
| > - `key` is the pts-constraint LHS `buildObjectLiteralSiteConstraints` (WU-4) flows this site's pts fact into, and is also what `entry.owner` is set to, verbatim, regardless of owner kind: the bare variable name for a direct binding (`const T = {…}` → `"T"`), the array-element wildcard key for an array element (`const A = [{…}]` → `"A[*]"`), or the scoped return key for a returned literal (`return {…}` inside `f` → `"f::return"`). This is the ONLY field the points-to solver (WU-4) or T1's evidence matching (WU-5b) ever reads — both work purely in terms of site tokens and pts facts, never by textually matching a binding name. | ||
| > - `bindingName` is **always the bare declarator identifier** that `allReferencesTracked` walks the binding's declaring scope for, and **never** carries a `[*]` or `::return` suffix (round-7, finding 5) — **nor, round 8 (finding 2), a `#${scopeLine}` disambiguating suffix either**: it is always `nameNode.text` read directly off the `variable_declarator`'s `name` field, never the string `findEnclosingTableName` itself would return for that same declarator. `findEnclosingTableName` (`src/extractors/javascript.ts:4513-4528`) appends exactly that suffix — `` `${nameNode.text}#${scopeLine}` `` — for any declaration scoped inside a block, via `findDeclaringScopeLine`; `resolveSiteOwner` must stop at `nameNode.text` and never call through to that suffix-appending return construction, precisely because `bindingName` is consumed as an AST SEARCH TARGET (`allReferencesTracked` looks for `identifier` AND `shorthand_property_identifier` nodes — ROUND 19, #2088 finding 3, corrects this parenthetical's own pre-round-19 wording, which named only `identifier`; see `allReferencesTracked`'s own doc comment for why the omission is load-bearing, not editorial — whose `.text` equals it), not as a human-readable disambiguating label the way `findEnclosingTableName`'s result is. Concretely: `"T"` for a direct binding regardless of what scope it's declared in, `"A"` (never `"A[*]"`) for an array element, or `null` for a return-owner (there is no binding at all to scan — condition 1 already makes every return-owner escape unconditionally, before condition 3 ever runs `allReferencesTracked`). This is unrelated to, and must never be confused with, `Call.receiver` (set by the pre-existing, UNCHANGED `collectObjectLiteralValueRefCall` as `findEnclosingTableName(pairNode)` — see WU-2's implementation above): `receiver` is #2260's own T3 (`computedDispatchTableEvidence`) matching key and is EXPECTED to keep its `#line` suffix, since T3 disambiguates by exactly that string; `bindingName` is WU-2b's own escape-analysis input and must NOT carry it. Two different fields, two different consumers, two different rules — round 8 exists because an earlier draft let them blur. | ||
| > - The two fields are EQUAL (`key === bindingName`) for exactly one owner kind — direct binding — and differ for exactly one other — array element (`"A[*]" !== "A"`). This is what `isArrayOwner = owner.key !== owner.bindingName` (condition 3, above) relies on, and it is why `bindingName` cannot be left to be "whatever seems natural": were it ever `"A[*]"` instead of `"A"`, or ever `"T#7"` instead of `"T"`, `allReferencesTracked` would search the AST for an identifier literally spelled `A[*]` or `T#7` — text that can never appear as identifier syntax — find zero surviving references for every affected site, and read that vacuous walk as non-escaping. Round 8 (see the withdrawal of round 7's vacuous-truth conclusion, above) is precisely why this can no longer be waved away as "vacuous truth is always fine": a search that structurally can never match anything is not an exhaustive, PROVEN-COVERED search — it is a broken one, and must be treated as unproven, not as a trivial pass. This would silently bypass condition 2's export check for every affected site, exported or n |
There was a problem hiding this comment.
Fallback scopes cross-credit sites
When a file has multiple for-of bindings with the same receiver name, candidateScopesFor unconditionally probes the caller's unqualified name and module scope, then unions every matching site. The call consequently supplies T1 evidence to unrelated object-literal sites, preserving their same-named handlers as live and losing the intended dead-code recall improvement.
There was a problem hiding this comment.
Confirmed — you're right on the mechanism, and this was independently found in the same round: candidateScopesFor unions the scoped, last-dot, and <module> probes unconditionally, never short-circuiting once an earlier probe already resolved a call correctly, so a same-named receiver elsewhere in the file (another for-of, or a same-bare-named module-level function) does get cross-credited. Fixed in the doc at 8830670, not in the probe set — this is a deliberate, bounded, measured trade, not an oversight, and the analysis is now written up in full in WU-5(a)'s collision note plus WU-10 correlation shape 36.
Measured cost, from executing the widened set against codegraph's own src/: 113 files contain a for-of, 577 for-of bindings, 68 non-value-ref member calls on a for-of-bound receiver. Round 27's single (scoped-only) probe: 28 hits / 2 collisions. The round-28 widened set: 31 hits / 3 collisions — +3 resolving call sites against +1 new colliding one, 1.5% of the 68 candidates. Per probe: the last-dot probe alone contributed 1 genuine rescue and 0 collisions in this repo; the <module> probe alone contributed 0 rescues here and 3 leaks.
The proved bound: T1's over-credit is a subset of T2's own admit set. collectInvokedPropertySites and the real collectInvokedPropertyNames share the same collection predicate (call.receiver && call.dynamicKind !== 'value-ref') and the same key source (call.name) — the only difference is T2's set is accumulated globally across every file before any file's points-to map exists, while T1's correlation is per-file. Any property name a collision could ever attribute to an unrelated site is, by that identical predicate, already a member of T2's global invokedNames. So the worst case is byte-identical to pre-#2088 behaviour for the affected pairs: it can never mark live what #1895 already marked dead, and it never marks anything dead — it can only make T1 credit, for the wrong structural reason, a property T2 would already have credited anyway.
Why the probes stay rather than narrowing back down: removing the <module> probe reopens shapes 34 and 35 (class-field arrow, object-literal arrow prop — enclosingFunc never gets a context for either node kind and falls through to <module> unconditionally); removing the last-dot probe reopens shape 33 (TS class method — a TS class name parses as type_identifier, so enclosingFunc stays unqualified while findCaller resolves the qualified name). Each was independently verified dead under that probe's own single-probe ablation before this round. The actual fix — reconciling funcStack with findCaller at the extractor so the two stop diverging — is out of this plan's scope and is filed at #2647.
Shape 36 (WU-10, above the escape-fallback shapes) is the fixture covering this specific cost: two same-named TS class methods, each with its own for-of array bound to the same receiver name, colliding on one scoped pts key via the last-dot probe — asserting the over-credited-but-still-live outcome directly, with a self-ablation showing the collision (and, inseparably, the correct resolution) both disappear when that one probe is removed. The write-side collision and the extractor/solver output it depends on were re-executed against the real, unmodified pipeline rather than only argued.
|
@greptileai please re-review. Round 29 (8830670) on top of 59bd575 — documentation-only, per an independent critic's PASS at the prior head: the escape analysis, the probe set, the axes, the oracle, and every fixture's behaviour are unchanged. Four items, all in
|
…s citation (round 27, #2088) B1 — collectInvokedPropertySites fed raw symbols.calls into resolveReceiverSites, so call.callerName was always undefined and the scoped pts key buildForOfConstraints actually writes (`${enclosingFunc}::${varName}`, e.g. `pick::r`) was never reachable, only the bare key, which for-of aliases never populate. Verified end-to-end against the real pipeline (dist/domain/parser.js + dist/domain/graph/resolver/points-to.js) on the plan's own #1771 idiom: the site token lands at pts.get('pick::r'), never pts.get('r'). Fixed by deriving callerName via findCaller, the same way Pass 3 already does, with null mapped to the '<module>' sentinel emitPtsNoReceiverEdges already uses. Mirrored in WU-8 (Rust find_enclosing_caller + the caller_name.is_empty() -> "<module>" conversion). Correlation shape 2 gets a self-ablation note; shapes 29-32 add coverage for B2. B2 — buildPointsToMapForFile's null guard checks nine legacy binding arrays but never objectLiteralSites, so a file whose only pts-relevant content is a parenthesised/`as const`/`satisfies`/non-null-asserted object literal returns null and WU-4's new constraint-seeding never runs, even though the site provably does not escape. Verified against the real extractor across all four wrapper spellings (table in WU-4). Fixed by adding objectLiteralSites to the guard on both engines; new correlation shapes 29-32 fixture each spelling with self-ablation notes. B3 — the definitionNames worked example claimed `new Set(definitions.map((d) => d.name))` "exactly as points-to.ts already builds it." The real points-to.ts (541-545) filters to kind === 'function' || 'method' first; the unfiltered form is build-edges.ts:559, an unrelated native-FFI payload. Adopts the filtered form (matches the real Rust mirror too, build_edges.rs:1368-1373) and pins findTopLevelFunctionNodeByName's existing null-for-non-function-shaped-value behavior, verified by reading its actual implementation rather than assuming. Also: corrects a stale escape-fallback-shape count (79 -> 86, missed in round 26), a "sixteen total readings" arithmetic error (seven cases x two dimensions is fourteen), and softens an overstated typeMap-reuse claim. Counts reconciled: 86 escape-fallback + 32 correlation shapes = 118.
…nd 28, #2088) Round 27's callerName derivation (via findCaller) was necessary but not sufficient: it silently assumed findCaller's callerName always equals buildForOfConstraints's own enclosingFunc scope prefix. Executed against the real pipeline, that equality is false for a TS class method (instance, static, getter, async), a class-field arrow, and an object-literal arrow-valued property - all three wrongly report the plan's own #1771 idiom's handlers as dead, in a codebase that is itself TypeScript. Two pre-existing, symmetric (both engines) extractor root causes, filed as #2647 and left unfixed here (shared primitive, out of scope for this plan): TS class names parse as type_identifier and are invisible to funcStack's identifier-only qualification check, and the context-collector has no dispatch case for a class-field arrow or an object-literal arrow-valued property at all. Fixes resolveReceiverSites to probe every candidate scope a for-of receiver could actually have been written under (callerName, its own last-dot segment, and the '<module>' sentinel) instead of the single scoped key round 27 tried - a documented tolerance for the divergence, not a claim it doesn't exist. Confirmed against the real, unmodified pipeline that the widened set resolves all three previously-missing shapes with no regression on the two that already worked, and that WU-4's "seeding also writes a normalised key" alternative cannot work regardless, since WU-4 never touches buildForOfConstraints in the first place. Adds WU-10 correlation shapes 33-35 (one per previously-uncovered shape) with executed self-ablation, mirrors the same widened probe set into WU-7/WU-8 for the Rust engine (enclosing_func_context carries the identical divergence, verified independently), and reconciles shape/case counts throughout. Also fixes four unrelated issues found in the same pass: ptsMapsByFile's Pass 1/3 typing (buildPointsToMapForFile genuinely returns PointsToMap | null; the Map was typed as if it couldn't), the misspelled buildNativeFilesInput -> buildNativeFileInputs, the round-10 Builder note's shape count left at "twenty-eight" after round 27 added four shapes without updating this line, and callAssignments's "exactly two fields" overclaim (it carries two more, unused here).
…er reach (round 29, #2088) The plan passed review at 59bd575 on structural grounds; this is a documentation-only follow-up addressing four review findings, including a Greptile P1 that independently converged on the same mechanism as the first item below. - Collision write-up (WU-5(a)) named one collision family and claimed the widening introduces "not a new risk" — false as written, true only of that one family. Rewrites the bound to name all three families (write-side collision between two TS classes; a correctly-resolving call picking up a same-bare-named module-level function via the last-dot probe; the unconditional <module> probe merging any module-scope for-of into every same-named-receiver call), states the T2-bounded consequence precisely, and quantifies it against codegraph's own src/ (113 files with a for-of, 577 bindings, 68 candidate calls; round 27 baseline 28 hits/2 collisions, round 28 widened 31 hits/3 collisions). - Adds correlation shape 36 (WU-10): two same-named TS class methods whose arrays collide on one scoped pts key, fixturing the over-credited-but- still-live outcome the T2 bound argues, with a self-ablation isolating the collision to the one responsible probe. Write-side collision and extractor/solver output re-executed against the real, unmodified pipeline. Reconciles shape/case counts throughout (35->36 correlation shapes, 121->122 total fixtures) everywhere they are enumerated. - Notes the widened probe set is not for-of-specific: a `this` receiver inside a method also picks up the afterLastDot/<module> probes via thisCallBindings' `${callee}::this` keys, same T2 bound, inert in every fixture tested. - Cites two real call sites (parser.ts:1181, wasm-worker-entry.ts:646) illustrating the <module>-probe mechanism concretely, corrected against current main during verification (the second site's receiver is a same-named parameter, not itself a `_extToLang.get(ext)` local; the credited array is independently exported/escaping, so T2 already governs its outcome regardless of the collision). No behavioural change to any fixture, predicate, probe set, or the escape analysis. Plan-doc only.
8830670 to
2183bea
Compare
| // Top-level call — `declaringScope` is omitted, so `allReferencesTracked` | ||
| // computes it once via `findDeclaringScopeNode(objectNode, owner.bindingName) | ||
| // ?? root` (round 8, #2088 finding 1) and threads that fixed node through | ||
| // its own recursive calls unchanged. See `allReferencesTracked`'s own doc | ||
| // comment below for why the boundary must be computed exactly once, here, | ||
| // rather than re-derived per recursive call. `exportedNames` is threaded | ||
| // through as of ROUND 25 (#2088, blocking) so every recursive call can | ||
| // re-apply condition 2 to its own subject — see that round's own essay, |
There was a problem hiding this comment.
Stale recursive scope instructions
When an implementer follows these instructions for an alias or loop binding whose name collides in the original scope, allReferencesTracked reuses the fixed outer boundary instead of deriving the recursion subject's scope. The walk can then miss an escaping use such as sink(r), mark the site local-closed, and cause exclusive T1 evidence to report a live property dead.
There was a problem hiding this comment.
Confirmed and fixed in 8aced6d. The Top-level call comment above computeObjectLiteralSiteEscapes's call to allReferencesTracked (lines 1410-1420) still described the pre-round-21 contract verbatim — "threads that fixed node through its own recursive calls unchanged" / "the boundary must be computed exactly once, here, rather than re-derived per recursive call" — even though round 21's own essay (this file, further down) explicitly supersedes that: each recursion (rebinding alias, for-of loop variable) recomputes its OWN declaringScope, seeded from the recursion subject's own lexical position, falling back to the enclosing call's scope (never straight to root) only when nothing shadows the subject between its own position and that boundary. The essay text itself already carried inline "corrected by ROUND 21" annotations at every point it was originally stated (lines 3806, 3825, and the Rust mirror at 5514), but this specific comment — the one sitting directly above the actual call site, the most likely place an implementer reads literally rather than archaeologizing through the full round history — never got the same correction and flatly contradicted it.
Rewrote the comment to state the round-21-corrected contract directly (per-level recomputation, with the enclosing-call fallback and the for-of kind === 'var'" split's own #2643 pointer), rather than the superseded round 8-20 one, while still pointing to allReferencesTracked`'s own doc comment and the round-21 essay for the full mechanism and counter-example. No behavior or code changes — this PR is docs-only — just correcting the one instructional comment that was out of sync with the design it's supposed to be summarizing.
The 'Top-level call' comment above computeObjectLiteralSiteEscapes's call to allReferencesTracked still described the pre-round-21 behavior -- the outer declaringScope threaded unchanged through every recursive call. Round 21 replaced that with per-level recomputation (each recursion seeds its own declaringScope from the recursion subject's own position, falling back to the enclosing call's scope, never straight to root), but this comment was never updated to match and would mislead an implementer into reintroducing the round-21 self-shadow bug. Flagged by Greptile.
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.mdWhat the issue asks for
collectInvokedPropertyNames(src/domain/graph/builder/call-resolver.ts:91) reduces to: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 deadcommand, 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:
site|keyT1 being exclusive rather than ORed with T2 is what produces the recall gain; the
escapesguard 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.fieldas the same abstract location regardless of whichobjinstance".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:
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|keyevidence 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
src/domain/graph/resolver/points-to.tsand the Rustbuild_points_to_map. No new subsystem; the 50-iteration solver loop (buildCallSiteTypeMap/MAX_SOLVER_ITERATIONS) is untouched.wasm-worker-{protocol,entry,pool}.tsseam the primary parity-divergence risk, so it is its own work unit (WU-3) with its own verification. NoteCall.objectLiteralSiteneeds no protocol edit —SerializedExtractorOutput.callsis typedCall[]and passed whole (wasm-worker-protocol.ts:51); only top-levelExtractorOutputextras need explicit threading. Verified by reading the file, not assumed.pts-javascript; thejavascriptfixture's precision-1.0 floor must not move.domain/graph/resolver/directory — the Rust solver lives insidebuild_edges.rs, its pre-existing mirror location.Building on prior art, not duplicating it
collectObjectLiteralValueRefCallalready sets a value-refCall'sreceiverto the dispatch table's name, feedingcomputedDispatchTableEvidence(#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_sitesandinvoked_property_sites, both persisted and purged per-file exactly asinvoked_property_names(#2087) is — deliberately not the in-memory-only shortcut #2260 took.Config: exactly one new
DEFAULTSkey,analysis.correlatedPropertyEvidence. Setting itfalserestores pre-#2088 behavior exactly. No new language, noLANGUAGE_REGISTRY/AST_TYPE_MAPS/LangAstConfigchange, 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 againstTRACKED_REFERENCE_PARENTSand 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 exportedmakeTable()and therefore escapes and resolves on T2, i.e. today's exact path.Out of scope — filed, not carried in prose
computedDispatchTableEvidenceis in-memory only, so a scoped incremental build can report a live dispatch-table property dead. Non-conservative direction, and a full-vs-incremental divergence. Its sibling channel got a durable table in follow-up: persist cross-file invoked-property-name evidence for incremental dead-code classification #2087 for exactly this reason.findEnclosingTableNamedoes not traverse array literals, soconst RESOLVERS = [{ matches, resolve }]yields noreceiverand the Computed-property (bracket-access) dispatch-table lookups lack a real calls edge, unlike dot-property value-refs #2260 pathway can never credit a handler array — the exact idiom named incollectObjectLiteralValueRefCall's own doc comment as Dispatch-table function references (resolve: fn) inconsistently flagged dead-unresolved depending on unrelated fanOut #1771's motivating case. Not closed by this plan, which leaves T3 name-keyed.-Tunder-filterstests/. Relevant only because the plan's dogfood measurement must filtertests/by hand rather than trust the raw dead-symbol count.Plan provenance
Round 1. No
plan-carry-forwardartifact exists on #2088 —gh api .../issues/2088/comments --paginatereturns zero comments carrying the sentinel, from any author, trusted or not. Everything in the plan is derived fresh from live source at6221df16.Verification status of this PR
Docs-only.
npm run lintwas run and passes (Biome is scoped tosrc//tests/, neither touched). It reports 1 pre-existing warning insrc/graph/algorithms/louvain.ts:135(ineffectivebiome-ignoresuppression) — 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)
docs/plans/issue-2088.md8830670d59bd5752— no blocking findings8830670d, documentation-only (verified no-logic-change by full diff), closing the four non-blocking items the passing critic itself namedoversee/plan-gate=successon the current headWhat was verified, and how
The bar throughout: can this design ever report live code as dead? Today's
collectInvokedPropertyNamescredits any truthy receiver and structurally cannot, so theplan's whole safety argument is that it never converts that conservative false negative into
a false positive.
The final review drove the real pipeline —
parseFileAuto, the real points-to solver,the real
findCaller, cross-checked against a realcodegraph build— not a model of it.probe set feeds only
collectInvokedPropertySites, whose sole effect iskeys.add(...). Itnever touches escape analysis,
nonEscapingSites, or thelocalClosedpredicate.verified to flip under ablation of the specific fix it covers.
The trade-off, quantified
The final fix is a tolerance, not a tightening. The extractor and the resolver name scopes
differently, so the site lookup probes four keys instead of one. That cross-credits some
sites — a recall cost, independently raised by Greptile and confirmed rather than disputed.
Measured on codegraph's own
src/— 113 files with a for-of, 577 for-of bindings, 68 relevantcall sites:
+3 resolved, +1 collision (1.5%). The severity is bounded by proof, not assertion: T1's
over-credit is a subset of T2's admit set, so the worst case is byte-identical to pre-#2088
behaviour for the affected pairs. It can never mark live what #1895 marked dead, and never
marks anything dead. The proper fix — reconciling the two naming schemes in the extractor —
is filed as #2647, out of this plan's scope.
How the review got here
Four standing rules now govern the plan, each added after a failure the previous level could
not catch:
exportedNamesand validated five false claimsThe last one matters most: a verification model more permissive than reality confirms exactly
the claims reality falsifies — that gap survived twenty-five rounds and ~1M executed
combinations, because volume of execution cannot correct a miscalibrated input.
Residual limitations — all recall-direction, all tracked
Every excluded shape carries a direction label and a real open issue: #2610, #2611, #2617–#2625,
#2627, #2629–#2636, #2638–#2643, #2645–#2647. Under the plan's own standing rule an
UNDER-escapegap may never be filed as an accepted limitation; every one of these isOVER-escape, except #2610, which is pre-existing and verified not worsened.Reviewing this plan also surfaced #2628, #2639, #2643 and #2647 — suspected bugs
in shipped product code, unrelated to #2088.
The decision that is actually yours
Soundness has stopped being the interesting variable. T1 correlation now fires for a
deliberately narrow core, with roughly thirty tracked exclusions describing what it will not
catch, and the final tolerance trades a further 1.5% of precision for coverage.
Against that: 10 work units, a dual-engine mirror, and a v32 DB migration — for a recall
improvement to an advisory command (
roles --role dead).That trade is a judgement no critic can make for you, and it is the last thing standing
between this plan and a decision.
Review the plan above. To approve it for execution, tick this box, then run
/oversee #2612: