From b3c20ff34a82018fe65686e6a8bf081bc137ab5c Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 17 Aug 2026 00:29:26 -0600 Subject: [PATCH 1/5] fix(extractors): stop crediting a fallback read a prior write already killed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #2257 value-ref liveness scan looked for ANY later reference to the declared name anywhere in the enclosing block, but never modeled a write as a kill — so `var fn = a || fallback; fn = other; fn();` still credited `fallback` as reachable via `fn()`, even though `fn` no longer held it by that point. `killsBinding`/`kills_binding` now stops the per-statement scan once a sibling statement unconditionally overwrites the name (a top-level assignment or `var` redeclaration), while still crediting a genuine read on that same statement's own right-hand side first. A write nested inside a conditional never kills, since the branch might not run. Closes #2438 docs check acknowledged Impact: 2 functions changed, 0 affected --- .../src/extractors/javascript.rs | 128 ++++++++++++++++++ src/extractors/javascript.ts | 48 +++++++ ...-2257-logical-or-ternary-value-ref.test.ts | 69 ++++++++++ 3 files changed, 245 insertions(+) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 76ddca618..e524fb446 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -5439,6 +5439,59 @@ fn block_contains_identifier_excluding( false } +/// True when `statement` — a DIRECT child of the enclosing block, exactly the +/// granularity `has_later_reference_in_enclosing_block` iterates — +/// unconditionally overwrites `name`: a top-level `name = value;` assignment +/// (any operator; `pattern_binds_name` also covers destructuring targets +/// like `[name] = arr`) or a `var name = value;` redeclaration sitting +/// directly in the block. A write nested inside an `if`/loop/`switch`/`try` +/// never matches here — it surfaces as a single +/// `if_statement`/`for_statement`/etc. child, not as the assignment itself — +/// so a conditional write correctly never kills (issue #2438's own +/// requirement: the original value can still reach a later read when the +/// write didn't actually run). +/// +/// `exclude_id` skips the declarator this liveness check is FOR, so the +/// declaration statement that introduces `name` (which trivially "binds" +/// name via its own declarator) is never mistaken for a kill of its own +/// freshly-assigned value. +/// +/// Mirrors `killsBinding` in `src/extractors/javascript.ts`. +fn kills_binding(statement: &Node, name: &str, source: &[u8], exclude_id: usize) -> bool { + let node: Node = if statement.kind() == "expression_statement" { + match statement.child(0) { + Some(child) => child, + None => return false, + } + } else { + *statement + }; + if node.kind() == "assignment_expression" { + return match node.child_by_field_name("left") { + Some(left) => pattern_binds_name(&left, name, source, 0), + None => false, + }; + } + if node.kind() == "variable_declaration" || node.kind() == "lexical_declaration" { + for i in 0..node.child_count() { + let Some(declarator) = node.child(i) else { + continue; + }; + if declarator.kind() != "variable_declarator" || declarator.id() == exclude_id { + continue; + } + let decl_name = declarator.child_by_field_name("name"); + let value = declarator.child_by_field_name("value"); + if let (Some(decl_name), Some(_)) = (decl_name, value) { + if pattern_binds_name(&decl_name, name, source, 0) { + return true; + } + } + } + } + false +} + /// True when `name` appears as a bare identifier reference anywhere else in /// `declarator_node`'s enclosing block (function body, module top level, or /// arrow-function body) — the local, position-scoped liveness evidence @@ -5466,6 +5519,15 @@ fn block_contains_identifier_excluding( /// reference (`console.log(handler)`), matching #1895's own "invoked... /// via member-call syntax" precision. /// +/// Stops crediting reads once a sibling statement unconditionally overwrites +/// `name` (`kills_binding`, issue #2438): `var fn = a || b; fn = other; +/// fn();` must NOT count `fn();` as evidence that `b` is reachable — by the +/// time it runs, `fn` already holds `other`, not the fallback. The killing +/// statement's OWN right-hand side is still scanned for a genuine read +/// before the kill takes effect (`fn = fn || other;` still credits the read +/// of the pre-existing value), since the read-check on each statement always +/// runs before its kill-check. +/// /// Mirrors `hasLaterReferenceInEnclosingBlock` in `src/extractors/javascript.ts`. fn has_later_reference_in_enclosing_block( declarator_node: &Node, @@ -5522,6 +5584,9 @@ fn has_later_reference_in_enclosing_block( ) { return true; } + if kills_binding(&child, name, source, declarator_node.id()) { + return false; + } } } false @@ -8930,6 +8995,69 @@ mod tests { })); } + // Issue #2438 (deferred from PR #2432's review): a write correctly does + // not count as a read of ITS OWN statement, but a plain reassignment + // must also KILL the value for every LATER statement — a read after the + // fallback has already been overwritten sees the new value, never the + // fallback. + #[test] + fn does_not_credit_liveness_from_a_read_after_an_unconditional_reassignment_killed_it() { + let s = parse_js( + "let fn = options.custom || fetchLatestVersion;\n\ + fn = replacement;\n\ + fn();", + ); + assert!(!s.calls.iter().any(|c| { + c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "fetchLatestVersion" + })); + } + + // Same kill semantics via a `var` redeclaration in a separate later + // statement, rather than a plain assignment expression. + #[test] + fn does_not_credit_liveness_from_a_read_after_a_var_redeclaration_in_a_later_statement() { + let s = parse_js( + "var fn = options.custom || fetchLatestVersion;\n\ + var fn = replacement;\n\ + fn();", + ); + assert!(!s.calls.iter().any(|c| { + c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "fetchLatestVersion" + })); + } + + // Issue #2438: a write nested inside a conditional is NOT a guaranteed + // kill — the branch might not run, so the fallback can still reach the + // later read. + #[test] + fn still_credits_liveness_from_a_read_after_a_write_nested_inside_a_conditional() { + let s = parse_js( + "let fn = options.custom || fetchLatestVersion;\n\ + if (cond) {\n\ + fn = replacement;\n\ + }\n\ + fn();", + ); + assert!(s.calls.iter().any(|c| { + c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "fetchLatestVersion" + })); + } + + // Issue #2438: the killing statement's OWN right-hand side is scanned + // for a genuine read BEFORE the kill takes effect — `fn` on the right of + // its own reassignment still reads the pre-existing (possibly fallback) + // value. + #[test] + fn still_credits_a_genuine_read_on_the_right_hand_side_of_the_killing_statement_itself() { + let s = parse_js( + "let fn = options.custom || fetchLatestVersion;\n\ + fn = fn || somethingElse;", + ); + assert!(s.calls.iter().any(|c| { + c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "fetchLatestVersion" + })); + } + // A compound assignment (`+=`, `||=`, etc. — a distinct // `augmented_assignment_expression` node in this grammar) DOES read the // current value before writing, so its left-hand identifier is a real diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 57da76c3f..2d5b48672 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -5162,6 +5162,42 @@ function blockContainsIdentifierExcluding( return false; } +/** + * True when `statement` — a DIRECT child of the enclosing block, exactly the + * granularity `hasLaterReferenceInEnclosingBlock` iterates — unconditionally + * overwrites `name`: a top-level `name = value;` assignment (any operator; + * `patternBindsName` also covers destructuring targets like `[name] = arr`) + * or a `var name = value;` redeclaration sitting directly in the block. A + * write nested inside an `if`/loop/`switch`/`try` never matches here — it + * surfaces as a single `if_statement`/`for_statement`/etc. child, not as the + * assignment itself — so a conditional write correctly never kills (issue + * #2438's own requirement: the original value can still reach a later read + * when the write didn't actually run). + * + * `excludeId` skips the declarator this liveness check is FOR, so the + * declaration statement that introduces `name` (which trivially "binds" + * name via its own declarator) is never mistaken for a kill of its own + * freshly-assigned value. + */ +function killsBinding(statement: TreeSitterNode, name: string, excludeId: number): boolean { + const node = statement.type === 'expression_statement' ? statement.child(0) : statement; + if (!node) return false; + if (node.type === 'assignment_expression') { + const left = node.childForFieldName('left'); + return !!left && patternBindsName(left, name); + } + if (node.type === 'variable_declaration' || node.type === 'lexical_declaration') { + for (let i = 0; i < node.childCount; i++) { + const declarator = node.child(i); + if (declarator?.type !== 'variable_declarator' || declarator.id === excludeId) continue; + const declName = declarator.childForFieldName('name'); + const value = declarator.childForFieldName('value'); + if (declName && value && patternBindsName(declName, name)) return true; + } + } + return false; +} + /** * True when `name` appears as a bare identifier reference anywhere else in * `declaratorNode`'s enclosing block (function body, module top level, or @@ -5200,6 +5236,15 @@ function blockContainsIdentifierExcluding( * invocation evidence (`handler(...)`), not just any reference * (`console.log(handler)`), matching #1895's own "invoked... via * member-call syntax" precision. + * + * Stops crediting reads once a sibling statement unconditionally overwrites + * `name` (`killsBinding`, issue #2438): `var fn = a || b; fn = other; fn();` + * must NOT count `fn();` as evidence that `b` is reachable — by the time it + * runs, `fn` already holds `other`, not the fallback. The killing + * statement's OWN right-hand side is still scanned for a genuine read before + * the kill takes effect (`fn = fn || other;` still credits the read of the + * pre-existing value), since the read-check on each statement always runs + * before its kill-check. */ function hasLaterReferenceInEnclosingBlock( declaratorNode: TreeSitterNode, @@ -5238,6 +5283,9 @@ function hasLaterReferenceInEnclosingBlock( if (blockContainsIdentifierExcluding(child, name, declaratorNode.id, 0, requireCallSite)) { return true; } + if (killsBinding(child, name, declaratorNode.id)) { + return false; + } } return false; } diff --git a/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts b/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts index b6ed15ec0..86eb25254 100644 --- a/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts +++ b/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts @@ -19,6 +19,17 @@ * variable is later passed to `useCallback`) from `deadViaUnusedFallback` * (whose variable is declared and never touched again) — both reference * their target identically, so only the liveness check tells them apart. + * + * Also covers #2438 (deferred from PR #2432's review): the liveness scan + * ignored plain writes correctly, but didn't model a write as a KILL — a + * read occurring after the variable has already been unconditionally + * overwritten was still credited as evidence the fallback is consumed, even + * though that read can only ever see the new value. Fixed by having the + * per-statement scan stop once it passes a statement that unconditionally + * overwrites the name (`killsBinding`/`kills_binding`), while still crediting + * a genuine read on the killing statement's OWN right-hand side first. A + * write nested inside a conditional must NOT kill, since the original value + * can still reach a later read when the branch doesn't run. */ import fs from 'node:fs'; @@ -51,6 +62,47 @@ function hoistedInNestedFn(x) { return x + 6; } function loopOwnBinding(x) { return x + 7; } function loopBareTarget(x) { return x + 8; } +function killedThenRead(x) { return x + 9; } +function killedByVarRedeclare(x) { return x + 10; } +function survivesConditionalWrite(x) { return x + 11; } +function survivesSelfReadInKillStatement(x) { return x + 12; } +function somethingElse(x) { return x + 13; } + +// #2438: a plain top-level reassignment kills the fallback value before the +// later read runs — that read sees \`other\`, never \`killedThenRead\`. +export function killAssignBeforeRead(opts, other) { + let fn = opts.custom || killedThenRead; + fn = other; + return fn(); +} + +// #2438: a \`var\` redeclaration in a later sibling statement is the same +// kind of unconditional overwrite as a plain assignment. +export function killViaVarRedeclare(opts, other) { + var fn = opts.custom || killedByVarRedeclare; + var fn = other; + return fn(); +} + +// #2438: a write inside a conditional is NOT a guaranteed kill — the +// fallback can still reach the later read when \`cond\` is false. +export function conditionalWriteDoesNotKill(opts, other, cond) { + let fn = opts.custom || survivesConditionalWrite; + if (cond) { + fn = other; + } + return fn(); +} + +// #2438: the killing statement's OWN right-hand side is scanned for a +// genuine read before the kill takes effect — \`fn\` on the right of its own +// reassignment still reads the pre-existing (possibly fallback) value. +export function selfReadWithinKillStatement(opts) { + let fn = opts.custom || survivesSelfReadInKillStatement; + fn = fn || somethingElse; + return fn; +} + // \`var\` is FUNCTION-scoped, so the nested block's \`var varScoped\` is the SAME // binding as the outer one — the \`varScoped()\` read before it genuinely // consumes the fallback, and must not be pruned as a nested-scope shadow. @@ -195,6 +247,23 @@ function runShared(getDbPath: () => string) { it('does not credit liveness from a for-of body read of a bare loop target', () => { expect(countCallEdgesTo(getDbPath(), 'loopBareTarget')).toBe(0); }); + + // #2438: a read after an unconditional overwrite must not be credited. + it('does not credit liveness from a read that a prior unconditional assignment already killed', () => { + expect(countCallEdgesTo(getDbPath(), 'killedThenRead')).toBe(0); + }); + + it('does not credit liveness from a read after a var redeclaration in a later statement', () => { + expect(countCallEdgesTo(getDbPath(), 'killedByVarRedeclare')).toBe(0); + }); + + it('still credits liveness from a read after a write nested inside a conditional', () => { + expect(countCallEdgesTo(getDbPath(), 'survivesConditionalWrite')).toBeGreaterThan(0); + }); + + it('still credits a genuine read on the right-hand side of the killing statement itself', () => { + expect(countCallEdgesTo(getDbPath(), 'survivesSelfReadInKillStatement')).toBeGreaterThan(0); + }); } describe('logical-or/ternary value-ref requires local usage evidence (#2257) — WASM', () => { From 12bd339f220b145e6862fa4ec77099c318a33f21 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 17 Aug 2026 01:04:47 -0600 Subject: [PATCH 2/5] fix(extractors): recognize kills through parens/sequences and multi-declarator order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile review on PR #2554 found two more shapes the new kill detection missed: - A kill wrapped in a parenthesized or sequence expression (`(fn = replacement);`) wasn't recognized, since `killsBinding` only peeled one layer of `expression_statement` — now unwraps recursively through nested `parenthesized_expression`s and treats a `sequence_expression` as a kill when any of its parts kills the name. - A LATER statement with multiple declarators (`var fn = replacement, result = fn();`) still credited the second declarator's read, since the read-check scanned all declarators of a non-excluded `variable_declaration` unconditionally. `blockContainsIdentifierExcluding` now walks such declarators in order and stops once one of them kills the name, mirroring the same read-then-kill ordering already used at the block level. docs check acknowledged Impact: 3 functions changed, 7 affected --- .../src/extractors/javascript.rs | 146 ++++++++++++++---- src/extractors/javascript.ts | 88 +++++++++-- ...-2257-logical-or-ternary-value-ref.test.ts | 31 ++++ 3 files changed, 222 insertions(+), 43 deletions(-) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index e524fb446..3acda7d8d 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -5238,6 +5238,35 @@ fn block_contains_identifier_excluding( } return false; } + // This statement doesn't contain the declarator we're checking + // liveness FOR, but its OWN declarators still execute left-to-right + // — an earlier declarator unconditionally redeclaring `name` kills + // the value before a LATER declarator's initializer in the SAME + // statement runs (`var fn = replacement, result = fn();` must not + // credit `fn()`'s read to whatever `fn` held before this statement — + // Greptile review, #2438). + for i in 0..node.child_count() { + let Some(declarator) = node.child(i) else { + continue; + }; + if declarator.kind() != "variable_declarator" { + continue; + } + if block_contains_identifier_excluding( + &declarator, + name, + exclude_id, + source, + depth + 1, + require_call_site, + ) { + return true; + } + if declarator_kills_name(&declarator, name, source, exclude_id) { + return false; + } + } + return false; } if node.kind() == "variable_declarator" { let decl_name = node.child_by_field_name("name"); @@ -5439,6 +5468,25 @@ fn block_contains_identifier_excluding( false } +/// True when `declarator` unconditionally overwrites `name`: an initialized +/// (has a `value`) declarator whose binding pattern includes `name`, other +/// than `exclude_id` itself — the declarator the whole liveness check is +/// FOR, which trivially "binds" name via its own declaration and must never +/// be mistaken for a kill of its own freshly-assigned value. +/// +/// Mirrors `declaratorKillsName` in `src/extractors/javascript.ts`. +fn declarator_kills_name(declarator: &Node, name: &str, source: &[u8], exclude_id: usize) -> bool { + if declarator.kind() != "variable_declarator" || declarator.id() == exclude_id { + return false; + } + let decl_name = declarator.child_by_field_name("name"); + let value = declarator.child_by_field_name("value"); + match (decl_name, value) { + (Some(decl_name), Some(_)) => pattern_binds_name(&decl_name, name, source, 0), + _ => false, + } +} + /// True when `statement` — a DIRECT child of the enclosing block, exactly the /// granularity `has_later_reference_in_enclosing_block` iterates — /// unconditionally overwrites `name`: a top-level `name = value;` assignment @@ -5451,39 +5499,54 @@ fn block_contains_identifier_excluding( /// requirement: the original value can still reach a later read when the /// write didn't actually run). /// -/// `exclude_id` skips the declarator this liveness check is FOR, so the -/// declaration statement that introduces `name` (which trivially "binds" -/// name via its own declarator) is never mistaken for a kill of its own -/// freshly-assigned value. +/// Transparently unwraps `expression_statement` and any number of nested +/// `parenthesized_expression`s (`(fn = replacement);` is exactly as +/// unconditional as `fn = replacement;` — Greptile review), and treats a +/// `sequence_expression` as a kill the moment ANY of its comma-separated +/// parts kills `name`: every part of a sequence unconditionally executes in +/// order, so by the time the whole statement finishes, `name` no longer +/// holds whatever it held before that part ran (Greptile review). +/// Depth-bounded like every other recursive walk in this file. +/// +/// `exclude_id` skips the declarator this liveness check is FOR — see +/// `declarator_kills_name`. /// /// Mirrors `killsBinding` in `src/extractors/javascript.ts`. -fn kills_binding(statement: &Node, name: &str, source: &[u8], exclude_id: usize) -> bool { - let node: Node = if statement.kind() == "expression_statement" { - match statement.child(0) { - Some(child) => child, - None => return false, +fn kills_binding(statement: &Node, name: &str, source: &[u8], exclude_id: usize, depth: usize) -> bool { + if depth >= MAX_WALK_DEPTH { + return false; + } + // Recurse (not just peel once) — `((fn = x));` nests `expression_statement + // -> parenthesized_expression -> parenthesized_expression -> + // assignment_expression`, so a single unwrap leaves a + // `parenthesized_expression` that matches none of the checks below. + if statement.kind() == "expression_statement" || statement.kind() == "parenthesized_expression" + { + return match statement.named_child(0) { + Some(child) => kills_binding(&child, name, source, exclude_id, depth + 1), + None => false, + }; + } + if statement.kind() == "sequence_expression" { + for i in 0..statement.named_child_count() { + if let Some(part) = statement.named_child(i) { + if kills_binding(&part, name, source, exclude_id, depth + 1) { + return true; + } + } } - } else { - *statement - }; - if node.kind() == "assignment_expression" { - return match node.child_by_field_name("left") { + return false; + } + if statement.kind() == "assignment_expression" { + return match statement.child_by_field_name("left") { Some(left) => pattern_binds_name(&left, name, source, 0), None => false, }; } - if node.kind() == "variable_declaration" || node.kind() == "lexical_declaration" { - for i in 0..node.child_count() { - let Some(declarator) = node.child(i) else { - continue; - }; - if declarator.kind() != "variable_declarator" || declarator.id() == exclude_id { - continue; - } - let decl_name = declarator.child_by_field_name("name"); - let value = declarator.child_by_field_name("value"); - if let (Some(decl_name), Some(_)) = (decl_name, value) { - if pattern_binds_name(&decl_name, name, source, 0) { + if statement.kind() == "variable_declaration" || statement.kind() == "lexical_declaration" { + for i in 0..statement.child_count() { + if let Some(declarator) = statement.child(i) { + if declarator_kills_name(&declarator, name, source, exclude_id) { return true; } } @@ -5584,7 +5647,7 @@ fn has_later_reference_in_enclosing_block( ) { return true; } - if kills_binding(&child, name, source, declarator_node.id()) { + if kills_binding(&child, name, source, declarator_node.id(), 0) { return false; } } @@ -9058,6 +9121,35 @@ mod tests { })); } + // Greptile review, PR #2554: a kill wrapped in parentheses is exactly as + // unconditional as a bare assignment statement. + #[test] + fn does_not_credit_liveness_from_a_read_after_a_parenthesized_kill_assignment() { + let s = parse_js( + "let fn = options.custom || fetchLatestVersion;\n\ + (fn = replacement);\n\ + fn();", + ); + assert!(!s.calls.iter().any(|c| { + c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "fetchLatestVersion" + })); + } + + // Greptile review, PR #2554: within a single LATER statement, an + // earlier declarator's redeclaration kills the value before a later + // declarator's own initializer in that same statement runs. + #[test] + fn does_not_credit_liveness_from_a_later_declarator_reading_a_value_an_earlier_declarator_in_the_same_statement_killed( + ) { + let s = parse_js( + "var fn = options.custom || fetchLatestVersion;\n\ + var fn = replacement, result = fn();", + ); + assert!(!s.calls.iter().any(|c| { + c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "fetchLatestVersion" + })); + } + // A compound assignment (`+=`, `||=`, etc. — a distinct // `augmented_assignment_expression` node in this grammar) DOES read the // current value before writing, so its left-hand identifier is a real diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 2d5b48672..0b6781fe9 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -5033,6 +5033,25 @@ function blockContainsIdentifierExcluding( } return false; } + // This statement doesn't contain the declarator we're checking liveness + // FOR, but its OWN declarators still execute left-to-right — an earlier + // declarator unconditionally redeclaring `name` kills the value before a + // LATER declarator's initializer in the SAME statement runs + // (`var fn = replacement, result = fn();` must not credit `fn()`'s read + // to whatever `fn` held before this statement — Greptile review, #2438). + for (let i = 0; i < node.childCount; i++) { + const declarator = node.child(i); + if (declarator?.type !== 'variable_declarator') continue; + if ( + blockContainsIdentifierExcluding(declarator, name, excludeId, depth + 1, requireCallSite) + ) { + return true; + } + if (declaratorKillsName(declarator, name, excludeId)) { + return false; + } + } + return false; } if (node.type === 'variable_declarator') { const declName = node.childForFieldName('name'); @@ -5162,6 +5181,20 @@ function blockContainsIdentifierExcluding( return false; } +/** + * True when `declarator` unconditionally overwrites `name`: an initialized + * (has a `value`) declarator whose binding pattern includes `name`, other + * than `excludeId` itself — the declarator the whole liveness check is FOR, + * which trivially "binds" name via its own declaration and must never be + * mistaken for a kill of its own freshly-assigned value. + */ +function declaratorKillsName(declarator: TreeSitterNode, name: string, excludeId: number): boolean { + if (declarator.type !== 'variable_declarator' || declarator.id === excludeId) return false; + const declName = declarator.childForFieldName('name'); + const value = declarator.childForFieldName('value'); + return !!declName && !!value && patternBindsName(declName, name); +} + /** * True when `statement` — a DIRECT child of the enclosing block, exactly the * granularity `hasLaterReferenceInEnclosingBlock` iterates — unconditionally @@ -5174,25 +5207,48 @@ function blockContainsIdentifierExcluding( * #2438's own requirement: the original value can still reach a later read * when the write didn't actually run). * - * `excludeId` skips the declarator this liveness check is FOR, so the - * declaration statement that introduces `name` (which trivially "binds" - * name via its own declarator) is never mistaken for a kill of its own - * freshly-assigned value. + * Transparently unwraps `expression_statement` and any number of nested + * `parenthesized_expression`s (`(fn = replacement);` is exactly as + * unconditional as `fn = replacement;` — Greptile review), and treats a + * `sequence_expression` as a kill the moment ANY of its comma-separated + * parts kills `name`: every part of a sequence unconditionally executes in + * order, so by the time the whole statement finishes, `name` no longer + * holds whatever it held before that part ran (Greptile review). + * Depth-bounded like every other recursive walk in this file. + * + * `excludeId` skips the declarator this liveness check is FOR — see + * `declaratorKillsName`. */ -function killsBinding(statement: TreeSitterNode, name: string, excludeId: number): boolean { - const node = statement.type === 'expression_statement' ? statement.child(0) : statement; - if (!node) return false; - if (node.type === 'assignment_expression') { - const left = node.childForFieldName('left'); +function killsBinding( + statement: TreeSitterNode, + name: string, + excludeId: number, + depth = 0, +): boolean { + if (depth >= MAX_WALK_DEPTH) return false; + // Recurse (not just peel once) — `((fn = x));` nests `expression_statement + // -> parenthesized_expression -> parenthesized_expression -> + // assignment_expression`, so a single unwrap leaves a + // `parenthesized_expression` that matches none of the checks below. + if (statement.type === 'expression_statement' || statement.type === 'parenthesized_expression') { + const inner = statement.namedChild(0); + return inner ? killsBinding(inner, name, excludeId, depth + 1) : false; + } + if (statement.type === 'sequence_expression') { + for (let i = 0; i < statement.namedChildCount; i++) { + const part = statement.namedChild(i); + if (part && killsBinding(part, name, excludeId, depth + 1)) return true; + } + return false; + } + if (statement.type === 'assignment_expression') { + const left = statement.childForFieldName('left'); return !!left && patternBindsName(left, name); } - if (node.type === 'variable_declaration' || node.type === 'lexical_declaration') { - for (let i = 0; i < node.childCount; i++) { - const declarator = node.child(i); - if (declarator?.type !== 'variable_declarator' || declarator.id === excludeId) continue; - const declName = declarator.childForFieldName('name'); - const value = declarator.childForFieldName('value'); - if (declName && value && patternBindsName(declName, name)) return true; + if (statement.type === 'variable_declaration' || statement.type === 'lexical_declaration') { + for (let i = 0; i < statement.childCount; i++) { + const declarator = statement.child(i); + if (declarator && declaratorKillsName(declarator, name, excludeId)) return true; } } return false; diff --git a/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts b/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts index 86eb25254..4fa7ef06b 100644 --- a/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts +++ b/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts @@ -67,6 +67,8 @@ function killedByVarRedeclare(x) { return x + 10; } function survivesConditionalWrite(x) { return x + 11; } function survivesSelfReadInKillStatement(x) { return x + 12; } function somethingElse(x) { return x + 13; } +function killedByParenthesizedAssign(x) { return x + 14; } +function killedByLaterDeclaratorInSameStatement(x) { return x + 15; } // #2438: a plain top-level reassignment kills the fallback value before the // later read runs — that read sees \`other\`, never \`killedThenRead\`. @@ -103,6 +105,23 @@ export function selfReadWithinKillStatement(opts) { return fn; } +// #2438 (Greptile review): a kill wrapped in parentheses is exactly as +// unconditional as a bare assignment statement. +export function killViaParenthesizedAssign(opts, other) { + let fn = opts.custom || killedByParenthesizedAssign; + (fn = other); + return fn(); +} + +// #2438 (Greptile review): within a single LATER statement, an earlier +// declarator's redeclaration kills the value before a later declarator's +// own initializer in that same statement runs. +export function killViaLaterDeclaratorInSameStatement(opts, other) { + var fn = opts.custom || killedByLaterDeclaratorInSameStatement; + var fn = other, result = fn(); + return result; +} + // \`var\` is FUNCTION-scoped, so the nested block's \`var varScoped\` is the SAME // binding as the outer one — the \`varScoped()\` read before it genuinely // consumes the fallback, and must not be pruned as a nested-scope shadow. @@ -264,6 +283,18 @@ function runShared(getDbPath: () => string) { it('still credits a genuine read on the right-hand side of the killing statement itself', () => { expect(countCallEdgesTo(getDbPath(), 'survivesSelfReadInKillStatement')).toBeGreaterThan(0); }); + + // Greptile review, PR #2554: a kill wrapped in parentheses must be + // recognized just like a bare assignment statement. + it('does not credit liveness from a read after a parenthesized kill assignment', () => { + expect(countCallEdgesTo(getDbPath(), 'killedByParenthesizedAssign')).toBe(0); + }); + + // Greptile review, PR #2554: an earlier declarator's kill within a LATER + // statement must suppress a later declarator's read in that SAME statement. + it('does not credit liveness from a later declarator reading a value an earlier declarator in the same statement killed', () => { + expect(countCallEdgesTo(getDbPath(), 'killedByLaterDeclaratorInSameStatement')).toBe(0); + }); } describe('logical-or/ternary value-ref requires local usage evidence (#2257) — WASM', () => { From f70fcc580678f88d4fd88a0858d902f6d67f8889 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 17 Aug 2026 01:31:53 -0600 Subject: [PATCH 3/5] style: run cargo fmt on kills_binding signature docs check acknowledged --- crates/codegraph-core/src/extractors/javascript.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 3acda7d8d..e6ed384da 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -5512,7 +5512,13 @@ fn declarator_kills_name(declarator: &Node, name: &str, source: &[u8], exclude_i /// `declarator_kills_name`. /// /// Mirrors `killsBinding` in `src/extractors/javascript.ts`. -fn kills_binding(statement: &Node, name: &str, source: &[u8], exclude_id: usize, depth: usize) -> bool { +fn kills_binding( + statement: &Node, + name: &str, + source: &[u8], + exclude_id: usize, + depth: usize, +) -> bool { if depth >= MAX_WALK_DEPTH { return false; } From 99550e4e530dca95edc335a0d3488cb9e0697084 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 17 Aug 2026 02:02:44 -0600 Subject: [PATCH 4/5] fix(extractors): respect kill ordering within a sequence expression Greptile review on PR #2554: (fn = replacement, fn()) still credited the fn() read, since blockContainsIdentifierExcluding recursed into a sequence_expression's parts generically with no concept of the left-to-right order they actually execute in. Added the same ordered read-then-kill scan already used for top-level block statements and multi-declarator statements: each part of a sequence is checked for a read first, then whether it kills the name, stopping the scan once a kill is found. docs check acknowledged Impact: 1 functions changed, 5 affected --- .../src/extractors/javascript.rs | 43 +++++++++++++++++++ src/extractors/javascript.ts | 19 ++++++++ ...-2257-logical-or-ternary-value-ref.test.ts | 16 +++++++ 3 files changed, 78 insertions(+) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index e6ed384da..6e405f470 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -5295,6 +5295,34 @@ fn block_contains_identifier_excluding( None => false, }; } + // A comma-separated sequence (`fn = replacement, fn()`) executes its + // parts in order — a kill earlier in the sequence must suppress a read + // later in the SAME sequence, the same ordering already applied across + // top-level block statements and multi-declarator statements above + // (Greptile review, PR #2554: `(fn = replacement, fn())` was crediting + // the read because the generic recursive walk below has no concept of + // sequence-internal order). + if node.kind() == "sequence_expression" { + for i in 0..node.named_child_count() { + let Some(part) = node.named_child(i) else { + continue; + }; + if block_contains_identifier_excluding( + &part, + name, + exclude_id, + source, + depth + 1, + require_call_site, + ) { + return true; + } + if kills_binding(&part, name, source, exclude_id, depth + 1) { + return false; + } + } + return false; + } if node.kind() == "assignment_expression" { if let Some(left) = node.child_by_field_name("left") { if pattern_binds_name(&left, name, source, 0) { @@ -9156,6 +9184,21 @@ mod tests { })); } + // Greptile review, PR #2554: a sequence expression's parts execute in + // order — a kill earlier in the sequence must suppress a read later in + // the SAME sequence. + #[test] + fn does_not_credit_liveness_from_a_read_later_in_a_sequence_expression_whose_earlier_part_killed_it( + ) { + let s = parse_js( + "let fn = options.custom || fetchLatestVersion;\n\ + (fn = replacement, fn());", + ); + assert!(!s.calls.iter().any(|c| { + c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "fetchLatestVersion" + })); + } + // A compound assignment (`+=`, `||=`, etc. — a distinct // `augmented_assignment_expression` node in this grammar) DOES read the // current value before writing, so its left-hand identifier is a real diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 0b6781fe9..82355f65c 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -5066,6 +5066,25 @@ function blockContainsIdentifierExcluding( ? blockContainsIdentifierExcluding(value, name, excludeId, depth + 1, requireCallSite) : false; } + // A comma-separated sequence (`fn = replacement, fn()`) executes its parts + // in order — a kill earlier in the sequence must suppress a read later in + // the SAME sequence, the same ordering already applied across top-level + // block statements and multi-declarator statements above (Greptile review, + // PR #2554: `(fn = replacement, fn())` was crediting the read because the + // generic recursive walk below has no concept of sequence-internal order). + if (node.type === 'sequence_expression') { + for (let i = 0; i < node.namedChildCount; i++) { + const part = node.namedChild(i); + if (!part) continue; + if (blockContainsIdentifierExcluding(part, name, excludeId, depth + 1, requireCallSite)) { + return true; + } + if (killsBinding(part, name, excludeId, depth + 1)) { + return false; + } + } + return false; + } if (node.type === 'assignment_expression') { const left = node.childForFieldName('left'); const right = node.childForFieldName('right'); diff --git a/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts b/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts index 4fa7ef06b..be404c596 100644 --- a/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts +++ b/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts @@ -69,6 +69,7 @@ function survivesSelfReadInKillStatement(x) { return x + 12; } function somethingElse(x) { return x + 13; } function killedByParenthesizedAssign(x) { return x + 14; } function killedByLaterDeclaratorInSameStatement(x) { return x + 15; } +function killedBySequenceExprPriorPart(x) { return x + 16; } // #2438: a plain top-level reassignment kills the fallback value before the // later read runs — that read sees \`other\`, never \`killedThenRead\`. @@ -122,6 +123,14 @@ export function killViaLaterDeclaratorInSameStatement(opts, other) { return result; } +// #2438 (Greptile review): a sequence expression's parts execute in order — +// a kill earlier in the sequence must suppress a read later in the SAME +// sequence. +export function killViaSequenceExprPriorPart(opts, other) { + let fn = opts.custom || killedBySequenceExprPriorPart; + return (fn = other, fn()); +} + // \`var\` is FUNCTION-scoped, so the nested block's \`var varScoped\` is the SAME // binding as the outer one — the \`varScoped()\` read before it genuinely // consumes the fallback, and must not be pruned as a nested-scope shadow. @@ -295,6 +304,13 @@ function runShared(getDbPath: () => string) { it('does not credit liveness from a later declarator reading a value an earlier declarator in the same statement killed', () => { expect(countCallEdgesTo(getDbPath(), 'killedByLaterDeclaratorInSameStatement')).toBe(0); }); + + // Greptile review, PR #2554: a sequence expression's own internal ordering + // must be respected — a kill earlier in the sequence suppresses a read + // later in the SAME sequence. + it('does not credit liveness from a read later in a sequence expression whose earlier part killed it', () => { + expect(countCallEdgesTo(getDbPath(), 'killedBySequenceExprPriorPart')).toBe(0); + }); } describe('logical-or/ternary value-ref requires local usage evidence (#2257) — WASM', () => { From d9c2d42097c9e0846102b060fdea39e0185d6101 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 17 Aug 2026 02:53:41 -0600 Subject: [PATCH 5/5] fix(extractors): stop the last same-declaration kill gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile review on PR #2554: the hasExcludedDeclarator branch (the declaration statement containing the ORIGINAL fallback declarator) scanned its later sibling declarators for reads without checking whether one of them itself unconditionally redeclares the name first — `var fn = a || fallback, fn = other, result = fn();` still credited result's read to fallback. Now checks declaratorKillsName after each sibling's read-check, same pattern already used everywhere else in this function. docs check acknowledged Impact: 1 functions changed, 0 affected --- .../src/extractors/javascript.rs | 28 +++++++++++++++++++ src/extractors/javascript.ts | 10 +++++++ ...-2257-logical-or-ternary-value-ref.test.ts | 15 ++++++++++ 3 files changed, 53 insertions(+) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 6e405f470..5ab31b473 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -5235,6 +5235,19 @@ fn block_contains_identifier_excluding( ) { return true; } + // A LATER sibling declarator in this SAME statement can + // itself unconditionally redeclare `name` — `var fn = a || + // fallback, fn = other, result = fn();` must not credit + // `result`'s read to `fallback` once the intervening `fn = + // other` has already run (Greptile review, PR #2554). + // `declarator_kills_name` already excludes `exclude_id` + // itself, so the original declarator's own initializer is + // never mistaken for a kill of its own value. + if child.kind() == "variable_declarator" + && declarator_kills_name(&child, name, source, exclude_id) + { + return false; + } } return false; } @@ -9199,6 +9212,21 @@ mod tests { })); } + // Greptile review, PR #2554: a later sibling declarator in the SAME + // statement as the original fallback declarator can itself + // unconditionally redeclare the name — must suppress a read from a + // declarator after that. + #[test] + fn does_not_credit_liveness_from_a_declarator_reading_a_value_a_later_sibling_in_its_own_declaration_statement_killed( + ) { + let s = parse_js( + "var fn = options.custom || fetchLatestVersion, fn = replacement, result = fn();", + ); + assert!(!s.calls.iter().any(|c| { + c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "fetchLatestVersion" + })); + } + // A compound assignment (`+=`, `||=`, etc. — a distinct // `augmented_assignment_expression` node in this grammar) DOES read the // current value before writing, so its left-hand identifier is a real diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 82355f65c..6b6e0bdf5 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -5030,6 +5030,16 @@ function blockContainsIdentifierExcluding( if (blockContainsIdentifierExcluding(child, name, excludeId, depth + 1, requireCallSite)) { return true; } + // A LATER sibling declarator in this SAME statement can itself + // unconditionally redeclare `name` — `var fn = a || fallback, fn = + // other, result = fn();` must not credit `result`'s read to + // `fallback` once the intervening `fn = other` has already run + // (Greptile review, PR #2554). `declaratorKillsName` already + // excludes `excludeId` itself, so the original declarator's own + // initializer is never mistaken for a kill of its own value. + if (child.type === 'variable_declarator' && declaratorKillsName(child, name, excludeId)) { + return false; + } } return false; } diff --git a/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts b/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts index be404c596..788108d28 100644 --- a/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts +++ b/tests/integration/issue-2257-logical-or-ternary-value-ref.test.ts @@ -70,6 +70,7 @@ function somethingElse(x) { return x + 13; } function killedByParenthesizedAssign(x) { return x + 14; } function killedByLaterDeclaratorInSameStatement(x) { return x + 15; } function killedBySequenceExprPriorPart(x) { return x + 16; } +function killedByLaterDeclaratorInOwnStatement(x) { return x + 17; } // #2438: a plain top-level reassignment kills the fallback value before the // later read runs — that read sees \`other\`, never \`killedThenRead\`. @@ -131,6 +132,14 @@ export function killViaSequenceExprPriorPart(opts, other) { return (fn = other, fn()); } +// #2438 (Greptile review): a LATER sibling declarator in the SAME statement +// as the original fallback declarator can itself unconditionally redeclare +// the name — must suppress a read from a declarator after that. +export function killViaLaterDeclaratorInOwnStatement(opts, other) { + var fn = opts.custom || killedByLaterDeclaratorInOwnStatement, fn = other, result = fn(); + return result; +} + // \`var\` is FUNCTION-scoped, so the nested block's \`var varScoped\` is the SAME // binding as the outer one — the \`varScoped()\` read before it genuinely // consumes the fallback, and must not be pruned as a nested-scope shadow. @@ -311,6 +320,12 @@ function runShared(getDbPath: () => string) { it('does not credit liveness from a read later in a sequence expression whose earlier part killed it', () => { expect(countCallEdgesTo(getDbPath(), 'killedBySequenceExprPriorPart')).toBe(0); }); + + // Greptile review, PR #2554: a later sibling declarator in the SAME + // statement as the original fallback declarator can itself kill the name. + it('does not credit liveness from a declarator reading a value a later sibling in its own declaration statement killed', () => { + expect(countCallEdgesTo(getDbPath(), 'killedByLaterDeclaratorInOwnStatement')).toBe(0); + }); } describe('logical-or/ternary value-ref requires local usage evidence (#2257) — WASM', () => {