Skip to content

fix(resolver): require invocation evidence for object-literal value-ref liveness#2034

Open
carlos-alm wants to merge 1 commit into
fix/issue-1893-es6-getter-setter-property-reads-are-neverfrom
fix/issue-1895-roles-role-dead-object-literal-property-value
Open

fix(resolver): require invocation evidence for object-literal value-ref liveness#2034
carlos-alm wants to merge 1 commit into
fix/issue-1893-es6-getter-setter-property-reads-are-neverfrom
fix/issue-1895-roles-role-dead-object-literal-property-value

Conversation

@carlos-alm

Copy link
Copy Markdown
Contributor

Summary

codegraph roles --role dead treated a function referenced as an object-literal property value ({ resolve: someFn } bare identifiers, { someFn } shorthand — the #1771 "value-ref" dispatch-table mechanism) as sufficient evidence of liveness on its own, without checking whether the property is ever actually accessed and invoked (table.resolve(...)) anywhere. A dispatch-table entry that is wired up but genuinely never read escaped dead-code detection as a false negative.

Reproduced and confirmed against this repo's own built dist/cli.js (the published npm binary predates this feature and can't be used to test it — see crates/codegraph-core staleness note below):

function neverCalled(x) { return x + 1; }
function realHandler(x) { return x + 2; }
const handlers = { resolve: neverCalled, reject: realHandler };
function run() { return handlers.reject(1); } // .resolve(...) is never called anywhere

Before this fix, neverCalled was classified leaf/core (never dead-*) purely because it appeared as resolve's value — identical treatment to realHandler, which genuinely is invoked. After the fix, neverCalled is correctly classified dead-unresolved; realHandler is unaffected.

Fix

Value-ref calls originating from an object-literal property now carry the property's key name (Call.keyExpr / CallInfo.key_expr — an existing field, previously only used for reflection-style resolution, unambiguously safe to reuse since every consumer of it is gated on dynamicKind === 'reflection'), distinct from the referenced value's own declared name. The resolver only honors the value-ref as a calls edge when that key is independently confirmed to be invoked via member-call syntax (x.keyExpr(...)) somewhere in the files currently being processed — full builds see the whole codebase; incremental rebuilds see the file(s) being rebuilt (the same scoping trade-off this codebase already accepts elsewhere for incremental classification, e.g. hasActiveFileSiblings/exported-via-reexport/median fan-in in roles.rs's incremental path).

Mirrored identically in both engines:

  • WASM/TS: collectObjectLiteralValueRefCall + shorthand collector (src/extractors/javascript.ts) now set keyExpr; resolveFallbackTargets (stages/build-edges.ts) and buildCallEdges (incremental.ts) apply the new check via a shared collectInvokedPropertyNames helper (call-resolver.ts, the module already shared between the full-build and incremental paths).
  • Native/Rust: handle_object_literal_pair_value_ref + shorthand handler (extractors/javascript.rs) now set key_expr; EdgeContext (build_edges.rs) precomputes the same invoked_property_names set from files and process_file's value-ref filter applies the identical check.

instanceof/Lua-builtin-reassignment value-refs never set keyExpr (no property-key concept applies to them), so they're unaffected by this stricter check.

Scope note (partial fix — see follow-ups)

This closes the false negative for the literal mechanism named in #1895's title (bare-identifier/shorthand object-literal property values). While investigating, I found the issue's own concrete motivating example (Partition.deltaCPM etc. from #1770) is actually caused by two different, deeper mechanisms that this PR does not touch:

Both are filed as separate, correctly-scoped issues per repo convention rather than expanding this PR's diff — closing the loop on the original #1770 concrete case needs all three fixes together. Given the value-ref liveness gap described by #1895's title and suggested-fix-direction is fully and correctly closed here, this PR uses Refs #1895 rather than Fixes #1895, following the precedent set by PR #1943.

Test plan

Refs #1895

…ef liveness

Object-literal property-value references (`{ resolve: someFn }` bare
identifiers and `{ someFn }` shorthand) previously granted a function
liveness merely by existing as a property value, regardless of whether
that property was ever actually invoked (`table.resolve(...)`)
anywhere in the codebase. A dispatch-table entry that was wired up but
never read escaped `roles --role dead` detection as a false negative.

Value-ref calls originating from an object-literal property now carry
the property's key name (Call.keyExpr / CallInfo.key_expr), distinct
from the referenced value's own name. The resolver only honors the
value-ref as a `calls` edge when that key is independently confirmed
to be invoked via member-call syntax somewhere in the files being
processed (full build: the whole codebase; incremental: the current
file), mirrored identically in both the WASM/TS and native Rust
engines.

Impact: 10 functions changed, 32 affected
@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR tightens the dead-code false-negative introduced by the #1771 dispatch-table value-ref mechanism: a function referenced as an object-literal property value ({ resolve: someFn }) now only receives a calls edge when the property key is independently confirmed to be member-called (x.resolve(...)) somewhere in the files being processed. The fix is mirrored identically in both the TS/WASM and Rust/native engines, with the shared collectInvokedPropertyNames helper (TS) and collect_invoked_property_names (Rust) extracting the evidence set.

  • Extractor change: collectObjectLiteralValueRefCall / handle_object_literal_pair_value_ref (and their shorthand counterparts) now populate keyExpr / key_expr with the property's key name; a new resolveObjectLiteralKeyName helper centralises the key-resolution logic previously inlined in extractObjectLiteralFunctions.
  • Resolver change: both resolveFallbackTargets (full build, build-edges.ts) and buildCallEdges (incremental, incremental.ts) apply the keyExpr ∉ invokedPropertyNames → targets = [] guard after the existing value-ref kind-filter; the Rust process_file applies the identical key_expr/invoked_property_names check.
  • Test coverage: new unit tests for key extraction (all key forms, including shorthand and non-string computed), a Rust-level build_call_edges unit test for the filter, and a full-build integration test confirming dead-vs-live classification for both WASM and native engines.

Confidence Score: 4/5

Safe to merge for the full-build path; the incremental path now aggressively drops value-ref edges when the invocation is cross-file, which can surface dead-code findings during watch rebuilds that a subsequent full rebuild will correct.

The core fix is correct and well-tested on both engines for the full-build case. The only structural concern is that the incremental TS path scopes invokedPropertyNames to a single file — a new behaviour change from the pre-fix always-keep baseline — meaning a function genuinely reachable via a cross-file consumer can appear dead on an incremental rebuild.

src/domain/graph/builder/incremental.ts — the single-file scope for invokedPropertyNames is the new behavioural trade-off worth a second look if incremental dead-code CI checks are part of the workflow.

Important Files Changed

Filename Overview
src/domain/graph/builder/call-resolver.ts Adds collectInvokedPropertyNames — a clean, shared helper that collects member-call property names from a nested iterable of call arrays; correct and well-documented.
src/domain/graph/builder/stages/build-edges.ts Full-build path: invokedPropertyNames computed once across all fileSymbols and threaded into buildFileCallEdgesresolveFallbackTargets; the guard is correctly applied only to value-ref calls with keyExpr set.
src/domain/graph/builder/incremental.ts Incremental path: invokedPropertyNames is scoped to the single file being rebuilt — documented trade-off but can produce false dead-code classifications when the cross-file invocation is in an untouched file.
src/extractors/javascript.ts Extracts new resolveObjectLiteralKeyName helper (pure refactoring of existing inline logic) and sets keyExpr in both collectObjectLiteralValueRefCall and the shorthand handler; non-string computed keys correctly collapse to undefined.
crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs Rust mirror: collect_invoked_property_names added, EdgeContext extended with invoked_property_names, process_file filter correct; lifetime annotation HashSet<&'a str> properly tied to the files borrow.
crates/codegraph-core/src/extractors/javascript.rs Both handle_object_literal_pair_value_ref and handle_object_literal_shorthand_value_ref now populate key_expr; shorthand correctly sets key_expr = name; non-string computed keys return None from resolve_pair_key_name.
tests/integration/issue-1895-value-ref-invocation-check.test.ts Integration test covers full-build path (both engines) for the dead/live classification; incremental path not exercised, but that gap is a documented limitation.
tests/parsers/javascript.test.ts Unit tests added for all key-extraction variants (identifier, string literal, computed string, non-string computed, shorthand, instanceof); comprehensive and correctly matched to the extractor logic.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["Object-literal extractor\n{ resolve: someFn }"] -->|"sets keyExpr = resolve\nname = someFn"| B[Call record\ndynamicKind=value-ref]

    B --> C{Full build or\nIncremental?}

    C -->|Full build via build-edges.ts| D["collectInvokedPropertyNames\nfrom all files in fileSymbols"]
    C -->|Incremental via incremental.ts| E["collectInvokedPropertyNames\nfrom current file only"]

    D --> F{"keyExpr in\ninvokedPropertyNames?"}
    E --> F

    F -->|Yes| G["Keep calls edge\nsomeFn is LIVE"]
    F -->|No| H["Drop targets\nsomeFn is DEAD"]

    style G fill:#d4edda,stroke:#28a745
    style H fill:#f8d7da,stroke:#dc3545
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["Object-literal extractor\n{ resolve: someFn }"] -->|"sets keyExpr = resolve\nname = someFn"| B[Call record\ndynamicKind=value-ref]

    B --> C{Full build or\nIncremental?}

    C -->|Full build via build-edges.ts| D["collectInvokedPropertyNames\nfrom all files in fileSymbols"]
    C -->|Incremental via incremental.ts| E["collectInvokedPropertyNames\nfrom current file only"]

    D --> F{"keyExpr in\ninvokedPropertyNames?"}
    E --> F

    F -->|Yes| G["Keep calls edge\nsomeFn is LIVE"]
    F -->|No| H["Drop targets\nsomeFn is DEAD"]

    style G fill:#d4edda,stroke:#28a745
    style H fill:#f8d7da,stroke:#dc3545
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(resolver): require invocation eviden..." | Re-trigger Greptile

// #1895: scoped to this file's own calls only — see collectInvokedPropertyNames
// doc comment (call-resolver.ts) for why incremental rebuilds use a narrower,
// same-file view rather than a full-codebase one.
const invokedPropertyNames = collectInvokedPropertyNames([symbols.calls]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Incremental rebuild can false-positive dead-code when the invocation is cross-file

collectInvokedPropertyNames([symbols.calls]) only sees calls extracted from the single file currently being rebuilt. If factory.js defines { resolve: neverCalled } and the only table.resolve(1) site lives in consumer.js, an incremental rebuild triggered solely on factory.js will not find resolve in invokedPropertyNames and will drop the edge — classifying neverCalled as dead even though it is genuinely reachable. Before this fix the edge was unconditionally kept, so this is a new false-positive direction introduced on the incremental path.

This is documented as the same scoping trade-off accepted elsewhere, and a full rebuild will correct it. Worth confirming the team is comfortable with incremental builds potentially surfacing "dead" findings that disappear on the next full run, since that could affect CI dead-code checks that run on incremental watches.

Fix in Claude Code

Comment on lines +71 to +80
export function collectInvokedPropertyNames(
callsList: Iterable<Iterable<{ name: string; receiver?: string }>>,
): Set<string> {
const names = new Set<string>();
for (const calls of callsList) {
for (const call of calls) {
if (call.receiver) names.add(call.name);
}
}
return names;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Name-only matching can suppress dead-code detection due to unrelated call sites

collectInvokedPropertyNames adds a property name whenever ANY call in the processed files has a non-empty receiver and that name. There is no check that the receiver is plausibly the same dispatch-table object. A completely unrelated logger.resolve() or promise.resolve() call anywhere in the codebase will add resolve to the set and prevent { resolve: neverCalled } from being flagged dead, even if the actual dispatch table is never read. This is the conservative direction of error (false negative for dead-code), so it won't misclassify live code as dead, but it can cause real dead-code entries to escape detection. Given the existing comment explicitly scopes this as "one hop further" heuristic evidence rather than precise analysis, this is understood — just worth noting for future precision work.

Fix in Claude Code

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Codegraph Impact Analysis

10 functions changed30 callers affected across 4 files

  • collectInvokedPropertyNames in src/domain/graph/builder/call-resolver.ts:71 (8 transitive callers)
  • buildCallEdges in src/domain/graph/builder/incremental.ts:1043 (5 transitive callers)
  • buildCallEdgesJS in src/domain/graph/builder/stages/build-edges.ts:947 (3 transitive callers)
  • resolveFallbackTargets in src/domain/graph/builder/stages/build-edges.ts:1292 (3 transitive callers)
  • buildFileCallEdges in src/domain/graph/builder/stages/build-edges.ts:1731 (3 transitive callers)
  • resolveObjectLiteralKeyName in src/extractors/javascript.ts:1508 (20 transitive callers)
  • extractObjectLiteralFunctions in src/extractors/javascript.ts:1536 (8 transitive callers)
  • collectObjectLiteralValueRefCall in src/extractors/javascript.ts:3429 (14 transitive callers)
  • runCollectorWalk in src/extractors/javascript.ts:4249 (3 transitive callers)
  • walk in src/extractors/javascript.ts:4250 (14 transitive callers)

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant