fix(wasm): preserve dyn=1 when deduplicating edges with same source/target/kind/conf#1688
Conversation
…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 SummaryThis PR fixes the WASM engine's edge-deduplication path so that a bare decorator (
Confidence Score: 4/5The 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
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 ✓
%%{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 ✓
Reviews (1): Last reviewed commit: "fix(wasm): preserve dyn=1 when deduplica..." | Re-trigger Greptile |
| @@ -1273,7 +1294,13 @@ function emitDirectCallEdgesForCall( | |||
| seenCallEdges.add(edgeKey); | |||
There was a problem hiding this comment.
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.
Codegraph Impact Analysis2 functions changed → 4 callers affected across 2 files
|
Summary
@Logdecorator (emitted asdyn=1, dynamicKind=reflectionbyhandleDecoratorinrunCollectorWalk) was being silently dropped when@Log()(a call-expression decorator,dyn=0) was already processed first and added toseenCallEdgesdynZeroEdgeRowsmap (parallel toptsEdgeRows) that tracks theallEdgeRowsindex of anydyn=0direct-call edge; when a subsequent call for the same pair hasdyn=1AND an explicitdynamicKind, the existing row is upgraded in-place todyn=1dynamicKindguard prevents genericdynamic=truealias/callback calls (f.call,f.bind) from incorrectly upgradingdyn=0edgesTest plan
node scripts/parity-compare.mjs --langs dynamic-typescript→PARITY OK(was DIVERGED)node scripts/parity-compare.mjs→ 41/42 fixtures pass (jelly-micro fails due to pre-existing bind.js issue filed as fix(parity): jelly-micro bind.js: WASM emits dyn=1 for f.call/bind aliases, native emits dyn=0 #1687, unrelated to this PR)npx vitest run tests/benchmarks/resolution/resolution-benchmark.test.ts→ 206/206 tests passCloses #1683