Skip to content

fix: emit reference edges for function identifiers used as object-literal values#1898

Open
carlos-alm wants to merge 1 commit into
fix/issue-1770-leiden-partition-dead-codefrom
fix/issue-1771-dispatch-table-reference-edges
Open

fix: emit reference edges for function identifiers used as object-literal values#1898
carlos-alm wants to merge 1 commit into
fix/issue-1770-leiden-partition-dead-codefrom
fix/issue-1771-dispatch-table-reference-edges

Conversation

@carlos-alm

Copy link
Copy Markdown
Contributor

Summary

  • Functions referenced as object-literal property values (dispatch-table pattern: { matches, resolve: someFunction }) got zero graph edges at all, so roles --role dead fell back to an incidental fanOut > 0 heuristic in the role classifier to rescue them — producing inconsistent results across structurally identical dispatch-table entries depending on whether each handler happened to call another tracked symbol internally (unrelated to whether the dispatch-table reference itself was real).
  • Reused the existing calls edge kind + dynamic=1 with a new DynamicKind variant 'value-ref', rather than inventing a new edge kind — this wires into fan-in/fan-out, cycle detection, exporters, and MCP schemas for free (fan-in/out are scoped to kind IN ('calls', 'imports-type')/kind='calls'), matches the precedent set by bug(resolver): identifier-arg dynamic call resolution fabricates false edges/cycles on name collision (e.g. communities.ts phantom cycle) #1741's identifier-arg handling, and matches ADR-002's explicit "no new edge kind" decision for dynamic-call classification.
  • Over-matching guard: resolver-side filter drops any resolved target whose kind isn't function/method (object-literal values are as often data as functions).
  • The fanOut > 0 heuristic is kept, not removed — it's no longer load-bearing for object-literal values but is still the only rescue for the sibling logical-or-fallback pattern (options._fetchLatest || fetchLatestVersion) and other unextracted value-reference shapes.

Closes #1771

Test plan

  • npm test — full suite green (3560 passed, 0 failed)
  • npm run lint / npx tsc --noEmit — clean
  • cargo test — 465 passed
  • Native addon rebuilt, codesigned, verified directly
  • Resolution benchmark: built an isolated worktree at the pre-change commit for a genuine baseline — before/after identical across all 34 languages, zero regression, zero fabricated edges
  • Exact repro confirmed fixed on both engines: all 5 PARAM_NODE_HANDLERS entries now show totalDependents:1, role:core, zero dead-flagged

Scope: fully general, not scoped down — any bare-identifier object-literal property value or shorthand property, same-file or cross-file.

Filed #1897 for an out-of-scope finding: WASM has no equivalent to native's inline object-literal dispatch-table extraction (({a:fnA,b:fnB})[k]()) — a genuine engine-parity gap, unrelated to this issue.

Stacked on #1896 (base branch fix/issue-1770-leiden-partition-dead-code) — only the extractor/builder/classifier/test diff is this issue's change.

…eral values

codegraph roles --role dead inconsistently flagged dispatch-table handler
functions (`{ matches, resolve: someFunction }`-style arrays) as dead,
depending on an unrelated, incidental property of the referenced function:
whether it happened to call another tracked symbol internally (fanOut > 0).

Root cause: codegraph created no edge at all for a bare function identifier
used as an object-literal property VALUE. The role classifier's
`node.kind === 'function' && node.fanOut > 0` heuristic in
classifyUnreferencedNode incidentally rescued whichever handlers happened to
have nonzero fanOut, leaving the rest misclassified dead-unresolved — a
false positive that risks deleting live dispatch-table handlers during
dead-code cleanup.

Fix: both engines now extract a dynamic `calls` edge (dynamicKind /
dynamic_kind = 'value-ref') for every bare-identifier object-literal
property value and shorthand property (`{ resolve: fn }` / `{ fn }`),
attributed to the enclosing scope via the existing findCaller machinery
(falls back to the widest enclosing constant/variable binding for top-level
dispatch tables, same as any other call). Resolution is restricted to
function/method-kind targets only, so plain data references
(`{ name: SOME_CONSTANT }`) are silently dropped rather than fabricating
nonsensical edges to constants.

Reuses the existing `calls` edge kind + dynamic flag (per ADR-002's "no new
edge kind" precedent, and the existing extractCallbackReferenceCalls
mechanism from #1741) rather than inventing a new edge kind — fan-in/fan-out
(scoped to `kind IN ('calls','imports-type')` / `kind = 'calls'`) already
account for it with zero changes to structure.ts/roles.rs's median
computations.

The `fanOut > 0` heuristic in classifyUnreferencedNode is kept, narrowed by
comment: it's no longer load-bearing for the object-literal case but remains
the only rescue for sibling value-reference patterns not yet extracted as
edges (logical-or fallback defaults, ternary defaults, array-of-functions
elements).

Applied to both engines:
- WASM/TS: src/extractors/javascript.ts (collectObjectLiteralValueRefCall,
  wired into the shared runCollectorWalk used by both the walk and query
  extraction paths), src/domain/graph/builder/stages/build-edges.ts
  (resolveFallbackTargets kind filter), src/domain/graph/builder/incremental.ts
  (mirrored filter for the incremental/watch-mode path), src/types.ts (new
  'value-ref' DynamicKind variant).
- Native: crates/codegraph-core/src/extractors/javascript.rs
  (handle_object_literal_pair_value_ref /
  handle_object_literal_shorthand_value_ref, wired into match_js_node),
  crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs
  (matching kind filter in process_file).

Verified against the exact repro (this repo's own PARAM_NODE_HANDLERS
dispatch table in src/ast-analysis/visitor-utils.ts): all 5 handlers now
show totalDependents=1 and role=core on both engines, with zero dead-flagged
symbols in that file. Resolution-benchmark precision/recall is unchanged
across all 34 language fixtures (javascript 100/100, typescript 95.7/93.6,
aggregate 63.8% recall, identical before and after).

Fixes #1771

docs check acknowledged — internal edge-emission/resolver bug fix, no new
commands, languages, or architecture to document.

Impact: 9 functions changed, 19 affected
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Codegraph Impact Analysis

9 functions changed19 callers affected across 6 files

  • buildCallEdges in src/domain/graph/builder/incremental.ts:733 (5 transitive callers)
  • resolveFallbackTargets in src/domain/graph/builder/stages/build-edges.ts:1303 (3 transitive callers)
  • extractSymbolsQuery in src/extractors/javascript.ts:392 (1 transitive callers)
  • extractSymbolsWalk in src/extractors/javascript.ts:835 (1 transitive callers)
  • collectObjectLiteralValueRefCall in src/extractors/javascript.ts:3158 (8 transitive callers)
  • CollectorWalkTargets.valueRefCalls in src/extractors/javascript.ts:3736 (0 transitive callers)
  • runCollectorWalk in src/extractors/javascript.ts:3762 (3 transitive callers)
  • walk in src/extractors/javascript.ts:3763 (8 transitive callers)
  • classifyUnreferencedNode in src/graph/classifiers/roles.ts:192 (4 transitive callers)

@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes inconsistent dead-code classification for functions referenced as object-literal property values (dispatch-table pattern: { matches: isA, resolve: resolveA }). Previously, no graph edge was emitted for such references, so role classification fell back to an incidental fanOut > 0 heuristic that produced different results for structurally identical dispatch-table entries.

  • Both the WASM (TypeScript) and native (Rust) extractors now emit a calls edge with dynamic=1 and dynamicKind='value-ref' for every bare-identifier object-literal property value; a downstream kind === 'function' || kind === 'method' filter in the full-build and incremental paths prevents false edges to plain data constants.
  • The fanOut > 0 heuristic in the role classifier is preserved as a fallback for value-reference patterns (logical-or defaults, ternary defaults, array-of-functions) that still produce no edge.

Confidence Score: 4/5

Safe to merge; the core extraction and edge-filtering logic is correct on both engines and both build paths, with comprehensive parser-level and integration-level test coverage for the full-build path.

The functional fix is well-reasoned and the Rust/TypeScript parity is tight. Two gaps keep the score from being higher: the kind type cast in resolveFallbackTargets silently drops valid edges if any resolution tier omits the field at runtime (no diagnostic), and the integration tests exclusively exercise incremental: false, leaving the incremental build path's value-ref edge behavior unverified end-to-end.

src/domain/graph/builder/stages/build-edges.ts (type cast) and tests/integration/issue-1771-dispatch-table-value-ref.test.ts (incremental coverage gap).

Important Files Changed

Filename Overview
src/extractors/javascript.ts Adds collectObjectLiteralValueRefCall helper and pair/shorthand_property_identifier cases to runCollectorWalk; routes both through the new required valueRefCalls field in CollectorWalkTargets.
crates/codegraph-core/src/extractors/javascript.rs Adds Rust parity functions for pair and shorthand_property_identifier node handling, registered in match_js_node with comprehensive unit tests.
src/domain/graph/builder/stages/build-edges.ts Adds dynamicKind === 'value-ref' filter in resolveFallbackTargets; requires a runtime type cast because call-resolver.ts omits kind from its declared return type.
src/domain/graph/builder/incremental.ts Mirrors the full-build value-ref kind filter; kind?: string is already properly typed in applyCallFallbacks so no cast is needed.
crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs Adds value-ref filter after all Rust resolution tiers, retaining only function/method targets.
tests/integration/issue-1771-dispatch-table-value-ref.test.ts New integration test covers WASM and native full-build paths; incremental build path is not exercised.
tests/parsers/javascript.test.ts Seven new parser-level unit tests cover positive and negative extraction cases.
src/types.ts Adds value-ref to the DynamicKind union type.
docs/architecture/decisions/002-dynamic-call-resolution.md ADR updated to document value-ref semantics.
src/graph/classifiers/roles.ts Comment-only update explaining the fanOut > 0 heuristic is now a fallback, not the primary rescue for dispatch tables.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[JS/TS source file] -->|tree-sitter parse| B{Node type?}
    B -->|pair node| C[collectObjectLiteralValueRefCall\nvalue must be bare identifier]
    B -->|shorthand_property_identifier| D[inline push to valueRefCalls]
    C -->|BUILTIN_GLOBALS? skip| E[Call record\ndynamic=true\ndynamicKind=value-ref]
    D -->|BUILTIN_GLOBALS? skip| E
    E --> F{Build path}
    F -->|Full build\nbuild-edges.ts| G[resolveFallbackTargets]
    F -->|Incremental\nincremental.ts| H[applyCallFallbacks]
    G --> I{Resolved targets}
    H --> I
    I -->|dynamicKind=value-ref filter| J{target.kind?}
    J -->|function or method| K[Emit calls edge\ndynamic=1\ndynamic_kind=NULL in DB]
    J -->|anything else| L[Silently dropped\nno edge, no flag]
    K --> M[fan-in increases\nrole classifier: non-dead]
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[JS/TS source file] -->|tree-sitter parse| B{Node type?}
    B -->|pair node| C[collectObjectLiteralValueRefCall\nvalue must be bare identifier]
    B -->|shorthand_property_identifier| D[inline push to valueRefCalls]
    C -->|BUILTIN_GLOBALS? skip| E[Call record\ndynamic=true\ndynamicKind=value-ref]
    D -->|BUILTIN_GLOBALS? skip| E
    E --> F{Build path}
    F -->|Full build\nbuild-edges.ts| G[resolveFallbackTargets]
    F -->|Incremental\nincremental.ts| H[applyCallFallbacks]
    G --> I{Resolved targets}
    H --> I
    I -->|dynamicKind=value-ref filter| J{target.kind?}
    J -->|function or method| K[Emit calls edge\ndynamic=1\ndynamic_kind=NULL in DB]
    J -->|anything else| L[Silently dropped\nno edge, no flag]
    K --> M[fan-in increases\nrole classifier: non-dead]
Loading

Comments Outside Diff (1)

  1. tests/integration/issue-1771-dispatch-table-value-ref.test.ts, line 559-565 (link)

    P2 Incremental build path not exercised by integration tests

    Both describe blocks (WASM and native) call buildGraph with incremental: false. The incremental path in incremental.ts received its own filter (lines 774–776), but its end-to-end behavior — whether value-ref edges are correctly emitted during an incremental rebuild of a file containing a dispatch table — is untested. A subsequent incremental build with incremental: true on the same fixture would confirm parity.

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

    Fix in Claude Code

Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix: emit reference edges for function i..." | Re-trigger Greptile

Comment on lines +1377 to +1384
if (call.dynamicKind === 'value-ref') {
// `targets` is typed without `kind` when it flows straight through from
// resolveCallTargets (call-resolver.ts's declared return type omits it),
// but every underlying CallNodeLookup method actually populates it — the
// same gap the preQualifiedTargets cast above already works around.
targets = (targets as ReadonlyArray<{ id: number; file: string; kind?: string }>).filter(
(t) => t.kind === 'function' || t.kind === 'method',
);

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 Unsafe kind field access on undeclared type

The cast targets as ReadonlyArray<{ id: number; file: string; kind?: string }> works around call-resolver.ts's return type omitting kind. If any resolution tier produces targets where kind is genuinely absent at runtime (not merely absent from the declared type), the filter silently drops those targets and no edge is emitted — with zero diagnostic signal. The PR comment and the preQualifiedTargets cast above confirm this is a known type gap in the codebase, but combining an unsafe cast with a functional gate in a single expression makes future regressions hard to detect.

Fix in Claude Code

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