diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 151a405e7..edac9ceed 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -4251,6 +4251,50 @@ mod tests { assert!(dynamic_calls.is_empty()); } + // ── #1778: .call/.apply/.bind reflection tagging (parity pin) ─────────── + // + // Pins the native extractor's classification of `.call/.apply/.bind` on both + // identifier and member-expression receivers as dynamic/reflection. This is + // the Option-A semantic from #1778: the WASM extractor previously stripped + // this tag for identifier receivers only, diverging from native. These tests + // guard against either engine's classification drifting again — the + // dedup-collision case that originally motivated the WASM regression (#1687) + // is a downstream build-edges.ts concern, not an extraction concern, so it is + // deliberately NOT re-tested here (see the JS-side pins in + // tests/integration for that). + + #[test] + fn call_on_identifier_receiver_tags_reflection() { + let s = parse_js("function test(ctx) { greet.call(ctx, 'world'); }"); + let c = s.calls.iter().find(|c| c.name == "greet").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + } + + #[test] + fn apply_on_identifier_receiver_tags_reflection() { + let s = parse_js("function test(ctx) { greet.apply(ctx, ['world']); }"); + let c = s.calls.iter().find(|c| c.name == "greet").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + } + + #[test] + fn bind_on_identifier_receiver_tags_reflection() { + let s = parse_js("var bound = greet.bind(ctx);"); + let c = s.calls.iter().find(|c| c.name == "greet").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + } + + #[test] + fn call_on_member_expression_receiver_tags_reflection() { + let s = parse_js("obj.method.call({});"); + let c = s.calls.iter().find(|c| c.name == "method").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + } + // ── #1771: object-literal value-ref extraction ────────────────────────── #[test] diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 64ae20356..9c5f5f7b8 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -67,6 +67,18 @@ import { getResolved, isBarrelFile, resolveBarrelExportCached } from './resolve- type EdgeRowTuple = [number, number, string, number, number, string | null, string | null]; // src tgt kind conf dyn technique dynamic_kind +/** + * Tracks a dyn=0 direct-call edge row so a later dynamicKind-tagged call to the + * same (caller, target) pair can decide whether to upgrade it in-place — see + * {@link emitDirectCallEdgesForCall}. `line` is the source line of the call that + * produced the row, used to detect out-of-source-order collection artifacts + * (bare decorators processed after call-expression matches in the query path). + */ +interface DynZeroEdgeEntry { + idx: number; + line: number; +} + interface NodeIdStmt { get(name: string, kind: string, file: string, line: number): { id: number } | undefined; } @@ -1388,8 +1400,12 @@ function resolveFallbackTargets( * - If a pts edge already exists for this pair, upgrades it in-place to * direct-call confidence and promotes to seenCallEdges. * - If a dyn=0 edge already exists and the incoming call has an explicit - * dynamicKind (e.g. 'reflection' for bare decorators), upgrades the - * existing row to dyn=1 in-place so the semantic classification wins. + * dynamicKind AND textually precedes the recorded dyn=0 call (e.g. a bare + * decorator `@Log` reordered after `@Log()` by the query path's + * query-then-walk collection — see buildFileCallEdges), upgrades the + * existing row to dyn=1 in-place so the earlier-in-source classification + * wins, matching what native's single-pass source-order walk produces + * natively. * - Otherwise records a new `calls` edge with `ts-native` technique. */ function emitDirectCallEdgesForCall( @@ -1398,11 +1414,12 @@ function emitDirectCallEdgesForCall( importedFrom: string | null | undefined, isDynamic: number, hasDynamicKind: boolean, + callLine: number, relPath: string, seenCallEdges: Set, ptsEdgeRows: Map, allEdgeRows: EdgeRowTuple[], - dynZeroEdgeRows?: Map, + dynZeroEdgeRows?: Map, ): void { // Sort targets by confidence descending before emitting edges. // For multi-target calls with duplicate (source_id, target_id) pairs the @@ -1425,14 +1442,30 @@ function emitDirectCallEdgesForCall( if (seenCallEdges.has(edgeKey)) { // Edge already emitted. If the incoming call carries an explicit semantic // dynamic classification (dynamicKind set — e.g. 'reflection' for bare - // decorators) and the existing edge was recorded with dyn=0, upgrade it - // in-place so the more specific classification wins. - // Generic dynamic=true without dynamicKind (alias/callback calls) does - // NOT override dyn=0 to avoid false positives on f.call/f.bind patterns. + // decorators or .call/.apply/.bind) and the existing edge was recorded + // with dyn=0, only upgrade it in-place when the incoming call's source + // line is EARLIER than the recorded dyn=0 call's line. + // + // Why line order, not just "hasDynamicKind": the query path collects + // calls in two phases — tree-sitter query matches (callfn_node/callmem_node, + // true source order) first, then a supplementary walk pass for constructs + // the query grammar can't capture (bare decorators, object-literal + // value-refs) appended AFTERWARD regardless of true position (#1683). + // A bare `@Log` at an earlier line can therefore reach this branch AFTER + // `@Log()` at a later line already recorded dyn=0 — upgrading is correct + // there because native's single-pass source-order walk would have seen + // `@Log` first and kept dyn=1. + // + // But `.call/.apply/.bind` calls (e.g. `f(); f.call({})`, #1687/#1778) are + // ordinary call_expressions collected in the SAME query phase as the + // direct call, so true source order is already preserved: when the + // dynamic-flavored call's line is LATER than the recorded dyn=0 call, it + // is genuinely a second, later reference to the same target — native's + // dedup (first-recorded-wins, no upgrade) drops it, so WASM must too. if (isDynamic === 1 && hasDynamicKind && dynZeroEdgeRows) { - const dynZeroIdx = dynZeroEdgeRows.get(edgeKey); - if (dynZeroIdx !== undefined) { - const row = allEdgeRows[dynZeroIdx]; + const dynZeroEntry = dynZeroEdgeRows.get(edgeKey); + if (dynZeroEntry !== undefined && callLine < dynZeroEntry.line) { + const row = allEdgeRows[dynZeroEntry.idx]; if (row) row[4] = 1; dynZeroEdgeRows.delete(edgeKey); } @@ -1456,10 +1489,12 @@ function emitDirectCallEdgesForCall( seenCallEdges.add(edgeKey); const newIdx = allEdgeRows.length; allEdgeRows.push([caller.id, t.id, 'calls', confidence, isDynamic, 'ts-native', null]); - // Track dyn=0 edges so a later dyn=1+dynamicKind call for the same pair - // can upgrade them (e.g. bare decorator after call-expression decorator). + // Track dyn=0 edges (with their source line) so a later dyn=1+dynamicKind + // call for the same pair can decide whether to upgrade them — see the + // line-order comparison above (e.g. bare decorator reordered ahead of a + // call-expression decorator by the query path, #1683). if (isDynamic === 0 && dynZeroEdgeRows) { - dynZeroEdgeRows.set(edgeKey, newIdx); + dynZeroEdgeRows.set(edgeKey, { idx: newIdx, line: callLine }); } } } @@ -1729,11 +1764,13 @@ function buildFileCallEdges( // no longer tracked here. const ptsEdgeRows = new Map(); - // Tracks direct-call edges emitted with dyn=0 (edgeKey → allEdgeRows index). - // When a later call to the same target has dyn=1 (e.g. a bare decorator `@Log` - // processed after the call-expression `@Log()` in the query path), the existing - // dyn=0 row is upgraded in-place so the more specific dynamic classification wins. - const dynZeroEdgeRows = new Map(); + // Tracks direct-call edges emitted with dyn=0 (edgeKey → { row index, source line }). + // When a later call to the same target has dyn=1 and textually precedes the recorded + // call (e.g. a bare decorator `@Log` reordered after the call-expression `@Log()` by + // the query path, #1683), the existing dyn=0 row is upgraded in-place. See the line-order + // comparison in emitDirectCallEdgesForCall for why line order (not mere dynamicKind + // presence) gates the upgrade — this is also what keeps #1687/#1778 from regressing. + const dynZeroEdgeRows = new Map(); // Pre-compute the set of names that appear as lhs in fnRefBindings so that // case (c) of the pts gate below only fires for names that are genuine @@ -1766,6 +1803,7 @@ function buildFileCallEdges( importedFrom, isDynamic, !!call.dynamicKind, + call.line, relPath, seenCallEdges, ptsEdgeRows, diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 1875b99a9..61a4867b2 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -353,9 +353,11 @@ function dispatchQueryMatch( if (callfnInfo) calls.push(callfnInfo); calls.push(...extractCallbackReferenceCalls(c.callfn_node)); } else if (c.callmem_node) { - // extractCallInfo → extractMemberExprCallInfo applies the plain-identifier guard for - // .call/.apply/.bind: when the object is a bare identifier (e.g. `fn.call(ctx)`), - // the call is emitted as static (no dynamic flag), matching the walk path and native engine. + // extractCallInfo → extractMemberExprCallInfo tags .call/.apply/.bind (e.g. `fn.call(ctx)`) + // as dynamic/reflection regardless of receiver shape, matching the walk path and native + // engine (#1778). The #1687 dedup-collision case — the same target already reached by a + // direct call from the same caller in the same scope — is resolved downstream in + // build-edges.ts's emitDirectCallEdgesForCall, not here. const callInfo = extractCallInfo(c.callmem_fn!, c.callmem_node); if (callInfo) calls.push(callInfo); const cbDef = extractCallbackDefinition(c.callmem_node, c.callmem_fn); @@ -3357,16 +3359,18 @@ function extractMemberExprCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode) }; } - // .call()/.apply()/.bind() — this-rebinding; the wrapped function is the real callee. - // When the object is a plain identifier (e.g. `f.call({})`), the target is statically - // known so we emit a static call (no dynamic flag). This keeps parity with the native - // Rust engine, which also resolves these as dyn=0, and prevents the dynZeroEdgeRows - // upgrade path in emitDirectCallEdgesForCall from wrongly converting a dyn=0 edge - // (emitted by a prior direct `f()` call) to dyn=1. - // When the object is a member_expression (e.g. `obj.method.call({})`), we still mark - // it dynamic/reflection because the inner callee requires a second resolution hop. + // .call()/.apply()/.bind() — this-rebinding; the wrapped function is the real callee, but + // invoking it through .call/.apply/.bind is a genuinely reflective mechanism (a distinct + // invocation path from a plain `f()` call), so both identifier and member-expression + // receivers are tagged dynamic/reflection — matching the native Rust engine and preserving + // the informational value of the `reflection` DynamicKind (queryable via + // `codegraph roles --dynamic`; see ADR-002). This does NOT reintroduce #1687: that bug was + // a dedup-collision in build-edges.ts (a direct `f()` edge getting wrongly flipped to dyn=1 + // by a later `f.call()` to the same target in the same scope), fixed narrowly at the + // edge-emission layer in emitDirectCallEdgesForCall rather than by suppressing the tag here. if (propText === 'call' || propText === 'apply' || propText === 'bind') { - if (obj && obj.type === 'identifier') return { name: obj.text, line: callLine }; + if (obj && obj.type === 'identifier') + return { name: obj.text, line: callLine, dynamic: true, dynamicKind: 'reflection' }; if (obj && obj.type === 'member_expression') { const innerProp = obj.childForFieldName('property'); if (innerProp) diff --git a/tests/benchmarks/resolution/tracer/go-tracer.sh b/tests/benchmarks/resolution/tracer/go-tracer.sh index 459990a28..7a8536122 100644 --- a/tests/benchmarks/resolution/tracer/go-tracer.sh +++ b/tests/benchmarks/resolution/tracer/go-tracer.sh @@ -8,6 +8,8 @@ set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/tracer-common.sh" + FIXTURE_DIR="${1:-}" if [[ -z "$FIXTURE_DIR" ]]; then echo "Usage: go-tracer.sh " >&2 diff --git a/tests/engines/dynamic-call-ffi.test.ts b/tests/engines/dynamic-call-ffi.test.ts index 060563e82..9ea8c25eb 100644 --- a/tests/engines/dynamic-call-ffi.test.ts +++ b/tests/engines/dynamic-call-ffi.test.ts @@ -65,28 +65,31 @@ describe('dynamic call classification — dynamicKind and keyExpr fields', () => expect(c?.dynamic).toBe(true); }); - it('resolves fn.call(ctx) as a static call — no dynamic flag (#1687)', () => { - // `greet.call(ctx, 'world')` — plain-identifier receiver; target is statically known. - // We emit a static call (no dynamic, no dynamicKind) to match native Rust parity and - // prevent the dynZeroEdgeRows upgrade from promoting a prior dyn=0 edge to dyn=1. + it('tags fn.call(ctx) as reflection kind (#1778)', () => { + // `greet.call(ctx, 'world')` — plain-identifier receiver. The wrapped function + // is the real callee, but invoking it via .call is a genuinely reflective + // mechanism, so it's tagged dynamic/reflection — matching native Rust parity. + // (Option A of #1778: the dedup-collision bug this used to work around + // — #1687 — is now fixed narrowly at the build-edges.ts edge-emission layer + // instead of by suppressing this tag for every identifier receiver.) const out = parseJS(` function test(ctx) { greet.call(ctx, 'world'); } `); const c = out.calls.find((c) => c.name === 'greet'); expect(c).toBeDefined(); - expect(c?.dynamic).toBeFalsy(); - expect(c?.dynamicKind).toBeUndefined(); + expect(c?.dynamicKind).toBe('reflection'); + expect(c?.dynamic).toBe(true); }); - it('resolves fn.apply(ctx, args) as a static call — no dynamic flag (#1687)', () => { - // Same as .call(): plain-identifier receiver → static call. + it('tags fn.apply(ctx, args) as reflection kind (#1778)', () => { + // Same as .call(): plain-identifier receiver → dynamic/reflection. const out = parseJS(` function test(ctx) { greet.apply(ctx, ['world']); } `); const c = out.calls.find((c) => c.name === 'greet'); expect(c).toBeDefined(); - expect(c?.dynamic).toBeFalsy(); - expect(c?.dynamicKind).toBeUndefined(); + expect(c?.dynamicKind).toBe('reflection'); + expect(c?.dynamic).toBe(true); }); it('tags obj[a + b]() as unresolved-dynamic kind', () => { diff --git a/tests/integration/issue-1778-reflection-dynamic-kind-parity.test.ts b/tests/integration/issue-1778-reflection-dynamic-kind-parity.test.ts new file mode 100644 index 000000000..eb0fdca92 --- /dev/null +++ b/tests/integration/issue-1778-reflection-dynamic-kind-parity.test.ts @@ -0,0 +1,190 @@ +/** + * Engine-parity tests for `.call/.apply/.bind` reflection tagging and the + * narrow dedup-collision fix (#1778). + * + * BACKGROUND + * ────────── + * PR #1693 (closing #1687) made the WASM/TS extractor unconditionally drop + * `dynamic`/`dynamicKind` for `.call()/.apply()/.bind()` on identifier + * receivers (e.g. `f.call({})`), to fix a narrow dedup-collision bug: a + * direct `f()` call followed by `f.call({})` to the SAME target in the SAME + * scope was wrongly promoting the already-recorded dyn=0 edge to dyn=1 via + * the `dynZeroEdgeRows` upgrade path in `emitDirectCallEdgesForCall` + * (build-edges.ts). That fix overcorrected: it silenced the `reflection` + * DynamicKind for EVERY identifier-based `.call/.apply/.bind`, not just the + * narrow dedup-collision case — diverging from the native Rust engine, which + * never changed and still tags these calls `dynamic=true, + * dynamicKind='reflection'` unconditionally (see ADR-002). + * + * FIX (#1778, Option A) + * ────────────────────── + * 1. The WASM/TS extractor (`extractMemberExprCallInfo`) once again tags + * `.call/.apply/.bind` on identifier receivers as dynamic/reflection, + * matching native and preserving the informational value of the + * `reflection` DynamicKind (queryable via `codegraph roles --dynamic`). + * 2. The dedup-collision from #1687 is fixed narrowly, at the edge-emission + * layer: `emitDirectCallEdgesForCall`'s dyn=0 → dyn=1 upgrade now compares + * SOURCE LINES. It only upgrades when the incoming dynamicKind-tagged call + * textually PRECEDES the already-recorded dyn=0 call — which only happens + * when the query path's two-phase call collection (query matches, then a + * supplementary walk pass for constructs like bare decorators, #1683) + * reorders a genuinely-earlier call to arrive later. A `.call/.apply/.bind` + * call is an ordinary call_expression collected in the SAME phase as any + * prior direct call, so true source order is already preserved — when it + * arrives LATER than a recorded dyn=0 edge, it is a genuine second + * reference to the same target and must NOT flip the edge, matching + * native's plain first-recorded-wins dedup (no upgrade logic at all). + * + * These tests build a real graph with each engine and assert on the + * PERSISTED edge — the only thing that must match across engines. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; + +const ENGINES = ['wasm', 'native'] as const; + +interface CallEdgeRow { + source: string; + target: string; + confidence: number; + dynamic: number; +} + +function writeFixture(baseDir: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + const fullPath = path.join(baseDir, rel); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } +} + +function readCallEdgesTo(dbPath: string, targetName: string): CallEdgeRow[] { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS source, n2.name AS target, e.confidence, e.dynamic + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind = 'calls' AND n2.name = ? + ORDER BY n1.name`, + ) + .all(targetName) as CallEdgeRow[]; + } finally { + db.close(); + } +} + +let tmpDirs: string[] = []; +afterEach(() => { + for (const d of tmpDirs) { + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + /* ignore cleanup races */ + } + } + tmpDirs = []; +}); + +async function buildAndReadEdgesTo( + files: Record, + engine: 'wasm' | 'native', + targetName: string, +): Promise { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-1778-${engine}-`)); + tmpDirs.push(tmpDir); + writeFixture(tmpDir, files); + await buildGraph(tmpDir, { engine, incremental: false, skipRegistry: true }); + return readCallEdgesTo(path.join(tmpDir, '.codegraph', 'graph.db'), targetName); +} + +describe('#1778: .call/.apply/.bind reflection tagging — engine parity', () => { + it.each( + ENGINES, + )('%s: greet.call(ctx) with NO prior direct call resolves dyn=1 (minimal repro, no dedup collision)', async (engine) => { + // Exactly the issue's own minimal repro: no direct call to `greet` exists + // anywhere, so the dedup-collision path in emitDirectCallEdgesForCall never + // fires — this is the plain, uncomplicated case the #1693 fix wrongly broke. + const edges = await buildAndReadEdgesTo( + { + 'index.js': [ + 'export function greet(name) { return name; }', + "export function runCall(ctx) { return greet.call(ctx, 'world'); }", + '', + ].join('\n'), + }, + engine, + 'greet', + ); + expect(edges).toHaveLength(1); + expect(edges[0]).toMatchObject({ source: 'runCall', target: 'greet', confidence: 1 }); + expect(edges[0].dynamic).toBe(1); + }); + + it.each( + ENGINES, + )('%s: direct f() followed by f.call({}) to the same target dedups to a single dyn=0 edge (#1687)', async (engine) => { + // The original #1687 scenario: a direct call and a reflection-style call to + // the SAME target from the SAME caller/scope. Must collapse to ONE edge + // (no double-edge emission) and that edge must be dyn=0, matching native's + // plain first-recorded-wins dedup (the direct call is recorded first, in + // true source order, and the later reflection call must not flip it). + const edges = await buildAndReadEdgesTo( + { 'index.js': ['function f() {}', 'f();', 'f.call({});', ''].join('\n') }, + engine, + 'f', + ); + expect(edges).toHaveLength(1); + expect(edges[0].dynamic).toBe(0); + }); + + it.each( + ENGINES, + )('%s: f.call({}) followed by direct f() to the same target dedups to a single dyn=1 edge (reverse-order sanity)', async (engine) => { + // Mirror of the #1687 fixture with the two call sites swapped: the + // reflection call is now genuinely first in source order, so it should win + // the dedup and the later direct call must not downgrade it. + const edges = await buildAndReadEdgesTo( + { 'index.js': ['function f() {}', 'f.call({});', 'f();', ''].join('\n') }, + engine, + 'f', + ); + expect(edges).toHaveLength(1); + expect(edges[0].dynamic).toBe(1); + }); + + it.each( + ENGINES, + )('%s: bare decorator before call-expression decorator still upgrades to dyn=1 (#1683 regression guard)', async (engine) => { + // Regression guard for the ORIGINAL motivating case of the dynZeroEdgeRows + // upgrade path: the WASM query path collects `@Log()` (dyn=0) before the + // bare `@Log` (dyn=1) despite `@Log` appearing earlier in the source — the + // line-order comparison introduced by #1778's fix must still upgrade this + // to dyn=1, exactly as the pre-#1778 unconditional-upgrade logic did. + const edges = await buildAndReadEdgesTo( + { + 'index.ts': [ + 'export function Log(target: unknown): void {}', + '', + '@Log', + 'export class UserController {}', + '', + '@Log()', + 'export class OrderController {}', + '', + ].join('\n'), + }, + engine, + 'Log', + ); + expect(edges).toHaveLength(1); + expect(edges[0].dynamic).toBe(1); + }); +}); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 2998cb932..dd53cd001 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -201,17 +201,22 @@ describe('JavaScript parser', () => { expect(symbols.imports[0].reexport).toBe(true); }); - it('resolves .call()/.apply() on plain identifiers as static calls', () => { - // `fn.call(null, arg)` — plain-identifier receiver; target is statically known, - // so we emit a static call (dyn=0). This keeps parity with the native Rust engine - // and prevents the dynZeroEdgeRows upgrade from wrongly promoting dyn=0 → dyn=1. + it('tags .call()/.apply() on plain identifiers as dynamic/reflection (#1778)', () => { + // `fn.call(null, arg)` — plain-identifier receiver; the wrapped function is the + // real callee, but invoking it via .call/.apply is a genuinely reflective + // mechanism, so it's tagged dynamic/reflection — matching the native Rust engine + // (Option A of #1778; the WASM extractor previously stripped this tag for + // identifier receivers only, to work around a dedup-collision bug now fixed + // narrowly in build-edges.ts's emitDirectCallEdgesForCall, see #1687/#1778). const symbols = parseJS(`fn.call(null, arg); obj.apply(undefined, args);`); const fnCall = symbols.calls.find((c) => c.name === 'fn'); expect(fnCall).toBeDefined(); - expect(fnCall.dynamic).toBeFalsy(); + expect(fnCall.dynamic).toBe(true); + expect(fnCall.dynamicKind).toBe('reflection'); const objCall = symbols.calls.find((c) => c.name === 'obj'); expect(objCall).toBeDefined(); - expect(objCall.dynamic).toBeFalsy(); + expect(objCall.dynamic).toBe(true); + expect(objCall.dynamicKind).toBe('reflection'); }); it('captures receiver for method calls', () => { @@ -688,15 +693,21 @@ describe('JavaScript parser', () => { expect(fnCall.receiver).toBeUndefined(); }); - it('emits static call for .call/.apply/.bind on plain identifier (#1687)', () => { - // `f.call({})` where f is a plain identifier — target is statically known; - // must emit dyn=0 (no dynamic flag) to match native Rust engine parity. + it('tags f.call({}) as dynamic/reflection even alongside a direct f() call (#1687/#1778)', () => { + // `f(); f.call({})` — at the PARSER level, each call site is classified on its + // own terms: the direct `f()` call is static, and `f.call({})` is tagged + // dynamic/reflection regardless of the sibling direct call, matching native. + // The #1687 dedup-collision (collapsing these two call sites into a single + // graph edge without letting the reflection tag wrongly flip an + // already-recorded dyn=0 edge) is a build-edges.ts concern, verified at the + // graph level in tests/integration/issue-1778-reflection-dynamic-kind-parity.test.ts + // — not here, since the parser has no visibility into sibling call sites. const symbols = parseJS(`const f = function () {}.bind({}); f(); f.call({});`); const fCallCalls = symbols.calls.filter((c) => c.name === 'f'); - expect(fCallCalls.length).toBeGreaterThanOrEqual(1); - for (const c of fCallCalls) { - expect(c.dynamic).toBeFalsy(); // all f() / f.call({}) calls must be dyn=0 - } + expect(fCallCalls.length).toBe(2); + expect(fCallCalls[0].dynamic).toBeFalsy(); // f() — direct call + expect(fCallCalls[1].dynamic).toBe(true); // f.call({}) — reflection + expect(fCallCalls[1].dynamicKind).toBe('reflection'); }); it('still emits dynamic/reflection for .call on member-expression object', () => {