Skip to content

Commit 4fe4f71

Browse files
committed
fix: add native orchestrator post-pass for prototype method resolution
The Rust engine does not recognise Foo.prototype.bar = function(){} as a method definition, so prototype-based method nodes were absent from the DB when the native orchestrator ran. This causes the integration tests to fail on all platforms where the native addon is available. Fix two issues: 1. Remove duplicate extractPrototypeMethodsWalk call in extractSymbolsQuery that was inflating the definitions array (identified by Greptile) 2. Add runPostNativePrototypeMethods post-pass to native-orchestrator.ts: - Re-parses JS/TS files via WASM after native build - Inserts any method nodes missing from the DB (prototype patterns) - Resolves and inserts call edges to those new nodes using the WASM typeMap and the call-resolver
1 parent 253bd71 commit 4fe4f71

3 files changed

Lines changed: 199 additions & 11 deletions

File tree

src/domain/graph/builder/stages/native-orchestrator.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ import {
4242
parseFilesWasmForBackfill,
4343
} from '../../../parser.js';
4444
import { computeConfidence } from '../../resolve.js';
45+
import type { CallNodeLookup } from '../call-resolver.js';
46+
import { findCaller, resolveByMethodOrGlobal } from '../call-resolver.js';
4547
import type { PipelineContext } from '../context.js';
4648
import {
4749
batchInsertEdges,
@@ -558,6 +560,183 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
558560
return newTargetIds;
559561
}
560562

563+
/**
564+
* Post-pass: backfill prototype-based method definitions and their call edges.
565+
*
566+
* The Rust engine does not recognise `Foo.prototype.bar = function(){}` as a
567+
* method definition, so those nodes are absent from the DB after the native
568+
* orchestrator completes. This pass:
569+
* 1. Re-parses JS/TS files via WASM to obtain the full ExtractorOutput
570+
* (including definitions emitted by extractPrototypeMethodsWalk).
571+
* 2. Inserts any method nodes that are missing from the DB.
572+
* 3. Resolves call edges to those newly-inserted nodes using the WASM typeMap
573+
* and the existing DB node table as a lookup.
574+
*/
575+
async function runPostNativePrototypeMethods(
576+
db: BetterSqlite3Database,
577+
rootDir: string,
578+
): Promise<void> {
579+
// Collect JS/TS file paths from the DB — only extensions where prototype
580+
// patterns can appear.
581+
const jsExts = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx']);
582+
const fileRows = db
583+
.prepare(
584+
`SELECT DISTINCT file FROM nodes WHERE kind = 'file' AND file IS NOT NULL ORDER BY file`,
585+
)
586+
.all() as Array<{ file: string }>;
587+
588+
const jsFiles = fileRows
589+
.map((r) => r.file)
590+
.filter((f) => jsExts.has(path.extname(f).toLowerCase()));
591+
592+
if (jsFiles.length === 0) return;
593+
594+
// WASM-parse all JS/TS files to get full ExtractorOutput including
595+
// prototype method definitions and typeMap entries.
596+
const absPaths = jsFiles.map((f) => path.join(rootDir, f));
597+
let wasmResults: Map<string, ExtractorOutput>;
598+
try {
599+
wasmResults = await parseFilesWasmForBackfill(absPaths, rootDir);
600+
} catch (e) {
601+
debug(`runPostNativePrototypeMethods: WASM parse failed: ${toErrorMessage(e)}`);
602+
return;
603+
}
604+
605+
if (wasmResults.size === 0) return;
606+
607+
// Check which nodes already exist — INSERT OR IGNORE handles races but
608+
// we need the IDs of newly inserted rows, so we check first.
609+
const existsStmt = db.prepare(
610+
`SELECT id FROM nodes WHERE name = ? AND kind = 'method' AND file = ?`,
611+
);
612+
613+
// Insert rows: [name, kind, file, line, end_line, parent_id, qualified_name, scope, visibility]
614+
const newNodeRows: unknown[][] = [];
615+
// Track which file+definition combos are new so we can insert edges for them.
616+
const newDefs: Array<{ name: string; file: string; line: number }> = [];
617+
618+
for (const [relPath, symbols] of wasmResults) {
619+
for (const def of symbols.definitions ?? []) {
620+
if (def.kind !== 'method') continue;
621+
const dotIdx = def.name.indexOf('.');
622+
if (dotIdx === -1) continue; // skip bare method names (shouldn't happen, but guard)
623+
624+
// Only insert if the node is not already in the DB.
625+
const existing = existsStmt.get(def.name, relPath) as { id: number } | undefined;
626+
if (existing) continue;
627+
628+
const scope = def.name.slice(0, dotIdx);
629+
newNodeRows.push([
630+
def.name,
631+
'method',
632+
relPath,
633+
def.line,
634+
def.endLine ?? null,
635+
null,
636+
def.name,
637+
scope,
638+
null,
639+
]);
640+
newDefs.push({ name: def.name, file: relPath, line: def.line });
641+
}
642+
}
643+
644+
if (newNodeRows.length === 0) return;
645+
646+
db.transaction(() => batchInsertNodes(db, newNodeRows))();
647+
648+
// Build a name → node lookup from all DB nodes (including newly inserted ones).
649+
type NodeEntry = { id: number; file: string; kind: string };
650+
const byNameMap = new Map<string, NodeEntry[]>();
651+
const byNameFileMap = new Map<string, NodeEntry[]>();
652+
const byIdKey = new Map<string, { id: number }>();
653+
654+
const allNodes = db
655+
.prepare(`SELECT id, name, kind, file, line FROM nodes WHERE kind != 'file'`)
656+
.all() as Array<{ id: number; name: string; kind: string; file: string; line: number }>;
657+
658+
for (const n of allNodes) {
659+
const list = byNameMap.get(n.name);
660+
if (list) list.push(n);
661+
else byNameMap.set(n.name, [n]);
662+
663+
const fk = `${n.name}::${n.file}`;
664+
const flist = byNameFileMap.get(fk);
665+
if (flist) flist.push(n);
666+
else byNameFileMap.set(fk, [n]);
667+
668+
byIdKey.set(`${n.name}|${n.kind}|${n.file}|${n.line}`, { id: n.id });
669+
}
670+
671+
const lookup: CallNodeLookup = {
672+
byName: (name) => byNameMap.get(name) ?? [],
673+
byNameAndFile: (name, file) => byNameFileMap.get(`${name}::${file}`) ?? [],
674+
isBarrel: () => false,
675+
resolveBarrel: () => null,
676+
nodeId: (name, kind, file, line) => byIdKey.get(`${name}|${kind}|${file}|${line}`),
677+
};
678+
679+
// Build a fast set of newly-inserted node IDs so we only emit edges to
680+
// prototype nodes that the Rust engine missed (avoids duplicating existing edges).
681+
const newNodeIds = new Set<number>(
682+
newDefs.flatMap((d) => {
683+
const nodes = byNameFileMap.get(`${d.name}::${d.file}`);
684+
return nodes ? nodes.map((n) => n.id) : [];
685+
}),
686+
);
687+
688+
// Seed seenByPair from existing call edges to avoid duplicates.
689+
const existingPairs = db
690+
.prepare(`SELECT source_id, target_id FROM edges WHERE kind = 'calls'`)
691+
.all() as Array<{ source_id: number; target_id: number }>;
692+
const seenByPair = new Set<string>(existingPairs.map((e) => `${e.source_id}|${e.target_id}`));
693+
694+
// For each file that produced new prototype nodes, resolve call edges.
695+
const newEdgeRows: unknown[][] = [];
696+
const newDefFiles = new Set(newDefs.map((d) => d.file));
697+
698+
for (const [relPath, symbols] of wasmResults) {
699+
if (!newDefFiles.has(relPath)) continue;
700+
701+
const fileNodeRow = db
702+
.prepare(`SELECT id FROM nodes WHERE kind = 'file' AND file = ?`)
703+
.get(relPath) as { id: number } | undefined;
704+
if (!fileNodeRow) continue;
705+
706+
const typeMap = symbols.typeMap instanceof Map ? symbols.typeMap : new Map();
707+
708+
for (const call of symbols.calls ?? []) {
709+
if (!call.receiver) continue; // prototype resolution only applies to receiver calls
710+
711+
const caller = findCaller(lookup, call, symbols.definitions ?? [], relPath, fileNodeRow);
712+
713+
const targets = resolveByMethodOrGlobal(
714+
lookup,
715+
call,
716+
relPath,
717+
typeMap as Map<string, unknown>,
718+
caller.callerName,
719+
);
720+
721+
for (const t of targets) {
722+
// Only emit edges to newly-inserted prototype nodes to avoid
723+
// duplicating edges the Rust engine already built.
724+
if (!newNodeIds.has(t.id)) continue;
725+
const key = `${caller.id}|${t.id}`;
726+
if (seenByPair.has(key)) continue;
727+
seenByPair.add(key);
728+
const conf = computeConfidence(relPath, t.file, null);
729+
if (conf <= 0) continue;
730+
newEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'receiver-typed']);
731+
}
732+
}
733+
}
734+
735+
if (newEdgeRows.length > 0) {
736+
db.transaction(() => batchInsertEdges(db, newEdgeRows))();
737+
}
738+
}
739+
561740
/** Format timing result from native orchestrator phases + JS post-processing. */
562741
function formatNativeTimingResult(
563742
p: Record<string, number>,
@@ -1195,6 +1374,15 @@ export async function tryNativeOrchestrator(
11951374
}
11961375
}
11971376

1377+
// Prototype method post-pass: the Rust engine does not recognise pre-ES6
1378+
// `Foo.prototype.bar = function(){}` patterns. Re-parse JS/TS files via
1379+
// WASM to insert missing method nodes and their call edges.
1380+
try {
1381+
await runPostNativePrototypeMethods(ctx.db as unknown as BetterSqlite3Database, ctx.rootDir);
1382+
} catch (err) {
1383+
debug(`Prototype methods post-pass failed: ${toErrorMessage(err)}`);
1384+
}
1385+
11981386
// Backfill the `technique` column on `calls` edges written by the Rust
11991387
// orchestrator, which does not write the column. Runs after all edge-writing
12001388
// phases (including the WASM dropped-language backfill and CHA post-pass) so

src/domain/graph/resolver/points-to.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,14 @@
1919
* that build-edges.ts already builds per file is the cross-module link — if
2020
* a variable aliases an imported name, resolveCallTargets follows it).
2121
*/
22-
import type { ArrayCallbackBinding, ArrayElemBinding, FnRefBinding, ForOfBinding, ParamBinding, SpreadArgBinding } from '../../../types.js';
22+
import type {
23+
ArrayCallbackBinding,
24+
ArrayElemBinding,
25+
FnRefBinding,
26+
ForOfBinding,
27+
ParamBinding,
28+
SpreadArgBinding,
29+
} from '../../../types.js';
2330

2431
export type PointsToMap = Map<string, Set<string>>;
2532

@@ -127,7 +134,7 @@ export function buildPointsToMap(
127134
if (spreadArgBindings && spreadArgBindings.length > 0 && definitionParams) {
128135
// Build a per-array index count from arrayElemBindings for precise per-index constraints.
129136
const arrayMaxIndex = new Map<string, number>();
130-
for (const { arrayName, index } of (arrayElemBindings ?? [])) {
137+
for (const { arrayName, index } of arrayElemBindings ?? []) {
131138
const cur = arrayMaxIndex.get(arrayName) ?? -1;
132139
if (index > cur) arrayMaxIndex.set(arrayName, index);
133140
}

src/extractors/javascript.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -372,9 +372,6 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
372372
// Extract definitions from destructured bindings (query patterns don't match object_pattern)
373373
extractDestructuredBindingsWalk(tree.rootNode, definitions);
374374

375-
// Pre-ES6 prototype methods: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }`
376-
extractPrototypeMethodsWalk(tree.rootNode, definitions, typeMap);
377-
378375
// Phase 8.5: collect all `new X()` constructor names for RTA instantiation tracking
379376
const newExpressions: string[] = [];
380377
extractNewExpressionsWalk(tree.rootNode, newExpressions);
@@ -1715,10 +1712,7 @@ function extractSpreadForOfWalk(
17151712
if (depth >= MAX_WALK_DEPTH) return;
17161713

17171714
let pushedFunc = false;
1718-
if (
1719-
node.type === 'function_declaration' ||
1720-
node.type === 'generator_function_declaration'
1721-
) {
1715+
if (node.type === 'function_declaration' || node.type === 'generator_function_declaration') {
17221716
const nameNode = node.childForFieldName('name');
17231717
if (nameNode?.type === 'identifier') {
17241718
funcStack.push(nameNode.text);
@@ -1745,8 +1739,7 @@ function extractSpreadForOfWalk(
17451739
if (child.type === ',' || child.type === '(' || child.type === ')') continue;
17461740
if (child.type === 'spread_element') {
17471741
const spreadTarget =
1748-
child.childForFieldName('argument') ??
1749-
(child.childCount > 1 ? child.child(1) : null);
1742+
child.childForFieldName('argument') ?? (child.childCount > 1 ? child.child(1) : null);
17501743
if (spreadTarget?.type === 'identifier' && !BUILTIN_GLOBALS.has(spreadTarget.text)) {
17511744
spreadArgBindings.push({
17521745
callee: fn.text,

0 commit comments

Comments
 (0)