Skip to content

Commit 2fa6d7e

Browse files
committed
merge: sync with feat/prototype-resolver-1317
Pick up Phase 8.3f object-rest-param and object-property binding extraction from the base branch.
2 parents a004e25 + c484fd1 commit 2fa6d7e

20 files changed

Lines changed: 1352 additions & 8 deletions

File tree

src/domain/graph/builder/stages/build-edges.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -830,7 +830,17 @@ function buildPointsToMapForFile(
830830
symbols: ExtractorOutput,
831831
importedNames: Map<string, string>,
832832
): PointsToMap | null {
833-
if (!symbols.fnRefBindings?.length && !symbols.paramBindings?.length) return null;
833+
if (
834+
!symbols.fnRefBindings?.length &&
835+
!symbols.paramBindings?.length &&
836+
!symbols.arrayElemBindings?.length &&
837+
!symbols.spreadArgBindings?.length &&
838+
!symbols.forOfBindings?.length &&
839+
!symbols.arrayCallbackBindings?.length &&
840+
!symbols.objectRestParamBindings?.length &&
841+
!symbols.objectPropBindings?.length
842+
)
843+
return null;
834844
const defNames = new Set(
835845
symbols.definitions
836846
.filter((d) => d.kind === 'function' || d.kind === 'method')
@@ -843,6 +853,12 @@ function buildPointsToMapForFile(
843853
importedNames,
844854
symbols.paramBindings,
845855
definitionParams,
856+
symbols.arrayElemBindings,
857+
symbols.spreadArgBindings,
858+
symbols.forOfBindings,
859+
symbols.arrayCallbackBindings,
860+
symbols.objectRestParamBindings,
861+
symbols.objectPropBindings,
846862
);
847863
}
848864

@@ -995,6 +1011,43 @@ function buildFileCallEdges(
9951011
}
9961012
}
9971013

1014+
// Phase 8.3f: pts fallback for receiver calls via object-rest param bindings.
1015+
// Fires when `rest.prop()` is encountered and `rest` was seeded as `pts["rest.prop"]`
1016+
// by the object-rest dispatch chain (ObjectRestParamBinding + paramBinding + ObjectPropBinding).
1017+
if (
1018+
targets.length === 0 &&
1019+
call.receiver &&
1020+
!BUILTIN_RECEIVERS.has(call.receiver) &&
1021+
call.receiver !== 'this' &&
1022+
call.receiver !== 'self' &&
1023+
call.receiver !== 'super' &&
1024+
ptsMap
1025+
) {
1026+
const receiverKey = `${call.receiver}.${call.name}`;
1027+
if (ptsMap.has(receiverKey)) {
1028+
for (const alias of resolveViaPointsTo(receiverKey, ptsMap)) {
1029+
const { targets: aliasTargets, importedFrom: aliasFrom } = resolveCallTargets(
1030+
lookup,
1031+
{ name: alias },
1032+
relPath,
1033+
importedNames,
1034+
typeMap as Map<string, unknown>,
1035+
);
1036+
for (const t of aliasTargets) {
1037+
const edgeKey = `${caller.id}|${t.id}`;
1038+
if (t.id !== caller.id && !seenCallEdges.has(edgeKey) && !ptsEdgeRows.has(edgeKey)) {
1039+
const conf =
1040+
computeConfidence(relPath, t.file, aliasFrom ?? null) - PROPAGATION_HOP_PENALTY;
1041+
if (conf > 0) {
1042+
ptsEdgeRows.set(edgeKey, allEdgeRows.length);
1043+
allEdgeRows.push([caller.id, t.id, 'calls', conf, isDynamic, 'points-to']);
1044+
}
1045+
}
1046+
}
1047+
}
1048+
}
1049+
}
1050+
9981051
if (
9991052
call.receiver &&
10001053
!BUILTIN_RECEIVERS.has(call.receiver) &&

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

Lines changed: 204 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,199 @@ 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+
// Quick pre-filter: only re-parse files that actually contain prototype or
595+
// function-as-object-property patterns to avoid an expensive WASM re-parse of
596+
// every JS/TS file in large repos. Covers:
597+
// - `.prototype.` — classical prototype method assignment
598+
// - `\b\w+\.\w+\s*=\s*function` — function-as-object property (`f.g = function(){}`)
599+
const protoFiles = jsFiles.filter((relPath) => {
600+
try {
601+
const content = readFileSafe(path.join(rootDir, relPath));
602+
return content.includes('.prototype.') || /\b\w+\.\w+\s*=\s*function/.test(content);
603+
} catch {
604+
return false;
605+
}
606+
});
607+
608+
if (protoFiles.length === 0) return;
609+
610+
// WASM-parse only the files that have prototype patterns to get full
611+
// ExtractorOutput including prototype method definitions and typeMap entries.
612+
const absPaths = protoFiles.map((f) => path.join(rootDir, f));
613+
let wasmResults: Map<string, ExtractorOutput>;
614+
try {
615+
wasmResults = await parseFilesWasmForBackfill(absPaths, rootDir);
616+
} catch (e) {
617+
debug(`runPostNativePrototypeMethods: WASM parse failed: ${toErrorMessage(e)}`);
618+
return;
619+
}
620+
621+
if (wasmResults.size === 0) return;
622+
623+
// Check which nodes already exist — INSERT OR IGNORE handles races but
624+
// we need the IDs of newly inserted rows, so we check first.
625+
const existsStmt = db.prepare(
626+
`SELECT id FROM nodes WHERE name = ? AND kind = 'method' AND file = ?`,
627+
);
628+
629+
// Insert rows: [name, kind, file, line, end_line, parent_id, qualified_name, scope, visibility]
630+
const newNodeRows: unknown[][] = [];
631+
// Track which file+definition combos are new so we can insert edges for them.
632+
const newDefs: Array<{ name: string; file: string; line: number }> = [];
633+
634+
for (const [relPath, symbols] of wasmResults) {
635+
for (const def of symbols.definitions ?? []) {
636+
if (def.kind !== 'method') continue;
637+
const dotIdx = def.name.indexOf('.');
638+
if (dotIdx === -1) continue; // skip bare method names (shouldn't happen, but guard)
639+
640+
// Only insert if the node is not already in the DB.
641+
const existing = existsStmt.get(def.name, relPath) as { id: number } | undefined;
642+
if (existing) continue;
643+
644+
const scope = def.name.slice(0, dotIdx);
645+
newNodeRows.push([
646+
def.name,
647+
'method',
648+
relPath,
649+
def.line,
650+
def.endLine ?? null,
651+
null,
652+
def.name,
653+
scope,
654+
null,
655+
]);
656+
newDefs.push({ name: def.name, file: relPath, line: def.line });
657+
}
658+
}
659+
660+
if (newNodeRows.length === 0) return;
661+
662+
db.transaction(() => batchInsertNodes(db, newNodeRows))();
663+
664+
// Build a name → node lookup from all DB nodes (including newly inserted ones).
665+
type NodeEntry = { id: number; file: string; kind: string };
666+
const byNameMap = new Map<string, NodeEntry[]>();
667+
const byNameFileMap = new Map<string, NodeEntry[]>();
668+
const byIdKey = new Map<string, { id: number }>();
669+
670+
const allNodes = db
671+
.prepare(`SELECT id, name, kind, file, line FROM nodes WHERE kind != 'file'`)
672+
.all() as Array<{ id: number; name: string; kind: string; file: string; line: number }>;
673+
674+
for (const n of allNodes) {
675+
const list = byNameMap.get(n.name);
676+
if (list) list.push(n);
677+
else byNameMap.set(n.name, [n]);
678+
679+
const fk = `${n.name}::${n.file}`;
680+
const flist = byNameFileMap.get(fk);
681+
if (flist) flist.push(n);
682+
else byNameFileMap.set(fk, [n]);
683+
684+
byIdKey.set(`${n.name}|${n.kind}|${n.file}|${n.line}`, { id: n.id });
685+
}
686+
687+
const lookup: CallNodeLookup = {
688+
byName: (name) => byNameMap.get(name) ?? [],
689+
byNameAndFile: (name, file) => byNameFileMap.get(`${name}::${file}`) ?? [],
690+
isBarrel: () => false,
691+
resolveBarrel: () => null,
692+
nodeId: (name, kind, file, line) => byIdKey.get(`${name}|${kind}|${file}|${line}`),
693+
};
694+
695+
// Build a fast set of newly-inserted node IDs so we only emit edges to
696+
// prototype nodes that the Rust engine missed (avoids duplicating existing edges).
697+
const newNodeIds = new Set<number>(
698+
newDefs.flatMap((d) => {
699+
const nodes = byNameFileMap.get(`${d.name}::${d.file}`);
700+
return nodes ? nodes.map((n) => n.id) : [];
701+
}),
702+
);
703+
704+
// seenByPair deduplicates edges we emit within this function only.
705+
// No pre-existing edge can target a newly-inserted node ID (SQLite
706+
// auto-increment guarantees the new IDs are unique), so there is no need
707+
// to seed this set from the DB — doing so would load O(|edges|) data for
708+
// zero benefit and could OOM on large repositories.
709+
const seenByPair = new Set<string>();
710+
711+
// Resolve call edges in every file — not just those that define new prototype
712+
// methods. A caller in app.js calling a prototype method defined in lib.js
713+
// would be silently missed if we only scanned definition files.
714+
// The newNodeIds guard inside the loop already prevents duplicate edges.
715+
const newEdgeRows: unknown[][] = [];
716+
const fileNodeStmt = db.prepare(`SELECT id FROM nodes WHERE kind = 'file' AND file = ?`);
717+
718+
for (const [relPath, symbols] of wasmResults) {
719+
const fileNodeRow = fileNodeStmt.get(relPath) as { id: number } | undefined;
720+
if (!fileNodeRow) continue;
721+
722+
const typeMap = symbols.typeMap instanceof Map ? symbols.typeMap : new Map();
723+
724+
for (const call of symbols.calls ?? []) {
725+
if (!call.receiver) continue; // prototype resolution only applies to receiver calls
726+
727+
const caller = findCaller(lookup, call, symbols.definitions ?? [], relPath, fileNodeRow);
728+
729+
const targets = resolveByMethodOrGlobal(
730+
lookup,
731+
call,
732+
relPath,
733+
typeMap as Map<string, unknown>,
734+
caller.callerName,
735+
);
736+
737+
for (const t of targets) {
738+
// Only emit edges to newly-inserted prototype nodes to avoid
739+
// duplicating edges the Rust engine already built.
740+
if (!newNodeIds.has(t.id)) continue;
741+
const key = `${caller.id}|${t.id}`;
742+
if (seenByPair.has(key)) continue;
743+
seenByPair.add(key);
744+
const conf = computeConfidence(relPath, t.file, null);
745+
if (conf <= 0) continue;
746+
newEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'receiver-typed']);
747+
}
748+
}
749+
}
750+
751+
if (newEdgeRows.length > 0) {
752+
db.transaction(() => batchInsertEdges(db, newEdgeRows))();
753+
}
754+
}
755+
561756
/** Format timing result from native orchestrator phases + JS post-processing. */
562757
function formatNativeTimingResult(
563758
p: Record<string, number>,
@@ -1195,6 +1390,15 @@ export async function tryNativeOrchestrator(
11951390
}
11961391
}
11971392

1393+
// Prototype method post-pass: the Rust engine does not recognise pre-ES6
1394+
// `Foo.prototype.bar = function(){}` patterns. Re-parse JS/TS files via
1395+
// WASM to insert missing method nodes and their call edges.
1396+
try {
1397+
await runPostNativePrototypeMethods(ctx.db as unknown as BetterSqlite3Database, ctx.rootDir);
1398+
} catch (err) {
1399+
debug(`Prototype methods post-pass failed: ${toErrorMessage(err)}`);
1400+
}
1401+
11981402
// Backfill the `technique` column on `calls` edges written by the Rust
11991403
// orchestrator, which does not write the column. Runs after all edge-writing
12001404
// phases (including the WASM dropped-language backfill and CHA post-pass) so

0 commit comments

Comments
 (0)