Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
2b6a00d
fix: scope codegraph batch complexity targets to file paths
carlos-alm Jul 5, 2026
b28051a
fix: exclude parameters and interface/type members from dead-role cla…
carlos-alm Jul 5, 2026
17fbcba
fix: credit import-type usages as exports consumers
carlos-alm Jul 5, 2026
26918bb
fix: prevent fn-impact/query crash when -f/--file is passed
carlos-alm Jul 5, 2026
be6432c
fix: correct exported-symbol detection for literal and object-literal…
carlos-alm Jul 5, 2026
4ead840
fix: exclude primitive type keywords from ast --kind string matching
carlos-alm Jul 5, 2026
f6326bf
fix: resolve call edges through renamed import specifiers
carlos-alm Jul 5, 2026
590f560
fix: couple file_hashes updates with edge regeneration in incremental…
carlos-alm Jul 5, 2026
d3ea4a4
fix: compare signature-change diffs using new-file line ranges
carlos-alm Jul 5, 2026
c306812
feat: add environment doctor check for stale native binary and missin…
carlos-alm Jul 5, 2026
8bb5893
fix: eliminate non-deterministic ordering in community detection
carlos-alm Jul 6, 2026
46037b1
fix: sync update-graph.sh hook extension allowlist with EXTENSIONS
carlos-alm Jul 6, 2026
6a84cf9
fix: recompute directory structure metrics for affected directories o…
carlos-alm Jul 6, 2026
8d936d1
refactor: register hardcoded execFileSync/execSync maxBuffer values i…
carlos-alm Jul 6, 2026
11c84be
fix: gate blast-radius check on newly introduced risk, not pre-existi…
carlos-alm Jul 6, 2026
963301a
fix: gate identifier-argument dynamic call edges on callback-acceptin…
carlos-alm Jul 6, 2026
3e8035d
fix: gate Array.from's callback arg by position, not just callee name
carlos-alm Jul 6, 2026
879635e
fix: scope reexportedSymbols to actually-named re-export specifiers
carlos-alm Jul 6, 2026
0fe9fc3
fix: stop CFG block/edge count from overriding AST-derived cyclomatic…
carlos-alm Jul 6, 2026
ccf3c19
fix: backfill edges.technique for incrementally-inserted calls edges
carlos-alm Jul 6, 2026
d5b1162
fix: wrap remote embedding JSON parse failure in EngineError
carlos-alm Jul 6, 2026
8971017
refactor: extract shared platform-default-path helper in config.ts
carlos-alm Jul 6, 2026
056c5e4
refactor: decompose loadConfig and related high-effort functions in c…
carlos-alm Jul 6, 2026
0738171
refactor: decompose findDbPath and openRepo in db/connection.ts (docs…
carlos-alm Jul 6, 2026
6f2423c
refactor: dedupe busy/locked error detection into isBusyOrLockedError
carlos-alm Jul 6, 2026
4c02378
fix: log statSync failures in findDbPath instead of silently swallowi…
carlos-alm Jul 6, 2026
0698e16
test: add direct unit coverage for closeDbPair/closeDbPairDeferred/cl…
carlos-alm Jul 6, 2026
4ac3b99
fix: correct blast-radius/fn-impact computation for line-shifted decl…
carlos-alm Jul 6, 2026
6767f09
feat: wire points-to solver max-iterations cap through DEFAULTS.analy…
carlos-alm Jul 6, 2026
5f6f30a
refactor: route console.log calls in domain/search through logger
carlos-alm Jul 6, 2026
b479318
refactor: reduce cyclomatic complexity of computeDeltaModularityDirected
carlos-alm Jul 6, 2026
c39ac40
refactor: unify impact-level rendering format between audit and fn-im…
carlos-alm Jul 6, 2026
067674f
refactor: dedupe computeSavings via pct helper and persist partial to…
carlos-alm Jul 6, 2026
f6807b4
fix: add main.rs driver to rust dynamic tracer fixture
carlos-alm Jul 6, 2026
1c07a54
fix: scope diff file-header detection to between-hunk positions
carlos-alm Jul 6, 2026
bf82aa2
fix: update titan-grind's dead-symbol script for current roles --json…
carlos-alm Jul 6, 2026
cd02d27
fix: thread configured busyTimeoutMs through remaining read-only quer…
carlos-alm Jul 6, 2026
980b5dc
fix: resolve computed string-literal keys in object-literal extractio…
carlos-alm Jul 6, 2026
4a873a3
fix: add same-class bare-call fallback to incremental rebuild path (d…
carlos-alm Jul 6, 2026
8f3348a
fix: unify Object.defineProperty accessor fallback kind-filtering bet…
carlos-alm Jul 6, 2026
acf804c
refactor: share chunked statement-cache primitive between builder and…
carlos-alm Jul 6, 2026
986c851
refactor: cache prepared statements in applyEdgeTechniquesAfterNative…
carlos-alm Jul 6, 2026
71e9174
fix: replace fixed-depth directory-proximity check with symmetric dis…
carlos-alm Jul 6, 2026
d2935cc
refactor: remove unreachable Partition delta-computation interface me…
carlos-alm Jul 6, 2026
6515186
fix: emit reference edges for function identifiers used as object-lit…
carlos-alm Jul 6, 2026
30c491a
refactor: unify call-ref and tests rendering format between audit and…
carlos-alm Jul 6, 2026
9db911b
fix: classify destructured bindings as constant, not function (docs c…
carlos-alm Jul 6, 2026
70559fc
refactor: adopt timeMedian in remaining benchmark timing loops
carlos-alm Jul 6, 2026
822e0eb
refactor: decompose runPerfBenchmarks in token-benchmark.ts
carlos-alm Jul 6, 2026
fcdfe7d
fix: attribute liveness to Lua functions assigned to global/builtin i…
carlos-alm Jul 6, 2026
ee6362c
refactor: extract shared sedi() helper for tracer scripts
carlos-alm Jul 6, 2026
d4e6212
fix: restore reflection dynamicKind for .call/.apply/.bind (docs chec…
carlos-alm Jul 6, 2026
92756af
fix: resolve merge conflicts with main
carlos-alm Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions crates/codegraph-core/src/extractors/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
74 changes: 56 additions & 18 deletions src/domain/graph/builder/stages/build-edges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(
Expand All @@ -1398,11 +1414,12 @@ function emitDirectCallEdgesForCall(
importedFrom: string | null | undefined,
isDynamic: number,
hasDynamicKind: boolean,
callLine: number,
relPath: string,
seenCallEdges: Set<string>,
ptsEdgeRows: Map<string, number>,
allEdgeRows: EdgeRowTuple[],
dynZeroEdgeRows?: Map<string, number>,
dynZeroEdgeRows?: Map<string, DynZeroEdgeEntry>,
): void {
// Sort targets by confidence descending before emitting edges.
// For multi-target calls with duplicate (source_id, target_id) pairs the
Expand All @@ -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) {

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 Same-line upgrade gap: the strict < guard means two constructs that refer to the same target on the same source line will never upgrade a pre-recorded dyn=0 edge to dyn=1, even when the dynamicKind-tagged one is textually first on that line. The most realistic trigger is a bare decorator and a call-expression decorator concatenated on the same line (e.g. @Log @Log() class Foo {}), which the WASM walk pass still delivers out-of-source-order (walk arrives after query phase regardless of line). Native's single-pass walk would emit dyn=1; WASM would not upgrade and emits dyn=0. Consider <= to close this parity gap without reintroducing #1687.call/.apply/.bind always arrive at an equal or later line than a prior direct call, never an earlier one.

Suggested change
if (dynZeroEntry !== undefined && callLine < dynZeroEntry.line) {
if (dynZeroEntry !== undefined && callLine <= dynZeroEntry.line) {

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the same-line decorator gap — but <= isn't safe here: it would also allow f(); f.call({}); written on a single line to re-upgrade, since .call/.apply/.bind are collected in the same query phase as the direct call (not walk-phase-reordered like decorators), so N <= N would reintroduce #1687 for that case. Call.line only has line granularity, so neither < nor <= alone can distinguish the two same-line scenarios. Filed #2029 to track the real fix (needs either column granularity or a walk-phase/query-phase discriminator) — out of scope for this PR since it needs a structural change, not addressed here.

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.

You're right — I missed that same-line f(); f.call({}) would hit N <= N and reintroduce #1687, since those are query-phase calls where line order is preserved but column-level position is what actually distinguishes them. The two same-line scenarios (walk-reordered bare decorator vs. same-phase .call) are indistinguishable at line granularity alone, so neither < nor <= can correctly cover both. The < guard is the right conservative choice here and #2029 is the correct place to resolve it with column or phase discriminator information. Happy to close this out.

const row = allEdgeRows[dynZeroEntry.idx];
if (row) row[4] = 1;
dynZeroEdgeRows.delete(edgeKey);
}
Expand All @@ -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 });
}
}
}
Expand Down Expand Up @@ -1729,11 +1764,13 @@ function buildFileCallEdges(
// no longer tracked here.
const ptsEdgeRows = new Map<string, number>();

// 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<string, number>();
// 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<string, DynZeroEdgeEntry>();

// 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
Expand Down Expand Up @@ -1766,6 +1803,7 @@ function buildFileCallEdges(
importedFrom,
isDynamic,
!!call.dynamicKind,
call.line,
relPath,
seenCallEdges,
ptsEdgeRows,
Expand Down
28 changes: 16 additions & 12 deletions src/extractors/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions tests/benchmarks/resolution/tracer/go-tracer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <fixture-dir>" >&2
Expand Down
23 changes: 13 additions & 10 deletions tests/engines/dynamic-call-ffi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading