Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
178 changes: 173 additions & 5 deletions src/domain/graph/builder/stages/native-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ import {
parseFilesWasmForBackfill,
} from '../../../parser.js';
import { computeConfidence } from '../../resolve.js';
import type { CallNodeLookup } from '../call-resolver.js';
import type { ChaContext } from '../cha.js';
import { resolveThisDispatch } from '../cha.js';
import type { PipelineContext } from '../context.js';
import {
batchInsertEdges,
Expand Down Expand Up @@ -394,9 +397,8 @@ async function runPostNativeAnalysis(
* each call to an interface/abstract method to ALL RTA-filtered concrete
* implementations.
*
* Note: `this`/`super` dispatch requires the raw unresolved call sites which are
* not persisted to the DB by the Rust pipeline. That case is handled by the WASM
* path (`buildFileCallEdges`) and is a known gap for the native orchestrator.
* Note: `this`/`super` dispatch is handled separately by `runPostNativeThisDispatch`,
* which WASM-re-parses JS/TS files to obtain raw call site receiver info.
*
* Returns the set of target node IDs for newly inserted CHA edges so the caller
* can re-classify roles for the affected implementation files. An empty set
Expand Down Expand Up @@ -556,6 +558,163 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
return newTargetIds;
}

// Extensions where `this`/`super` dispatch can occur (JS/TS family)
const THIS_DISPATCH_EXTS = new Set(['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.mts', '.cts']);

/**
* Phase 8.5: this/super dispatch post-pass for the native orchestrator path.
*
* The Rust build pipeline resolves typed receiver calls but does NOT persist raw
* unresolved call site receiver info (e.g. `this`, `super`) to the DB. This
* hybrid post-pass re-parses JS/TS/TSX files via WASM to collect call sites with
* `this`/`super` receivers, then resolves them through the class hierarchy stored
* in DB `extends` edges — mirroring what `buildChaPostPass` does on the WASM path.
*
* Only runs when `extends` edges exist in the DB; if there is no inheritance
* hierarchy there is nothing to resolve via `this`/`super` dispatch.
*/
async function runPostNativeThisDispatch(
db: BetterSqlite3Database,
rootDir: string,
changedFiles: string[] | undefined,
isFullBuild: boolean,
): Promise<void> {
// Fast guard: need at least one extends edge for this/super to have meaning
const hasExtends = db.prepare(`SELECT 1 FROM edges WHERE kind = 'extends' LIMIT 1`).get();
if (!hasExtends) return;

// Build parents map: child class → direct parent class (from `extends` edges)
const parentRows = db
.prepare(`
SELECT src.name AS child_name, tgt.name AS parent_name
FROM edges e
JOIN nodes src ON e.source_id = src.id
JOIN nodes tgt ON e.target_id = tgt.id
WHERE e.kind = 'extends'
`)
.all() as Array<{ child_name: string; parent_name: string }>;

const parents = new Map<string, string>();
for (const row of parentRows) {
if (!parents.has(row.child_name)) parents.set(row.child_name, row.parent_name);
}
if (parents.size === 0) return;

const chaCtx: ChaContext = {
implementors: new Map(), // not needed for this/super resolution
parents,
instantiatedTypes: new Set(), // not needed for this/super resolution
};

// Determine which files to re-parse (JS/TS family only)
let relFiles: string[];
if (isFullBuild || !changedFiles) {
const rows = db
.prepare("SELECT DISTINCT file FROM nodes WHERE kind = 'file' AND file IS NOT NULL")
.all() as Array<{ file: string }>;
relFiles = rows
.map((r) => r.file)
.filter((f) => THIS_DISPATCH_EXTS.has(path.extname(f).toLowerCase()));
} else {
relFiles = changedFiles.filter((f) => THIS_DISPATCH_EXTS.has(path.extname(f).toLowerCase()));
}
if (relFiles.length === 0) return;

// DB-backed CallNodeLookup — resolveThisDispatch only calls byName()
const findByNameStmt = db.prepare(`SELECT id, file, kind FROM nodes WHERE name = ?`);
const lookup: CallNodeLookup = {
byName: (name) => findByNameStmt.all(name) as Array<{ id: number; file: string; kind: string }>,
byNameAndFile: (name, file) =>
(findByNameStmt.all(name) as Array<{ id: number; file: string; kind: string }>).filter(
(n) => n.file === file,
),
isBarrel: () => false,
resolveBarrel: () => null,
nodeId: () => undefined,
};

// Seed seen-pairs from existing call edges on source nodes in our file set
const seen = new Set<string>();
const CHUNK = 500;
for (let i = 0; i < relFiles.length; i += CHUNK) {
const chunk = relFiles.slice(i, i + CHUNK);
const ph = chunk.map(() => '?').join(',');
const rows = db
.prepare(
`SELECT e.source_id, e.target_id
FROM edges e
JOIN nodes n ON e.source_id = n.id
WHERE e.kind = 'calls' AND n.file IN (${ph})`,
)
.all(...chunk) as Array<{ source_id: number; target_id: number }>;
for (const r of rows) seen.add(`${r.source_id}|${r.target_id}`);
}

// Find the innermost containing method/function for a call at `line` in `file`.
// NULL end_line sorts last in SQLite ASC → only selected when no bounded node exists.
const findCallerByLineStmt = db.prepare(`
SELECT id, name FROM nodes
WHERE file = ? AND kind IN ('method', 'function')
AND line <= ? AND (end_line IS NULL OR end_line >= ?)
ORDER BY (end_line - line) ASC
LIMIT 1
`);
Comment on lines +684 to +693

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 SQLite NULL ordering inverted — wrong caller selected when end_line is absent

The comment says "NULL end_line sorts last in SQLite ASC", but SQLite actually sorts NULLs first in ASC order (NULLs are considered less than any other value). When any method/function node has end_line IS NULL, the expression (end_line - line) evaluates to NULL, which ranks before every positive span — so that unbounded node is picked by LIMIT 1 instead of the tightest-bounded enclosing method. The intent (use unbounded nodes only as a last resort) is directly contradicted by the actual SQLite behaviour, meaning this/super edges can silently be attributed to the wrong caller.

Fix: replace the ORDER BY with COALESCE(end_line - line, 999999999) ASC or (end_line - line) ASC NULLS LAST (supported since SQLite 3.30.0).

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 — replaced (end_line - line) ASC with COALESCE(end_line - line, 999999999) ASC. SQLite ASC orders NULLs first so the original query would pick unbounded nodes before any bounded node, inverting the intent. The COALESCE sentinel pushes them last, which is what the comment was trying to say. Committed in fd15d3c.


// WASM-parse the files to obtain raw call sites with receiver info
const absFiles = relFiles.map((f) => path.join(rootDir, f));
const wasmResults = await parseFilesWasmForBackfill(absFiles, rootDir);

const newEdges: Array<[number, number, string, number, number, string]> = [];

for (const [relPath, symbols] of wasmResults) {
for (const call of symbols.calls) {
if (call.receiver !== 'this' && call.receiver !== 'self' && call.receiver !== 'super')
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.

P2 Including 'self' as a this-like receiver for JS/TS-family files may produce false-positive dispatch edges. In JavaScript and TypeScript self is not a class-instance receiver — it refers to the global WindowOrWorkerGlobalScope object. Any call of the form self.postMessage() or similar inside a Worker would be misidentified as a this-dispatch and resolved against the class hierarchy, yielding spurious calls edges. If self support is intentional (e.g. for CoffeeScript or other transpiled sources), a comment explaining the rationale would help future readers avoid accidentally widening this further.

Suggested change
if (call.receiver !== 'this' && call.receiver !== 'self' && call.receiver !== 'super')
continue;
if (call.receiver !== 'this' && call.receiver !== 'super')
continue;

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 'self' from the receiver check in runPostNativeThisDispatch. This function only runs on JS/TS-family files where self is WindowOrWorkerGlobalScope, not a class instance. Keeping it would have produced spurious dispatch edges for any self.postMessage() or similar Worker call. Applied the suggested fix plus an explanatory comment. Committed in fd15d3c.


const callerRow = findCallerByLineStmt.get(relPath, call.line, call.line) as
| { id: number; name: string }
| undefined;
if (!callerRow) continue;

const targets = resolveThisDispatch(
call.name,
callerRow.name,
call.receiver as 'this' | 'self' | 'super',
chaCtx,
lookup,
);

for (const t of targets) {
const key = `${callerRow.id}|${t.id}`;
if (seen.has(key)) continue;
seen.add(key);
const conf = computeConfidence(relPath, t.file, null) - 0.1;
if (conf <= 0) continue;
newEdges.push([callerRow.id, t.id, 'calls', conf, 0, 'cha']);
}
}
}

if (newEdges.length > 0) {
db.transaction(() => batchInsertEdges(db, newEdges))();
debug(`this/super dispatch post-pass: inserted ${newEdges.length} edge(s)`);
}

// Free WASM parse trees — mirrors the cleanup in backfillNativeDroppedFiles
for (const [, symbols] of wasmResults) {
const tree = (symbols as { _tree?: { delete?: () => void } })._tree;
if (tree && typeof tree.delete === 'function') {
try {
tree.delete();
} catch {
/* ignore cleanup errors */
}
}
(symbols as { _tree?: unknown; _langId?: unknown })._tree = undefined;
(symbols as { _tree?: unknown; _langId?: unknown })._langId = undefined;
}
}

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

// Phase 8.5: this/super dispatch — hybrid WASM re-parse to resolve call sites
// whose raw receiver info the Rust pipeline does not persist to DB.
await runPostNativeThisDispatch(
ctx.db as unknown as BetterSqlite3Database,
ctx.rootDir,
result.changedFiles,
!!result.isFullBuild,
);

// 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
// every new edge in this build cycle gets a technique label.
// phases (including the WASM dropped-language backfill, CHA post-pass, and
// this/super dispatch) so every new edge in this build cycle gets a label.
backfillEdgeTechniquesAfterNativeOrchestrator(ctx.db, !!result.isFullBuild, result.changedFiles);

// ── Structure and analysis fallback (run after edge-writing so roles see full graph) ──
Expand Down
35 changes: 13 additions & 22 deletions tests/integration/phase-8.5-cha-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,32 +119,23 @@ describe.each(ENGINES)('Phase 8.5 CHA dispatch (%s)', (engine) => {
});

// ── this-dispatch ──────────────────────────────────────────────────────
// The WASM path resolves `this.prepare()` through the class hierarchy via
// the inline CHA dispatch in buildFileCallEdges. The native orchestrator
// path does not persist raw call sites to the DB, so this-dispatch is a
// known gap for native — tested only for wasm.

it.skipIf(engine === 'native')(
'this-dispatch: emits ConcreteWorker.doWork → ConcreteWorker.prepare',
() => {
const edge = callEdges.find(
(e) =>
e.caller_name === 'ConcreteWorker.doWork' &&
e.callee_name === 'ConcreteWorker.prepare' &&
e.callee_file === 'ConcreteWorker.ts',
);
expect(
edge,
`Expected ConcreteWorker.doWork → ConcreteWorker.prepare edge (this-dispatch).\nActual edges:\n${JSON.stringify(callEdges, null, 2)}`,
).toBeDefined();
},
);
it('this-dispatch: emits ConcreteWorker.doWork → ConcreteWorker.prepare', () => {
const edge = callEdges.find(
(e) =>
e.caller_name === 'ConcreteWorker.doWork' &&
e.callee_name === 'ConcreteWorker.prepare' &&
e.callee_file === 'ConcreteWorker.ts',
);
expect(
edge,
`Expected ConcreteWorker.doWork → ConcreteWorker.prepare edge (this-dispatch).\nActual edges:\n${JSON.stringify(callEdges, null, 2)}`,
).toBeDefined();
});

// ── super-dispatch ─────────────────────────────────────────────────────
// Same gap as this-dispatch: super.speak() cannot be resolved from DB edges
// alone in the native orchestrator path.

it.skipIf(engine === 'native')('super-dispatch: emits Lion.speak → Animal.speak', () => {
it('super-dispatch: emits Lion.speak → Animal.speak', () => {
const edge = callEdges.find(
(e) =>
e.caller_name === 'Lion.speak' &&
Expand Down
Loading