Skip to content

fix(wasm): preserve dyn=1 when deduplicating edges with same source/target/kind/conf#1688

Merged
carlos-alm merged 3 commits into
mainfrom
fix/parity-1683-ts-decorator-dyn
Jun 21, 2026
Merged

fix(wasm): preserve dyn=1 when deduplicating edges with same source/target/kind/conf#1688
carlos-alm merged 3 commits into
mainfrom
fix/parity-1683-ts-decorator-dyn

Conversation

@carlos-alm

Copy link
Copy Markdown
Contributor

Summary

  • In the WASM engine's query path, a bare @Log decorator (emitted as dyn=1, dynamicKind=reflection by handleDecorator in runCollectorWalk) was being silently dropped when @Log() (a call-expression decorator, dyn=0) was already processed first and added to seenCallEdges
  • Adds a dynZeroEdgeRows map (parallel to ptsEdgeRows) that tracks the allEdgeRows index of any dyn=0 direct-call edge; when a subsequent call for the same pair has dyn=1 AND an explicit dynamicKind, the existing row is upgraded in-place to dyn=1
  • The dynamicKind guard prevents generic dynamic=true alias/callback calls (f.call, f.bind) from incorrectly upgrading dyn=0 edges

Test plan

Closes #1683

…r Groovy/Java/Scala

JVM getMethod/invokeMethod calls with literal method names were emitted
by the Rust extractors with dynamicKind='reflection' and keyExpr set,
but resolve_call_targets in build_edges.rs had no path to resolve them.

Methods in Groovy/Java/Scala are stored as class-qualified names
(e.g. DynamicDispatch.greet), so the plain name lookup of 'greet'
finds nothing. The TypeScript resolver has a RES-3 fallback in
resolveFallbackTargets that handles this via two qualified lookups:
  1. typeMap[receiver] -> resolvedType.keyExpr (type-annotated locals)
  2. callerName class prefix -> CallerClass.keyExpr (same-class siblings)

Add the equivalent block to resolve_call_targets in Rust, scoped to
non-JS/TS files to avoid interfering with the existing JS reflection
path. Fixes the wasm=1 native=0 divergence for:
  dynamic-groovy: DynamicDispatch.runInvokeMethod -> DynamicDispatch.greet
  dynamic-java:   Reflection.runGetMethod -> Reflection.greet
  dynamic-scala:  Reflection.runGetMethod -> Reflection.greet

Closes #1681
…ous invoke sink

RES-4 pre-pass in resolve_call_targets: when a call has dynamicKind='reflection'
and a receiver but no keyExpr (i.e. Greeter::greet member callable reference),
look up {Receiver}.{name} in the same file before the plain {name} same-file
lookup. Without this, greet matched the top-level free function instead of
Greeter.greet (the class method), diverging from the WASM engine which has an
equivalent RES-4 pre-pass in resolveFallbackTargets.

For fn.invoke() patterns: the WASM grammar models navigation_expression with a
navigation_suffix child, so WASM's lastChild.type check never sees 'simple_identifier'
and emits nothing. Native was emitting an <dynamic:unresolved> call which produced a
spurious sink edge. Suppress the invoke call in native to match WASM behaviour.

Closes #1682
…arget/kind/conf

When two resolved call edges share the same (source, target) pair but differ
in the dynamic flag — e.g. a bare `@Log` decorator (dyn=1, dynamicKind=reflection)
and a call-expression `@Log()` decorator (dyn=0) both resolving to the same
function — the dedup logic in buildFileCallEdges previously let the first edge
emitted win unconditionally.

In the query path, `@Log()` is captured as a callfn_node match before the
runCollectorWalk pass emits the bare `@Log` via handleDecorator. This means
the dyn=0 edge is added to seenCallEdges first, and the subsequent dyn=1 call
is silently dropped.

Fix: add a dynZeroEdgeRows map (parallel to ptsEdgeRows) that tracks the
allEdgeRows index of any direct-call edge emitted with dyn=0. When a later
call for the same (source, target) pair carries both dyn=1 AND an explicit
dynamicKind (e.g. 'reflection'), the existing dyn=0 row is upgraded in-place
to dyn=1. The dynamicKind guard prevents generic dynamic=true alias/callback
calls (f.call, f.bind — no dynamicKind) from incorrectly overriding dyn=0
direct-call edges.

Closes #1683

Impact: 2 functions changed, 4 affected
@greptile-apps

greptile-apps Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the WASM engine's edge-deduplication path so that a bare decorator (@Log, dyn=1, dynamicKind=reflection) is no longer silently dropped when a call-expression form (@Log(), dyn=0) for the same source/target pair was already recorded. It also adds parity improvements to the native Rust engine (RES-3/RES-4 resolution steps for JVM reflection and Kotlin member callable references) and aligns the Kotlin extractor's behaviour for invoke calls with the WASM path.

  • build-edges.ts: Introduces dynZeroEdgeRows (a Map<string, number> from edge key to allEdgeRows index) that tracks newly emitted dyn=0 direct-call edges; when a later call for the same pair carries dyn=1 and an explicit dynamicKind, the existing row is upgraded in-place via row[4] = 1 and removed from the map.
  • build_edges.rs: Adds RES-4 (pre-qualified Receiver.name lookup for Kotlin member references) before the same-file fallback, and RES-3 (resolvedType.keyExpr and CallerClass.keyExpr lookups for JVM getMethod/invokeMethod reflection), both scoped to non-JS/TS files.
  • kotlin.rs: Suppresses the previously emitted <dynamic:unresolved> call for .invoke(…) callable-reference invocations, removing an unresolvable synthetic node that the WASM path never produces.

Confidence Score: 4/5

The change is well-scoped and verified against the full parity test suite; the core decorator-dedup fix is straightforward and the Rust additions mirror existing TypeScript logic.

The dynZeroEdgeRows map correctly handles the primary use case (call-expression decorator processed before bare decorator). A narrow gap exists: when a PTS-promoted dyn=0 edge is later encountered by a dyn=1+dynamicKind call, the upgrade won't fire because the PTS promotion path never writes into dynZeroEdgeRows. This won't affect the decorator scenario that motivated the fix, but it leaves the in-place upgrade logic incomplete for PTS-first resolutions.

src/domain/graph/builder/stages/build-edges.ts — specifically the PTS-upgrade branch (lines 1283–1294) and how it interacts with dynZeroEdgeRows.

Important Files Changed

Filename Overview
src/domain/graph/builder/stages/build-edges.ts Adds dynZeroEdgeRows map to track dyn=0 direct-call edges and upgrade them to dyn=1 when a later call carries an explicit dynamicKind; PTS-promoted dyn=0 edges are not enrolled in this map, leaving a narrow gap.
crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs Adds RES-4 (Kotlin member callable reference pre-qualified lookup) and RES-3 (JVM reflection with literal key_expr) resolution steps to the native engine, mirroring existing WASM fallback logic. Logic is guarded by is_module_scoped_language to avoid JS/TS interference.
crates/codegraph-core/src/extractors/kotlin.rs invoke calls on callable references now emit nothing instead of an unresolvable-dynamic synthetic node, matching WASM engine behaviour and eliminating a source of cross-engine divergence.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as buildFileCallEdges
    participant E as emitDirectCallEdgesForCall
    participant SE as seenCallEdges
    participant DZ as dynZeroEdgeRows
    participant ER as allEdgeRows

    Note over C: Call 1 — @Log() (dyn=0, no dynamicKind)
    C->>E: "isDynamic=0, hasDynamicKind=false"
    E->>SE: has(edgeKey)? → false
    E->>ER: "push([src,tgt,'calls',conf,0,'ts-native',null]) → idx=N"
    E->>SE: add(edgeKey)
    E->>DZ: set(edgeKey, N)

    Note over C: Call 2 — @Log (dyn=1, dynamicKind='reflection')
    C->>E: "isDynamic=1, hasDynamicKind=true"
    E->>SE: has(edgeKey)? → true
    E->>DZ: get(edgeKey)? → N
    E->>ER: "row[N][4] = 1  (upgrade dyn 0→1)"
    E->>DZ: delete(edgeKey)
    Note over ER: Edge now dyn=1 ✓
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"}}}%%
sequenceDiagram
    participant C as buildFileCallEdges
    participant E as emitDirectCallEdgesForCall
    participant SE as seenCallEdges
    participant DZ as dynZeroEdgeRows
    participant ER as allEdgeRows

    Note over C: Call 1 — @Log() (dyn=0, no dynamicKind)
    C->>E: "isDynamic=0, hasDynamicKind=false"
    E->>SE: has(edgeKey)? → false
    E->>ER: "push([src,tgt,'calls',conf,0,'ts-native',null]) → idx=N"
    E->>SE: add(edgeKey)
    E->>DZ: set(edgeKey, N)

    Note over C: Call 2 — @Log (dyn=1, dynamicKind='reflection')
    C->>E: "isDynamic=1, hasDynamicKind=true"
    E->>SE: has(edgeKey)? → true
    E->>DZ: get(edgeKey)? → N
    E->>ER: "row[N][4] = 1  (upgrade dyn 0→1)"
    E->>DZ: delete(edgeKey)
    Note over ER: Edge now dyn=1 ✓
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(wasm): preserve dyn=1 when deduplica..." | Re-trigger Greptile

Comment on lines 1283 to 1294
@@ -1273,7 +1294,13 @@ function emitDirectCallEdgesForCall(
seenCallEdges.add(edgeKey);

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 PTS-promoted dyn=0 edge not tracked in dynZeroEdgeRows

When a PTS edge is upgraded in-place to a direct-call edge with dyn=0 (lines 1283–1294), the key is moved from ptsEdgeRows to seenCallEdges but is never written into dynZeroEdgeRows. If a subsequent dyn=1+dynamicKind call arrives for the same source/target pair, dynZeroEdgeRows.get(edgeKey) returns undefined and the upgrade to dyn=1 is silently skipped, leaving the edge as dyn=0. The decorator case described in the PR (both calls resolved through the direct-call path) won't hit this, but a target that first appears via a PTS alias and later via a bare decorator would remain misclassified.

Fix in Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Codegraph Impact Analysis

2 functions changed4 callers affected across 2 files

  • emitDirectCallEdgesForCall in src/domain/graph/builder/stages/build-edges.ts:1235 (3 transitive callers)
  • buildFileCallEdges in src/domain/graph/builder/stages/build-edges.ts:1545 (3 transitive callers)

@carlos-alm carlos-alm merged commit 5e93e17 into main Jun 21, 2026
35 checks passed
@carlos-alm carlos-alm deleted the fix/parity-1683-ts-decorator-dyn branch June 21, 2026 23:09
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 21, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Engine parity: dynamic-typescript decorator dyn flag mismatch (WASM emits dyn=0, native emits dyn=1 for bare @Log decorator)

1 participant