Skip to content
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3892e7d
chore: gitignore napi-generated artifacts in crates/codegraph-core
carlos-alm Jun 13, 2026
ef8ea4f
chore(tests): remove unused biome suppression in visitor.test.ts
carlos-alm Jun 13, 2026
a372b82
fix(titan-run): sync --start-from enum and phase-timestamp list with …
carlos-alm Jun 13, 2026
9a52c7c
fix(hooks): track Bash file modifications via before/after git status…
carlos-alm Jun 13, 2026
85a26df
chore(native): remove dead code (unused var, method, variant, fields)
carlos-alm Jun 13, 2026
184d221
refactor(native): extract emit_pts_alias_edges params into PtsAliasCt…
carlos-alm Jun 13, 2026
909e1df
fix(wasm): sort call targets by confidence before emit to match nativ…
carlos-alm Jun 13, 2026
66fc899
fix(bench): add 2 warmup runs and raise INCREMENTAL_RUNS to 5 for inc…
carlos-alm Jun 13, 2026
84e1a5f
ci(bench): add per-PR perf canary for extractor/graph/native changes
carlos-alm Jun 13, 2026
d07b358
fix(perf): plumb symbolsOnly through parseFilesWasmInline to skip ana…
carlos-alm Jun 13, 2026
3db5d8c
fix(perf): scope runPostNativeCha to changed files on incremental builds
carlos-alm Jun 13, 2026
d70a9ab
Merge remote-tracking branch 'origin/main' into fix/cha-incremental-s…
carlos-alm Jun 13, 2026
a79855e
fix(perf): broaden Gate B to cover constructor/function-kind RTA fall…
carlos-alm Jun 13, 2026
76e5910
docs(native): document Gate A deletion-safety invariant in runPostNat…
carlos-alm Jun 13, 2026
ea219c9
chore: merge origin/main into fix/cha-incremental-scope-1441
carlos-alm Jun 13, 2026
b2aee93
chore: merge origin/main into fix/cha-incremental-scope-1441
carlos-alm Jun 13, 2026
41c635d
fix: resolve merge conflict with main in perf-canary.yml
carlos-alm Jun 13, 2026
1aed8fb
fix: resolve merge conflicts with main
carlos-alm Jun 13, 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
148 changes: 137 additions & 11 deletions src/domain/graph/builder/stages/native-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,12 +401,28 @@ async function runPostNativeAnalysis(
* Note: `this`/`super` dispatch is handled separately by `runPostNativeThisDispatch`,
* which WASM-re-parses JS/TS files to obtain raw call site receiver info.
*
* `changedFiles` controls candidate scoping on incremental builds:
* - null → full build; scan all call→method edges (existing behaviour).
* - array → incremental; two cheap gate queries decide scope:
* Gate A: any class/interface/trait/struct/record nodes in changed files?
* If yes, a new implementor may have appeared — full scan required.
* Gate B: any `calls` edges from changed-file sources targeting
* class/constructor/function-kind nodes? If yes, the RTA set may
* have grown (also covers the older-schema fallback where
* constructor calls target `constructor`/`function` nodes instead
* of `class` nodes) — full scan required.
* If neither gate fires: scope `callToMethods` to `src.file IN changedFiles`
* (safe because no hierarchy or RTA evidence changed).
*
* Returns the count of newly inserted CHA edges plus the set of files containing
* the new edges' endpoints, so the caller can scope role re-classification to the
* nodes whose fan-in/out actually changed. A zero count means no edges were added
* and role re-classification is unnecessary.
*/
function runPostNativeCha(db: BetterSqlite3Database): {
function runPostNativeCha(
db: BetterSqlite3Database,
changedFiles: string[] | null,
): {
newEdgeCount: number;
affectedFiles: Set<string>;
} {
Expand Down Expand Up @@ -474,19 +490,127 @@ function runPostNativeCha(db: BetterSqlite3Database): {
debug('runPostNativeCha: no constructor-call evidence found — proceeding without RTA filter');
}

// ── Incremental candidate scoping ──────────────────────────────────────────
// On incremental builds, two gate queries decide whether to restrict the
// candidate scan to changed-file call sites or run the full graph scan.
//
// Gate A: did a changed file add/change a class hierarchy node?
// A new `extends`/`implements` edge means a previously-untracked implementor
// is now in the hierarchy — unchanged call sites in OTHER files may gain new
// valid expansions, so the full scan is required.
// Note: *removed* class nodes are safe — Rust's `purge_changed_files` runs
// before this post-pass and deletes stale nodes and their hierarchy edges, so
// Gate A queries the post-purge DB. A deleted class returns no row here, which
// is correct: its stale CHA edges were already cleaned up by the Rust purge.
//
// Gate B: did a changed file add new RTA evidence (`new ConcreteX()`)?
// A new `calls` edge to a class/constructor/function-kind target means the
// instantiated set grew — previously RTA-filtered expansions in unchanged
// caller files become admissible, so the full scan is required.
// (`constructor`/`function` cover the older native engine fallback schema.)
//
// If neither gate fires, the hierarchy and RTA set are unchanged for all files
// outside changedFiles, so restricting to changed-file sources is safe.
let scopeToChangedFiles = false; // true → add WHERE src.file IN changedFiles
if (changedFiles !== null && changedFiles.length > 0) {
// Gate A: class/interface/trait/struct/record nodes in changed files?
const CHUNK_SIZE = 500;
let gateAFired = false;
for (let i = 0; i < changedFiles.length && !gateAFired; i += CHUNK_SIZE) {
const chunk = changedFiles.slice(i, i + CHUNK_SIZE);
const ph = chunk.map(() => '?').join(',');
const row = db
.prepare(
`SELECT 1 FROM nodes
WHERE file IN (${ph})
AND kind IN ('class', 'interface', 'trait', 'struct', 'record')
LIMIT 1`,
)
.get(...chunk);
if (row) gateAFired = true;
}

// Gate B: calls from changed-file sources to class-kind targets (or
// constructor/function-kind targets in the older native engine fallback schema)?
// Mirrors the two-shape RTA seed: primary checks `tgt.kind = 'class'`; older
// native engine schemas record constructor calls against `constructor`/`function`
// kinds instead. Including all three kinds here prevents Gate B from silently
// passing on older-schema DBs, which would incorrectly set scopeToChangedFiles
// and miss CHA edges whose RTA evidence lives in the fallback-schema rows.
let gateBFired = false;
if (!gateAFired) {
for (let i = 0; i < changedFiles.length && !gateBFired; i += CHUNK_SIZE) {
const chunk = changedFiles.slice(i, i + CHUNK_SIZE);
const ph = chunk.map(() => '?').join(',');
const row = db
.prepare(
`SELECT 1 FROM edges e
JOIN nodes src ON e.source_id = src.id
JOIN nodes tgt ON e.target_id = tgt.id
WHERE e.kind = 'calls'
AND tgt.kind IN ('class', 'constructor', 'function')
AND src.file IN (${ph})
LIMIT 1`,
)
.get(...chunk);
if (row) gateBFired = true;
}
}

if (!gateAFired && !gateBFired) {
scopeToChangedFiles = true;
debug(
`runPostNativeCha: neither gate fired — scoping candidate scan to ${changedFiles.length} changed file(s)`,
);
} else {
debug(
`runPostNativeCha: ${gateAFired ? 'Gate A (hierarchy)' : 'Gate B (RTA)'} fired — running full scan`,
);
}
}

// Find existing call edges targeting qualified methods (e.g., 'IWorker.doWork').
// Include the caller node's file so confidence can be computed file-pair-aware,
// matching the WASM path's computeConfidence(callerFile, targetFile, null) - CHA_DISPATCH_PENALTY formula.
const callToMethods = db
.prepare(`
SELECT e.source_id, tgt.name AS method_name, src.file AS caller_file
FROM edges e
JOIN nodes tgt ON e.target_id = tgt.id
JOIN nodes src ON e.source_id = src.id
WHERE e.kind = 'calls' AND tgt.kind = 'method'
AND INSTR(tgt.name, '.') > 0
`)
.all() as Array<{ source_id: number; method_name: string; caller_file: string | null }>;
// When scopeToChangedFiles is true, restrict to call sites in the changed files
// (safe because no hierarchy or RTA evidence changed outside those files).
let callToMethods: Array<{ source_id: number; method_name: string; caller_file: string | null }>;
if (scopeToChangedFiles && changedFiles && changedFiles.length > 0) {
const CHUNK_SIZE = 500;
const rows: Array<{ source_id: number; method_name: string; caller_file: string | null }> = [];
for (let i = 0; i < changedFiles.length; i += CHUNK_SIZE) {
const chunk = changedFiles.slice(i, i + CHUNK_SIZE);
const ph = chunk.map(() => '?').join(',');
const chunkRows = db
.prepare(
`SELECT e.source_id, tgt.name AS method_name, src.file AS caller_file
FROM edges e
JOIN nodes tgt ON e.target_id = tgt.id
JOIN nodes src ON e.source_id = src.id
WHERE e.kind = 'calls' AND tgt.kind = 'method'
AND INSTR(tgt.name, '.') > 0
AND src.file IN (${ph})`,
)
.all(...chunk) as Array<{
source_id: number;
method_name: string;
caller_file: string | null;
}>;
rows.push(...chunkRows);
}
callToMethods = rows;
} else {
callToMethods = db
.prepare(`
SELECT e.source_id, tgt.name AS method_name, src.file AS caller_file
FROM edges e
JOIN nodes tgt ON e.target_id = tgt.id
JOIN nodes src ON e.source_id = src.id
WHERE e.kind = 'calls' AND tgt.kind = 'method'
AND INSTR(tgt.name, '.') > 0
`)
.all() as Array<{ source_id: number; method_name: string; caller_file: string | null }>;
}

// Seed seen-pairs only from the source_ids we'll be expanding — avoids loading every
// call edge in the DB (which would be O(all edges)) for large codebases.
Expand Down Expand Up @@ -1427,6 +1551,8 @@ export async function tryNativeOrchestrator(
// no WASM re-parse post-pass is needed for them. `Foo.prototype.bar = fn` likewise.
const { newEdgeCount: chaEdgeCount, affectedFiles: chaAffectedFiles } = runPostNativeCha(
ctx.db as unknown as BetterSqlite3Database,
// null = full build (scan all call→method edges); array = incremental (gate queries decide scope)
result.isFullBuild ? null : (result.changedFiles ?? null),
);

// Phase 8.5: this/super dispatch — hybrid WASM re-parse to resolve call sites
Expand Down
Loading