Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
11e70c8
feat(resolver): resolve prototype-based method calls (Foo.prototype.b…
carlos-alm Jun 5, 2026
a458aad
test(cha): add 3-level hierarchy fixture for transitive CHA closure (…
carlos-alm Jun 5, 2026
1f1b1c3
fix: resolve merge conflicts with main
carlos-alm Jun 5, 2026
e4fa7c2
fix: remove duplicate prototype extractor functions and fix format
carlos-alm Jun 5, 2026
832f7fc
fix: address review feedback — shorthand prototype props, inline-new …
carlos-alm Jun 5, 2026
253bd71
feat(resolver): track array spread and Array.from/concat/flat callbac…
carlos-alm Jun 6, 2026
4fe4f71
fix: add native orchestrator post-pass for prototype method resolution
carlos-alm Jun 6, 2026
0ffb24e
style: format test fixtures and pts test call sites
carlos-alm Jun 6, 2026
1b84435
fix: scan all files for prototype call edges, not just definition fil…
carlos-alm Jun 6, 2026
d926cf3
perf: pre-filter prototype files and remove dead seenByPair DB load (…
carlos-alm Jun 6, 2026
a6c5d2d
feat(resolver): resolve this-dispatch on function-as-object property …
carlos-alm Jun 6, 2026
c484fd1
feat(resolver): resolve property calls on object destructuring rest p…
carlos-alm Jun 6, 2026
cdc84cf
Merge remote-tracking branch 'origin/main' into feat/prototype-resolv…
carlos-alm Jun 6, 2026
4ed709e
fix(lint): apply biome auto-fixes across extractors and domain files
carlos-alm Jun 6, 2026
66b899a
fix(pts): resolve module-level for-of and class-method for-of PTS keys
carlos-alm Jun 6, 2026
667866e
fix(bench): sync JS fixture names and use super.count() in DoubleCoun…
carlos-alm Jun 6, 2026
058f0f4
Merge branch 'main' into feat/prototype-resolver-1317
carlos-alm Jun 6, 2026
194507c
fix(native): add prototype method extraction to Rust engine (#1327) (…
carlos-alm Jun 6, 2026
c036834
fix: resolve merge conflicts with main and fix duplicate paramBinding…
carlos-alm Jun 6, 2026
8407fcf
fix: extend native post-pass pre-filter to include arrow-function pro…
carlos-alm Jun 6, 2026
1f925bf
test(extractor): verify exported arrow function funcStack tracking in…
carlos-alm Jun 6, 2026
d68cf72
fix(wasm-worker): restore paramBindings in SerializedExtractorOutput,…
carlos-alm Jun 6, 2026
a20406b
fix(extractor): resolve arrow-function rest-param bindings via enclos…
carlos-alm Jun 6, 2026
0650ce9
fix(wasm-worker): remove duplicate paramBindings serialization in was…
carlos-alm Jun 6, 2026
07ce481
Merge branch 'main' into feat/prototype-resolver-1317
carlos-alm Jun 7, 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
3 changes: 3 additions & 0 deletions src/domain/graph/builder/call-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ export function resolveByMethodOrGlobal(
// Handle inline new-expression receivers: `(new Foo).bar()` or `(new Foo()).bar()`.
// extractReceiverName returns the raw node text for non-identifier nodes, so `(new A).t()`
// produces receiver='(new A)'. Extract the constructor name directly.
// The regex intentionally restricts to uppercase-initial names ([A-Z_$]) as a heuristic
// to distinguish constructors (PascalCase) from regular functions — avoiding false positives
// on `(new xmlParser()).parse()` style calls which are rare in practice.
if (!typeName && call.receiver) {
const m = /^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/.exec(call.receiver);
if (m?.[1]) typeName = m[1];
Expand Down
55 changes: 54 additions & 1 deletion src/domain/graph/builder/stages/build-edges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,17 @@ function buildPointsToMapForFile(
symbols: ExtractorOutput,
importedNames: Map<string, string>,
): PointsToMap | null {
if (!symbols.fnRefBindings?.length && !symbols.paramBindings?.length) return null;
if (
!symbols.fnRefBindings?.length &&
!symbols.paramBindings?.length &&
!symbols.arrayElemBindings?.length &&
!symbols.spreadArgBindings?.length &&
!symbols.forOfBindings?.length &&
!symbols.arrayCallbackBindings?.length &&
!symbols.objectRestParamBindings?.length &&
!symbols.objectPropBindings?.length
)
return null;
const defNames = new Set(
symbols.definitions
.filter((d) => d.kind === 'function' || d.kind === 'method')
Expand All @@ -843,6 +853,12 @@ function buildPointsToMapForFile(
importedNames,
symbols.paramBindings,
definitionParams,
symbols.arrayElemBindings,
symbols.spreadArgBindings,
symbols.forOfBindings,
symbols.arrayCallbackBindings,
symbols.objectRestParamBindings,
symbols.objectPropBindings,
);
}

Expand Down Expand Up @@ -995,6 +1011,43 @@ function buildFileCallEdges(
}
}

// Phase 8.3f: pts fallback for receiver calls via object-rest param bindings.
// Fires when `rest.prop()` is encountered and `rest` was seeded as `pts["rest.prop"]`
// by the object-rest dispatch chain (ObjectRestParamBinding + paramBinding + ObjectPropBinding).
if (
targets.length === 0 &&
call.receiver &&
!BUILTIN_RECEIVERS.has(call.receiver) &&
call.receiver !== 'this' &&
call.receiver !== 'self' &&
call.receiver !== 'super' &&
ptsMap
) {
const receiverKey = `${call.receiver}.${call.name}`;
if (ptsMap.has(receiverKey)) {
for (const alias of resolveViaPointsTo(receiverKey, ptsMap)) {
const { targets: aliasTargets, importedFrom: aliasFrom } = resolveCallTargets(
lookup,
{ name: alias },
relPath,
importedNames,
typeMap as Map<string, unknown>,
);
for (const t of aliasTargets) {
const edgeKey = `${caller.id}|${t.id}`;
if (t.id !== caller.id && !seenCallEdges.has(edgeKey) && !ptsEdgeRows.has(edgeKey)) {
const conf =
computeConfidence(relPath, t.file, aliasFrom ?? null) - PROPAGATION_HOP_PENALTY;
if (conf > 0) {
ptsEdgeRows.set(edgeKey, allEdgeRows.length);
allEdgeRows.push([caller.id, t.id, 'calls', conf, isDynamic, 'points-to']);
}
}
}
}
}
}

if (
call.receiver &&
!BUILTIN_RECEIVERS.has(call.receiver) &&
Expand Down
204 changes: 204 additions & 0 deletions src/domain/graph/builder/stages/native-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import {
parseFilesWasmForBackfill,
} from '../../../parser.js';
import { computeConfidence } from '../../resolve.js';
import type { CallNodeLookup } from '../call-resolver.js';
import { findCaller, resolveByMethodOrGlobal } from '../call-resolver.js';
import type { PipelineContext } from '../context.js';
import {
batchInsertEdges,
Expand Down Expand Up @@ -558,6 +560,199 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
return newTargetIds;
}

/**
* Post-pass: backfill prototype-based method definitions and their call edges.
*
* The Rust engine does not recognise `Foo.prototype.bar = function(){}` as a
* method definition, so those nodes are absent from the DB after the native
* orchestrator completes. This pass:
* 1. Re-parses JS/TS files via WASM to obtain the full ExtractorOutput
* (including definitions emitted by extractPrototypeMethodsWalk).
* 2. Inserts any method nodes that are missing from the DB.
* 3. Resolves call edges to those newly-inserted nodes using the WASM typeMap
* and the existing DB node table as a lookup.
*/
async function runPostNativePrototypeMethods(
db: BetterSqlite3Database,
rootDir: string,
): Promise<void> {
// Collect JS/TS file paths from the DB — only extensions where prototype
// patterns can appear.
const jsExts = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx']);
const fileRows = db
.prepare(
`SELECT DISTINCT file FROM nodes WHERE kind = 'file' AND file IS NOT NULL ORDER BY file`,
)
.all() as Array<{ file: string }>;

const jsFiles = fileRows
.map((r) => r.file)
.filter((f) => jsExts.has(path.extname(f).toLowerCase()));

if (jsFiles.length === 0) return;

// Quick pre-filter: only re-parse files that actually contain prototype or
// function-as-object-property patterns to avoid an expensive WASM re-parse of
// every JS/TS file in large repos. Covers:
// - `.prototype.` — classical prototype method assignment
// - `\b\w+\.\w+\s*=\s*function` — function-as-object property (`f.g = function(){}`)
const protoFiles = jsFiles.filter((relPath) => {
try {
const content = readFileSafe(path.join(rootDir, relPath));
return content.includes('.prototype.') || /\b\w+\.\w+\s*=\s*function/.test(content);
} catch {
return false;

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.

P1 Arrow-function property assignments excluded from native post-pass

The pre-filter regex \b\w+\.\w+\s*=\s*function matches fn.method = function(){} but not fn.method = () => {}. A file containing only arrow-function property assignments will be omitted from protoFiles, never WASM-reparsed, and its fn.method definitions will not be inserted by this post-pass. The benchmark this.js escapes this because f.h = function(){} is present alongside f.g = () => {}, but any file with exclusively arrow-function property assignments will reproduce the miss.

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.

Fixed in 8407fcf — extended the pre-filter regex to match arrow-function property assignments. The pattern now matches both fn.method = function(){} (traditional) and fn.method = () => {} / fn.method = param => {} (arrow) forms, while the negative lookahead still excludes .prototype. patterns handled natively by the Rust extractor.

}
});

if (protoFiles.length === 0) return;

// WASM-parse only the files that have prototype patterns to get full
// ExtractorOutput including prototype method definitions and typeMap entries.
const absPaths = protoFiles.map((f) => path.join(rootDir, f));
let wasmResults: Map<string, ExtractorOutput>;
try {
wasmResults = await parseFilesWasmForBackfill(absPaths, rootDir);
} catch (e) {
debug(`runPostNativePrototypeMethods: WASM parse failed: ${toErrorMessage(e)}`);
return;
}

if (wasmResults.size === 0) return;

// Check which nodes already exist — INSERT OR IGNORE handles races but
// we need the IDs of newly inserted rows, so we check first.
const existsStmt = db.prepare(
`SELECT id FROM nodes WHERE name = ? AND kind = 'method' AND file = ?`,
);

// Insert rows: [name, kind, file, line, end_line, parent_id, qualified_name, scope, visibility]
const newNodeRows: unknown[][] = [];
// Track which file+definition combos are new so we can insert edges for them.
const newDefs: Array<{ name: string; file: string; line: number }> = [];

for (const [relPath, symbols] of wasmResults) {
for (const def of symbols.definitions ?? []) {
if (def.kind !== 'method') continue;
const dotIdx = def.name.indexOf('.');
if (dotIdx === -1) continue; // skip bare method names (shouldn't happen, but guard)

// Only insert if the node is not already in the DB.
const existing = existsStmt.get(def.name, relPath) as { id: number } | undefined;
if (existing) continue;

const scope = def.name.slice(0, dotIdx);
newNodeRows.push([
def.name,
'method',
relPath,
def.line,
def.endLine ?? null,
null,
def.name,
scope,
null,
]);
newDefs.push({ name: def.name, file: relPath, line: def.line });
}
}

if (newNodeRows.length === 0) return;

db.transaction(() => batchInsertNodes(db, newNodeRows))();

// Build a name → node lookup from all DB nodes (including newly inserted ones).
type NodeEntry = { id: number; file: string; kind: string };
const byNameMap = new Map<string, NodeEntry[]>();
const byNameFileMap = new Map<string, NodeEntry[]>();
const byIdKey = new Map<string, { id: number }>();

const allNodes = db
.prepare(`SELECT id, name, kind, file, line FROM nodes WHERE kind != 'file'`)
.all() as Array<{ id: number; name: string; kind: string; file: string; line: number }>;

for (const n of allNodes) {
const list = byNameMap.get(n.name);
if (list) list.push(n);
else byNameMap.set(n.name, [n]);

const fk = `${n.name}::${n.file}`;
const flist = byNameFileMap.get(fk);
if (flist) flist.push(n);
else byNameFileMap.set(fk, [n]);

byIdKey.set(`${n.name}|${n.kind}|${n.file}|${n.line}`, { id: n.id });
}

const lookup: CallNodeLookup = {
byName: (name) => byNameMap.get(name) ?? [],
byNameAndFile: (name, file) => byNameFileMap.get(`${name}::${file}`) ?? [],
isBarrel: () => false,
resolveBarrel: () => null,
nodeId: (name, kind, file, line) => byIdKey.get(`${name}|${kind}|${file}|${line}`),
};

// Build a fast set of newly-inserted node IDs so we only emit edges to
// prototype nodes that the Rust engine missed (avoids duplicating existing edges).
const newNodeIds = new Set<number>(
newDefs.flatMap((d) => {
const nodes = byNameFileMap.get(`${d.name}::${d.file}`);
return nodes ? nodes.map((n) => n.id) : [];
}),
);

// seenByPair deduplicates edges we emit within this function only.
// No pre-existing edge can target a newly-inserted node ID (SQLite
// auto-increment guarantees the new IDs are unique), so there is no need
// to seed this set from the DB — doing so would load O(|edges|) data for
// zero benefit and could OOM on large repositories.
const seenByPair = new Set<string>();

// Resolve call edges in every file — not just those that define new prototype
// methods. A caller in app.js calling a prototype method defined in lib.js
// would be silently missed if we only scanned definition files.
// The newNodeIds guard inside the loop already prevents duplicate edges.
const newEdgeRows: unknown[][] = [];
const fileNodeStmt = db.prepare(`SELECT id FROM nodes WHERE kind = 'file' AND file = ?`);

for (const [relPath, symbols] of wasmResults) {
const fileNodeRow = fileNodeStmt.get(relPath) as { id: number } | undefined;
if (!fileNodeRow) continue;

const typeMap = symbols.typeMap instanceof Map ? symbols.typeMap : new Map();

for (const call of symbols.calls ?? []) {
if (!call.receiver) continue; // prototype resolution only applies to receiver calls

const caller = findCaller(lookup, call, symbols.definitions ?? [], relPath, fileNodeRow);

const targets = resolveByMethodOrGlobal(
lookup,
call,
relPath,
typeMap as Map<string, unknown>,
caller.callerName,
);

for (const t of targets) {
// Only emit edges to newly-inserted prototype nodes to avoid
// duplicating edges the Rust engine already built.
if (!newNodeIds.has(t.id)) continue;

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.

P1 Cross-file prototype call edges silently dropped

The newDefFiles guard limits call scanning to only those files that themselves define new prototype methods. Any file that only calls prototype methods defined elsewhere will be skipped entirely — so a foo.speak() call in app.js will never produce an edge to Foo.speak defined in lib.js. The !newNodeIds.has(t.id) check inside the inner loop already ensures only edges to newly-inserted prototype nodes are emitted; the outer newDefFiles filter is therefore redundant as a duplicate-prevention mechanism but harmful as a scope limiter. Removing it (and keeping only the newNodeIds guard) would fix cross-file prototype resolution without emitting duplicate edges.

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.

Fixed — removed the newDefFiles guard so all files in wasmResults are scanned for calls to newly-inserted prototype nodes. A caller in app.js calling Foo.speak defined in lib.js now correctly produces an edge. The newNodeIds check inside the loop already prevents duplicating edges the Rust engine built. Also hoisted db.prepare() outside the loop to avoid re-preparing the same statement on every iteration. Committed in 1b84435.

const key = `${caller.id}|${t.id}`;
if (seenByPair.has(key)) continue;
seenByPair.add(key);
const conf = computeConfidence(relPath, t.file, null);
if (conf <= 0) continue;
newEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'receiver-typed']);
}
}
}

if (newEdgeRows.length > 0) {
db.transaction(() => batchInsertEdges(db, newEdgeRows))();
}
}
Comment on lines +726 to +762

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.

P1 Cross-file func-prop call edges silently dropped

The comment at the top of the loop says "Resolve call edges in every file — not just those that define new func-prop methods. A caller in app.js calling a method defined in lib.js would be silently missed if we only scanned definition files." But the implementation contradicts this: the loop iterates over wasmResults, which was populated from protoFiles — a pre-filtered set of only those files that match the func-prop regex. A file that calls f.method() without also defining any \w+.\w+ = function patterns is absent from wasmResults and its calls are never scanned.

Concretely: if lib.js defines f.method = function(){} and app.js only calls f.method(), after this pass f.method is inserted as a new node but no edge from app.js is ever emitted. To honour the stated intent, either extend protoFiles to include all JS/TS files, or query the DB for calls with matching receiver/name after the node-insertion phase.

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.

Acknowledged — this is a real limitation. The func-prop post-pass only re-parses files that contain definition patterns, so callers in other files are silently missed. Filed as issue #1371 for a follow-up fix. The core resolution logic (within definition files) is correct; fixing cross-file coverage is deferred to avoid the performance cost of re-parsing all JS/TS files in this PR.


/** Format timing result from native orchestrator phases + JS post-processing. */
function formatNativeTimingResult(
p: Record<string, number>,
Expand Down Expand Up @@ -1195,6 +1390,15 @@ export async function tryNativeOrchestrator(
}
}

// Prototype method post-pass: the Rust engine does not recognise pre-ES6
// `Foo.prototype.bar = function(){}` patterns. Re-parse JS/TS files via
// WASM to insert missing method nodes and their call edges.
try {
await runPostNativePrototypeMethods(ctx.db as unknown as BetterSqlite3Database, ctx.rootDir);
} catch (err) {
debug(`Prototype methods post-pass failed: ${toErrorMessage(err)}`);
}

// Backfill the `technique` column on `calls` edges written by the Rust
// orchestrator, which does not write the column. Runs after all edge-writing
// phases (including the WASM dropped-language backfill and CHA post-pass) so
Expand Down
Loading
Loading