Skip to content

parity: WASM emits dyn=0 for identifier.call()/.apply()/.bind(), native still emits dyn=1 reflection #1778

Description

@carlos-alm

Summary

/parity audit (run 2026-07-04) found 3 fixtures diverging between the WASM and native engines, all with the same root cause: for identifier.call(...), identifier.apply(...), and identifier.bind(...) call sites, the WASM engine now emits dyn=0 (plain static call, no dynamic tag) while the native (Rust) engine still emits dyn=1 with dynamic_kind: "reflection". Confidence is 1 (fully resolved) on both sides — only the dynamic/dynamic_kind metadata differs.

This predates the current Titan forge/grind run — confirmed by building both engines at the pre-run merge-base commit (597ed1c3, origin/main) and reproducing the identical divergence counts (6/8/12 edge diffs). Filing per the parity skill's pre-existing-finding rule (file an issue, don't expand scope) rather than fixing inline.

Root cause

PR #1693 (e2ec3fb2, "fix(wasm): emit dyn=0 for f.call/bind alias calls", closes #1687) changed extractMemberExprCallInfo in src/extractors/javascript.ts to unconditionally drop the dynamic/dynamicKind flag for .call()/.apply()/.bind() when the receiver is a plain identifier:

// src/extractors/javascript.ts (current)
if (propText === 'call' || propText === 'apply' || propText === 'bind') {
  if (obj && obj.type === 'identifier') return { name: obj.text, line: callLine };
  ...

The PR's own comment claims this "keeps parity with the native Rust engine, which also resolves these as dyn=0" — but that is only true for the specific dedup scenario in issue #1687 (bind/bind.js: a direct f() call followed by f.call({}) to the same target, where native's seenEdges dedup naturally keeps the first dyn=0 edge). It is not true in general: the Rust extractor (crates/codegraph-core/src/extractors/javascript.rs:2546-2557) still unconditionally emits dynamic: Some(true), dynamic_kind: Some("reflection") for identifier.call/apply/bind, with no corresponding dedup collision:

if prop_text == "call" || prop_text == "apply" || prop_text == "bind" {
    if let Some(obj) = &obj {
        if obj.kind() == "identifier" {
            return Some(Call {
                name: node_text(obj, source).to_string(),
                line: call_line,
                dynamic: Some(true),
                dynamic_kind: Some("reflection".to_string()),
                ...

So the WASM-only fix overcorrected: it silences the reflection tag for every identifier-based .call/.apply/.bind, not just the narrow double-edge-emission case from #1687.

Reproduction

node scripts/parity-compare.mjs --langs dynamic-javascript,javascript,jelly-micro
=== dynamic-javascript: wasm vs native DIVERGED (0 node diffs, 6 edge diffs)
  [edge] [calls] reflection.js:runApply(function) -> reflection.js:greet(function) conf=1 dyn=0  wasm=1 native=0
  [edge] [calls] reflection.js:runApply(function) -> reflection.js:greet(function) conf=1 dyn=1  wasm=0 native=1
  ... (runCall, runInvokerCall — same pattern)

=== javascript: wasm vs native DIVERGED (0 node diffs, 8 edge diffs)
  [edge] [calls] bind-call-apply.js:bind-call-apply.js(file) -> bind-call-apply.js:greet(function) conf=1 dyn=0/1 wasm=1/0 native=0/1
  ... (runApply, runCall, runCallThis — same pattern)

=== jelly-micro: wasm vs native DIVERGED (0 node diffs, 12 edge diffs)
  [edge] [calls] call/call.js:call/call.js(file) -> call/call.js:f(function) conf=1 dyn=0/1 wasm=1/0 native=0/1
  [edge] [calls] fun/fun.js:baz*(function) -> fun/fun.js:bar(function) conf=1 dyn=0/1 wasm=1/0 native=0/1
  [edge] [calls] fun/fun.js:fun/fun.js(file) -> fun/fun.js:foo(function) conf=1 dyn=0/1 wasm=1/0 native=0/1

Minimal repro (no prior direct call to the same target — rules out the #1687 dedup scenario):

export function greet(name) { return `Hello, ${name}`; }
export function runCall(ctx) { return greet.call(ctx, 'world'); }

Suggested fix

Decide the correct semantic first, then make both engines match it:

  • If identifier.call/apply/bind should still be tagged dynamicKind: 'reflection' (informational, queryable via codegraph roles --dynamic, independent of confidence) — revert the WASM-side unconditional drop and instead fix the actual fix(parity): jelly-micro bind.js: WASM emits dyn=1 for f.call/bind aliases, native emits dyn=0 #1687 bug narrowly: only suppress the dynamic flag when the same (caller, callee) edge was already emitted as a direct dyn=0 call in the same scope (the dynZeroEdgeRows upgrade-path collision), not for every identifier receiver.
  • If plain-identifier .call/.apply/.bind should be treated as a fully static call with no reflection tag (current WASM behavior) — update the Rust extractor at crates/codegraph-core/src/extractors/javascript.rs:2546-2557 to match by dropping dynamic/dynamic_kind for the identifier-receiver branch, mirroring the TS side exactly.

Either way, add a fixture/unit test that pins a case with no prior direct call to the same target (like reflection.js above), so the dedup-collision fix and the general-case semantic don't get conflated again.

Verification

Confirmed pre-existing by building both engines (napi release build + codesign) at merge-base 597ed1c3 and re-running scripts/parity-compare.mjs --langs dynamic-javascript,javascript,jelly-micro — identical divergence (6/8/12 edge diffs) reproduces there, before any of the current Titan forge/grind commits.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions