Skip to content

Commit c036834

Browse files
committed
fix: resolve merge conflicts with main and fix duplicate paramBindings identifier
- Merge origin/main into feat/prototype-resolver-1317 - Both post-passes are retained: runPostNativePrototypeMethods (func-prop this-dispatch for fn.method = function(){} patterns) and runPostNativeThisDispatch (this/super class-inheritance dispatch from PR #1337) - Remove duplicate paramBindings field in SerializedExtractorOutput that was introduced by the previous merge (TS2300 error blocking all CI jobs)
2 parents 194507c + 95bc17b commit c036834

4 files changed

Lines changed: 299 additions & 61 deletions

File tree

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

Lines changed: 274 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import {
4444
import { computeConfidence } from '../../resolve.js';
4545
import type { CallNodeLookup } from '../call-resolver.js';
4646
import { findCaller, resolveByMethodOrGlobal } from '../call-resolver.js';
47+
import type { ChaContext } from '../cha.js';
48+
import { resolveThisDispatch } from '../cha.js';
4749
import type { PipelineContext } from '../context.js';
4850
import {
4951
batchInsertEdges,
@@ -397,9 +399,8 @@ async function runPostNativeAnalysis(
397399
* each call to an interface/abstract method to ALL RTA-filtered concrete
398400
* implementations.
399401
*
400-
* Note: `this`/`super` dispatch requires the raw unresolved call sites which are
401-
* not persisted to the DB by the Rust pipeline. That case is handled by the WASM
402-
* path (`buildFileCallEdges`) and is a known gap for the native orchestrator.
402+
* Note: `this`/`super` dispatch is handled separately by `runPostNativeThisDispatch`,
403+
* which WASM-re-parses JS/TS files to obtain raw call site receiver info.
403404
*
404405
* Returns the set of target node IDs for newly inserted CHA edges so the caller
405406
* can re-classify roles for the affected implementation files. An empty set
@@ -561,16 +562,19 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
561562
}
562563

563564
/**
564-
* Post-pass: backfill prototype-based method definitions and their call edges.
565+
* Post-pass: backfill function-as-object-property method definitions and their call edges.
565566
*
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
567+
* The Rust engine does not recognise `fn.method = function(){}` patterns as
568+
* method definitions, so those nodes are absent from the DB after the native
568569
* orchestrator completes. This pass:
569570
* 1. Re-parses JS/TS files via WASM to obtain the full ExtractorOutput
570-
* (including definitions emitted by extractPrototypeMethodsWalk).
571+
* (including definitions emitted by extractFuncPropMethodsWalk).
571572
* 2. Inserts any method nodes that are missing from the DB.
572573
* 3. Resolves call edges to those newly-inserted nodes using the WASM typeMap
573574
* and the existing DB node table as a lookup.
575+
*
576+
* Note: `Foo.prototype.bar = function(){}` patterns are handled natively by
577+
* the Rust extractor and do not need a WASM re-parse here.
574578
*/
575579
async function runPostNativePrototypeMethods(
576580
db: BetterSqlite3Database,
@@ -606,8 +610,8 @@ async function runPostNativePrototypeMethods(
606610

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

609-
// WASM-parse only the files that have prototype patterns to get full
610-
// ExtractorOutput including prototype method definitions and typeMap entries.
613+
// WASM-parse only the files that have func-prop patterns to get full
614+
// ExtractorOutput including method definitions and typeMap entries.
611615
const absPaths = protoFiles.map((f) => path.join(rootDir, f));
612616
let wasmResults: Map<string, ExtractorOutput>;
613617
try {
@@ -692,7 +696,7 @@ async function runPostNativePrototypeMethods(
692696
};
693697

694698
// Build a fast set of newly-inserted node IDs so we only emit edges to
695-
// prototype nodes that the Rust engine missed (avoids duplicating existing edges).
699+
// func-prop nodes that the Rust engine missed (avoids duplicating existing edges).
696700
const newNodeIds = new Set<number>(
697701
newDefs.flatMap((d) => {
698702
const nodes = byNameFileMap.get(`${d.name}::${d.file}`);
@@ -707,8 +711,8 @@ async function runPostNativePrototypeMethods(
707711
// zero benefit and could OOM on large repositories.
708712
const seenByPair = new Set<string>();
709713

710-
// Resolve call edges in every file — not just those that define new prototype
711-
// methods. A caller in app.js calling a prototype method defined in lib.js
714+
// Resolve call edges in every file — not just those that define new func-prop
715+
// methods. A caller in app.js calling a method defined in lib.js
712716
// would be silently missed if we only scanned definition files.
713717
// The newNodeIds guard inside the loop already prevents duplicate edges.
714718
const newEdgeRows: unknown[][] = [];
@@ -721,7 +725,7 @@ async function runPostNativePrototypeMethods(
721725
const typeMap = symbols.typeMap instanceof Map ? symbols.typeMap : new Map();
722726

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

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

@@ -734,7 +738,7 @@ async function runPostNativePrototypeMethods(
734738
);
735739

736740
for (const t of targets) {
737-
// Only emit edges to newly-inserted prototype nodes to avoid
741+
// Only emit edges to newly-inserted func-prop nodes to avoid
738742
// duplicating edges the Rust engine already built.
739743
if (!newNodeIds.has(t.id)) continue;
740744
const key = `${caller.id}|${t.id}`;
@@ -752,11 +756,204 @@ async function runPostNativePrototypeMethods(
752756
}
753757
}
754758

759+
// Extensions where `this`/`super` dispatch can occur (JS/TS family)
760+
const THIS_DISPATCH_EXTS = new Set(['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.mts', '.cts']);
761+
762+
/**
763+
* Phase 8.5: this/super dispatch post-pass for the native orchestrator path.
764+
*
765+
* The Rust build pipeline resolves typed receiver calls but does NOT persist raw
766+
* unresolved call site receiver info (e.g. `this`, `super`) to the DB. This
767+
* hybrid post-pass re-parses JS/TS/TSX files via WASM to collect call sites with
768+
* `this`/`super` receivers, then resolves them through the class hierarchy stored
769+
* in DB `extends` edges — mirroring what `buildChaPostPass` does on the WASM path.
770+
*
771+
* Only runs when `extends` edges exist in the DB; if there is no inheritance
772+
* hierarchy there is nothing to resolve via `this`/`super` dispatch.
773+
*/
774+
async function runPostNativeThisDispatch(
775+
db: BetterSqlite3Database,
776+
rootDir: string,
777+
changedFiles: string[] | undefined,
778+
isFullBuild: boolean,
779+
): Promise<{ elapsedMs: number; targetIds: Set<number> }> {
780+
const t0 = Date.now();
781+
const targetIds = new Set<number>();
782+
// Fast guard: need at least one extends edge for this/super to have meaning
783+
const hasExtends = db.prepare(`SELECT 1 FROM edges WHERE kind = 'extends' LIMIT 1`).get();
784+
if (!hasExtends) return { elapsedMs: 0, targetIds };
785+
786+
// Build parents map: child class → direct parent class (from `extends` edges)
787+
const parentRows = db
788+
.prepare(`
789+
SELECT src.name AS child_name, tgt.name AS parent_name
790+
FROM edges e
791+
JOIN nodes src ON e.source_id = src.id
792+
JOIN nodes tgt ON e.target_id = tgt.id
793+
WHERE e.kind = 'extends'
794+
`)
795+
.all() as Array<{ child_name: string; parent_name: string }>;
796+
797+
const parents = new Map<string, string>();
798+
for (const row of parentRows) {
799+
if (!parents.has(row.child_name)) parents.set(row.child_name, row.parent_name);
800+
}
801+
if (parents.size === 0) return { elapsedMs: 0, targetIds };
802+
803+
const chaCtx: ChaContext = {
804+
implementors: new Map(), // not needed for this/super resolution
805+
parents,
806+
instantiatedTypes: new Set(), // not needed for this/super resolution
807+
};
808+
809+
// Determine which files to re-parse.
810+
//
811+
// On a full build we do NOT re-parse every JS/TS file — that would WASM-parse
812+
// the entire project on top of the native pass, causing a massive regression
813+
// (measured: +358% ms/file on codegraph itself). Instead we restrict to files
814+
// that are part of the class inheritance hierarchy: both subclass files (which
815+
// contain `super.X()` calls dispatching to a parent) and parent-class files
816+
// (whose method bodies contain `this.X()` calls that CHA must resolve). Any
817+
// file not in the hierarchy has no `extends` relationship, so `this`/`super`
818+
// calls in it either resolve locally (same-class dispatch, already handled by
819+
// the direct-call edge) or have no class context — and will be skipped by
820+
// `resolveThisDispatch` anyway.
821+
let relFiles: string[];
822+
if (isFullBuild || !changedFiles) {
823+
const rows = db
824+
.prepare(`
825+
SELECT DISTINCT file FROM (
826+
SELECT src.file AS file
827+
FROM edges e
828+
JOIN nodes src ON e.source_id = src.id
829+
WHERE e.kind = 'extends' AND src.file IS NOT NULL
830+
UNION
831+
SELECT tgt.file AS file
832+
FROM edges e
833+
JOIN nodes tgt ON e.target_id = tgt.id
834+
WHERE e.kind = 'extends' AND tgt.file IS NOT NULL
835+
)
836+
`)
837+
.all() as Array<{ file: string }>;
838+
relFiles = rows
839+
.map((r) => r.file)
840+
.filter((f) => THIS_DISPATCH_EXTS.has(path.extname(f).toLowerCase()));
841+
} else {
842+
// NOTE: Only files explicitly listed in changedFiles are re-parsed.
843+
// If a parent-class method is replaced (new node ID) but the child file is
844+
// unchanged, the stale super.method() edge is not refreshed here. A full
845+
// rebuild (isFullBuild=true) is required to recover in that scenario.
846+
relFiles = changedFiles.filter((f) => THIS_DISPATCH_EXTS.has(path.extname(f).toLowerCase()));
847+
}
848+
if (relFiles.length === 0) return { elapsedMs: 0, targetIds };
849+
850+
// DB-backed CallNodeLookup — resolveThisDispatch only calls byName()
851+
const findByNameStmt = db.prepare(`SELECT id, file, kind FROM nodes WHERE name = ?`);
852+
const lookup: CallNodeLookup = {
853+
byName: (name) => findByNameStmt.all(name) as Array<{ id: number; file: string; kind: string }>,
854+
byNameAndFile: (name, file) =>
855+
(findByNameStmt.all(name) as Array<{ id: number; file: string; kind: string }>).filter(
856+
(n) => n.file === file,
857+
),
858+
isBarrel: () => false,
859+
resolveBarrel: () => null,
860+
nodeId: () => undefined,
861+
};
862+
863+
// Seed seen-pairs from existing call edges on source nodes in our file set
864+
const seen = new Set<string>();
865+
const CHUNK = 500;
866+
for (let i = 0; i < relFiles.length; i += CHUNK) {
867+
const chunk = relFiles.slice(i, i + CHUNK);
868+
const ph = chunk.map(() => '?').join(',');
869+
const rows = db
870+
.prepare(
871+
`SELECT e.source_id, e.target_id
872+
FROM edges e
873+
JOIN nodes n ON e.source_id = n.id
874+
WHERE e.kind = 'calls' AND n.file IN (${ph})`,
875+
)
876+
.all(...chunk) as Array<{ source_id: number; target_id: number }>;
877+
for (const r of rows) seen.add(`${r.source_id}|${r.target_id}`);
878+
}
879+
880+
// Find the innermost containing method/function for a call at `line` in `file`.
881+
// COALESCE maps NULL end_line to a large sentinel so unbounded nodes sort last
882+
// (SQLite ASC orders NULLs first, so a raw `end_line - line` would pick them first).
883+
const findCallerByLineStmt = db.prepare(`
884+
SELECT id, name FROM nodes
885+
WHERE file = ? AND kind IN ('method', 'function')
886+
AND line <= ? AND (end_line IS NULL OR end_line >= ?)
887+
ORDER BY COALESCE(end_line - line, 999999999) ASC
888+
LIMIT 1
889+
`);
890+
891+
// WASM-parse the files to obtain raw call sites with receiver info
892+
const absFiles = relFiles.map((f) => path.join(rootDir, f));
893+
const wasmResults = await parseFilesWasmForBackfill(absFiles, rootDir);
894+
895+
const newEdges: Array<[number, number, string, number, number, string]> = [];
896+
897+
for (const [relPath, symbols] of wasmResults) {
898+
for (const call of symbols.calls) {
899+
// Only 'this' and 'super' are class-instance receivers in JS/TS.
900+
// 'self' refers to WindowOrWorkerGlobalScope — not a class instance — so
901+
// filtering it here prevents spurious dispatch edges from Worker call sites.
902+
if (call.receiver !== 'this' && call.receiver !== 'super') continue;
903+
904+
const callerRow = findCallerByLineStmt.get(relPath, call.line, call.line) as
905+
| { id: number; name: string }
906+
| undefined;
907+
if (!callerRow) continue;
908+
909+
const targets = resolveThisDispatch(
910+
call.name,
911+
callerRow.name,
912+
call.receiver as 'this' | 'super',
913+
chaCtx,
914+
lookup,
915+
);
916+
917+
for (const t of targets) {
918+
const key = `${callerRow.id}|${t.id}`;
919+
if (seen.has(key)) continue;
920+
seen.add(key);
921+
const conf = computeConfidence(relPath, t.file, null) - CHA_DISPATCH_PENALTY;
922+
if (conf <= 0) continue;
923+
newEdges.push([callerRow.id, t.id, 'calls', conf, 0, 'cha']);
924+
targetIds.add(t.id);
925+
}
926+
}
927+
}
928+
929+
if (newEdges.length > 0) {
930+
db.transaction(() => batchInsertEdges(db, newEdges))();
931+
debug(`this/super dispatch post-pass: inserted ${newEdges.length} edge(s)`);
932+
}
933+
934+
// Free WASM parse trees — mirrors the cleanup in backfillNativeDroppedFiles
935+
for (const [, symbols] of wasmResults) {
936+
const tree = (symbols as { _tree?: { delete?: () => void } })._tree;
937+
if (tree && typeof tree.delete === 'function') {
938+
try {
939+
tree.delete();
940+
} catch {
941+
/* ignore cleanup errors */
942+
}
943+
}
944+
(symbols as { _tree?: unknown; _langId?: unknown })._tree = undefined;
945+
(symbols as { _tree?: unknown; _langId?: unknown })._langId = undefined;
946+
}
947+
948+
return { elapsedMs: Date.now() - t0, targetIds };
949+
}
950+
755951
/** Format timing result from native orchestrator phases + JS post-processing. */
756952
function formatNativeTimingResult(
757953
p: Record<string, number>,
758954
structurePatchMs: number,
759955
analysisTiming: { astMs: number; complexityMs: number; cfgMs: number; dataflowMs: number },
956+
thisDispatchMs: number,
760957
): BuildResult {
761958
return {
762959
phases: {
@@ -769,6 +966,7 @@ function formatNativeTimingResult(
769966
edgesMs: +(p.edgesMs ?? 0).toFixed(1),
770967
structureMs: +((p.structureMs ?? 0) + structurePatchMs).toFixed(1),
771968
rolesMs: +(p.rolesMs ?? 0).toFixed(1),
969+
thisDispatchMs: +thisDispatchMs.toFixed(1),
772970
astMs: +(analysisTiming.astMs ?? 0).toFixed(1),
773971
complexityMs: +(analysisTiming.complexityMs ?? 0).toFixed(1),
774972
cfgMs: +(analysisTiming.cfgMs ?? 0).toFixed(1),
@@ -1308,7 +1506,7 @@ export async function tryNativeOrchestrator(
13081506
ctx.nativeFirstProxy = false;
13091507
} else if (!ctx.nativeFirstProxy && !handoffWalAfterNativeBuild(ctx)) {
13101508
// DB reopen failed — return partial result
1311-
return formatNativeTimingResult(p, 0, analysisTiming);
1509+
return formatNativeTimingResult(p, 0, analysisTiming, 0);
13121510
}
13131511
}
13141512

@@ -1399,10 +1597,68 @@ export async function tryNativeOrchestrator(
13991597
debug(`Function-prop methods post-pass failed: ${toErrorMessage(err)}`);
14001598
}
14011599

1600+
// Phase 8.5: this/super dispatch — hybrid WASM re-parse to resolve call sites
1601+
// whose raw receiver info the Rust pipeline does not persist to DB.
1602+
const { elapsedMs: thisDispatchMs, targetIds: thisDispatchTargetIds } =
1603+
await runPostNativeThisDispatch(
1604+
ctx.db as unknown as BetterSqlite3Database,
1605+
ctx.rootDir,
1606+
result.changedFiles,
1607+
!!result.isFullBuild,
1608+
);
1609+
1610+
// Re-classify roles for methods that gained incoming this/super dispatch edges.
1611+
// The Rust orchestrator classifies roles BEFORE this post-pass, so target methods
1612+
// (e.g. Animal.speak, ConcreteWorker.prepare) that had no callers at Rust time
1613+
// are classified `dead` or `dead-ffi`. Inserting the new call edges does not
1614+
// automatically update those role labels — without a re-run the stale labels
1615+
// propagate to dead-code detection and API boundary analysis.
1616+
if (thisDispatchTargetIds.size > 0) {
1617+
try {
1618+
const db = ctx.db as unknown as BetterSqlite3Database;
1619+
const idArray = Array.from(thisDispatchTargetIds);
1620+
const CHUNK_SIZE = 500;
1621+
const seenFiles = new Set<string>();
1622+
const affectedFiles: Array<{ file: string }> = [];
1623+
for (let i = 0; i < idArray.length; i += CHUNK_SIZE) {
1624+
const chunk = idArray.slice(i, i + CHUNK_SIZE);
1625+
const placeholders = chunk.map(() => '?').join(',');
1626+
const rows = db
1627+
.prepare(
1628+
`SELECT DISTINCT file FROM nodes WHERE id IN (${placeholders}) AND file IS NOT NULL`,
1629+
)
1630+
.all(...chunk) as Array<{ file: string }>;
1631+
for (const row of rows) {
1632+
if (!seenFiles.has(row.file)) {
1633+
seenFiles.add(row.file);
1634+
affectedFiles.push(row);
1635+
}
1636+
}
1637+
}
1638+
if (affectedFiles.length > 0) {
1639+
const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
1640+
classifyNodeRoles: (
1641+
db: BetterSqlite3Database,
1642+
changedFiles?: string[] | null,
1643+
) => Record<string, number>;
1644+
};
1645+
classifyNodeRoles(
1646+
db,
1647+
affectedFiles.map((r) => r.file),
1648+
);
1649+
debug(
1650+
`this/super dispatch post-pass: re-classified roles for ${affectedFiles.length} target file(s)`,
1651+
);
1652+
}
1653+
} catch (err) {
1654+
debug(`this/super dispatch post-pass role re-classification failed: ${toErrorMessage(err)}`);
1655+
}
1656+
}
1657+
14021658
// Backfill the `technique` column on `calls` edges written by the Rust
14031659
// orchestrator, which does not write the column. Runs after all edge-writing
1404-
// phases (including the WASM dropped-language backfill and CHA post-pass) so
1405-
// every new edge in this build cycle gets a technique label.
1660+
// phases (including the WASM dropped-language backfill, CHA post-pass, and
1661+
// this/super dispatch) so every new edge in this build cycle gets a label.
14061662
backfillEdgeTechniquesAfterNativeOrchestrator(ctx.db, !!result.isFullBuild, result.changedFiles);
14071663

14081664
// ── Structure and analysis fallback (run after edge-writing so roles see full graph) ──
@@ -1427,5 +1683,5 @@ export async function tryNativeOrchestrator(
14271683
}
14281684

14291685
closeDbPair({ db: ctx.db, nativeDb: ctx.nativeDb });
1430-
return formatNativeTimingResult(p, structurePatchMs, analysisTiming);
1686+
return formatNativeTimingResult(p, structurePatchMs, analysisTiming, thisDispatchMs);
14311687
}

0 commit comments

Comments
 (0)