Skip to content

Commit 2b9fb3f

Browse files
committed
fix: close edge gap in watcher's single-file rebuild (#533)
rebuildFile (used by watch mode) deleted all edges for a changed file but only rebuilt the file's own outgoing edges — incoming edges from other files were lost. This produced ~3.3% fewer edges than a full build. Root causes fixed: - No reverse-dep cascade: files importing the changed file never had their outgoing edges rebuilt after the changed file's node IDs changed. Added findReverseDeps + two-pass rebuild (direct edges first, then barrel resolution) to match the build pipeline's behavior. - Missing child nodes: insertFileNodes skipped def.children (parameters, properties), losing contains/parameter_of edges. - Missing containment edges: file→symbol and dir→file contains edges were never created by the watcher path. - Missing ancillary table cleanup: function_complexity, cfg_blocks, etc. had FK references to old nodes, causing SQLITE_CONSTRAINT_FOREIGNKEY on node deletion. Added purgeAncillaryData before node deletion. - No barrel resolution: import edges through re-export chains (barrel files) were not resolved, losing transitive import edges.
1 parent 54e6115 commit 2b9fb3f

12 files changed

Lines changed: 633 additions & 3 deletions

File tree

src/domain/graph/builder/incremental.js

Lines changed: 270 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
*
44
* Reuses pipeline helpers instead of duplicating node insertion and edge building
55
* logic from the main builder. This eliminates the watcher.js divergence (ROADMAP 3.9).
6+
*
7+
* Reverse-dep cascade: when a file changes, files that have edges targeting it
8+
* must have their outgoing edges rebuilt (since the changed file's node IDs change).
69
*/
710
import fs from 'node:fs';
811
import path from 'node:path';
12+
import { bulkNodeIdsByFile } from '../../../db/index.js';
913
import { warn } from '../../../infrastructure/logger.js';
1014
import { normalizePath } from '../../../shared/constants.js';
1115
import { parseFileIncremental } from '../../parser.js';
@@ -18,15 +22,204 @@ function insertFileNodes(stmts, relPath, symbols) {
1822
stmts.insertNode.run(relPath, 'file', relPath, 0, null);
1923
for (const def of symbols.definitions) {
2024
stmts.insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null);
25+
if (def.children?.length) {
26+
for (const child of def.children) {
27+
stmts.insertNode.run(
28+
child.name,
29+
child.kind,
30+
relPath,
31+
child.line,
32+
child.endLine || null,
33+
);
34+
}
35+
}
2136
}
2237
for (const exp of symbols.exports) {
2338
stmts.insertNode.run(exp.name, exp.kind, relPath, exp.line, null);
2439
}
2540
}
2641

42+
// ── Containment edges ──────────────────────────────────────────────────
43+
44+
function buildContainmentEdges(db, stmts, relPath, symbols) {
45+
const nodeIdMap = new Map();
46+
for (const row of bulkNodeIdsByFile(db, relPath)) {
47+
nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
48+
}
49+
const fileId = nodeIdMap.get(`${relPath}|file|0`);
50+
let edgesAdded = 0;
51+
for (const def of symbols.definitions) {
52+
const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
53+
if (fileId && defId) {
54+
stmts.insertEdge.run(fileId, defId, 'contains', 1.0, 0);
55+
edgesAdded++;
56+
}
57+
if (def.children?.length && defId) {
58+
for (const child of def.children) {
59+
const childId = nodeIdMap.get(`${child.name}|${child.kind}|${child.line}`);
60+
if (childId) {
61+
stmts.insertEdge.run(defId, childId, 'contains', 1.0, 0);
62+
edgesAdded++;
63+
if (child.kind === 'parameter') {
64+
stmts.insertEdge.run(childId, defId, 'parameter_of', 1.0, 0);
65+
edgesAdded++;
66+
}
67+
}
68+
}
69+
}
70+
}
71+
return edgesAdded;
72+
}
73+
74+
// ── Reverse-dep cascade ────────────────────────────────────────────────
75+
76+
function findReverseDeps(db, relPath) {
77+
return db
78+
.prepare(
79+
`SELECT DISTINCT n_src.file FROM edges e
80+
JOIN nodes n_src ON e.source_id = n_src.id
81+
JOIN nodes n_tgt ON e.target_id = n_tgt.id
82+
WHERE n_tgt.file = ? AND n_src.file != ? AND n_src.kind != 'directory'`,
83+
)
84+
.all(relPath, relPath)
85+
.map((r) => r.file);
86+
}
87+
88+
function deleteOutgoingEdges(db, relPath) {
89+
db.prepare('DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)').run(
90+
relPath,
91+
);
92+
}
93+
94+
async function parseReverseDep(rootDir, depRelPath, engineOpts, cache) {
95+
const absPath = path.join(rootDir, depRelPath);
96+
if (!fs.existsSync(absPath)) return null;
97+
98+
let code;
99+
try {
100+
code = readFileSafe(absPath);
101+
} catch {
102+
return null;
103+
}
104+
105+
return parseFileIncremental(cache, absPath, code, engineOpts);
106+
}
107+
108+
function rebuildReverseDepEdges(db, rootDir, depRelPath, symbols, stmts, skipBarrel) {
109+
const fileNodeRow = stmts.getNodeId.get(depRelPath, 'file', depRelPath, 0);
110+
if (!fileNodeRow) return 0;
111+
112+
const aliases = { baseUrl: null, paths: {} };
113+
let edgesAdded = buildContainmentEdges(db, stmts, depRelPath, symbols);
114+
// Don't rebuild dir→file containment for reverse-deps (it was never deleted)
115+
edgesAdded += buildImportEdges(
116+
stmts,
117+
depRelPath,
118+
symbols,
119+
rootDir,
120+
fileNodeRow.id,
121+
aliases,
122+
skipBarrel ? null : db,
123+
);
124+
const importedNames = buildImportedNamesMap(symbols, rootDir, depRelPath, aliases);
125+
edgesAdded += buildCallEdges(stmts, depRelPath, symbols, fileNodeRow, importedNames);
126+
return edgesAdded;
127+
}
128+
129+
// ── Directory containment edges ────────────────────────────────────────
130+
131+
function rebuildDirContainment(db, stmts, relPath) {
132+
const dir = normalizePath(path.dirname(relPath));
133+
if (!dir || dir === '.') return 0;
134+
const dirRow = stmts.getNodeId.get(dir, 'directory', dir, 0);
135+
const fileRow = stmts.getNodeId.get(relPath, 'file', relPath, 0);
136+
if (dirRow && fileRow) {
137+
stmts.insertEdge.run(dirRow.id, fileRow.id, 'contains', 1.0, 0);
138+
return 1;
139+
}
140+
return 0;
141+
}
142+
143+
// ── Ancillary table cleanup ────────────────────────────────────────────
144+
145+
function purgeAncillaryData(db, relPath) {
146+
const tryExec = (sql, ...args) => {
147+
try {
148+
db.prepare(sql).run(...args);
149+
} catch {
150+
/* table may not exist */
151+
}
152+
};
153+
tryExec(
154+
'DELETE FROM function_complexity WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
155+
relPath,
156+
);
157+
tryExec(
158+
'DELETE FROM node_metrics WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
159+
relPath,
160+
);
161+
tryExec(
162+
'DELETE FROM cfg_edges WHERE function_node_id IN (SELECT id FROM nodes WHERE file = ?)',
163+
relPath,
164+
);
165+
tryExec(
166+
'DELETE FROM cfg_blocks WHERE function_node_id IN (SELECT id FROM nodes WHERE file = ?)',
167+
relPath,
168+
);
169+
tryExec(
170+
'DELETE FROM dataflow WHERE source_id IN (SELECT id FROM nodes WHERE file = ?) OR target_id IN (SELECT id FROM nodes WHERE file = ?)',
171+
relPath,
172+
relPath,
173+
);
174+
tryExec('DELETE FROM ast_nodes WHERE file = ?', relPath);
175+
}
176+
27177
// ── Import edge building ────────────────────────────────────────────────
28178

29-
function buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeId, aliases) {
179+
function isBarrelFile(db, relPath) {
180+
const reexportCount = db
181+
.prepare(
182+
`SELECT COUNT(*) as c FROM edges e
183+
JOIN nodes n ON e.source_id = n.id
184+
WHERE e.kind = 'reexports' AND n.file = ? AND n.kind = 'file'`,
185+
)
186+
.get(relPath)?.c;
187+
return (reexportCount || 0) > 0;
188+
}
189+
190+
function resolveBarrelTarget(db, barrelPath, symbolName, visited = new Set()) {
191+
if (visited.has(barrelPath)) return null;
192+
visited.add(barrelPath);
193+
194+
// Find re-export targets from this barrel
195+
const reexportTargets = db
196+
.prepare(
197+
`SELECT DISTINCT n2.file FROM edges e
198+
JOIN nodes n1 ON e.source_id = n1.id
199+
JOIN nodes n2 ON e.target_id = n2.id
200+
WHERE e.kind = 'reexports' AND n1.file = ? AND n1.kind = 'file'`,
201+
)
202+
.all(barrelPath);
203+
204+
for (const { file: targetFile } of reexportTargets) {
205+
// Check if the symbol is defined in this target file
206+
const hasDef = db
207+
.prepare(
208+
`SELECT 1 FROM nodes WHERE name = ? AND file = ? AND kind != 'file' AND kind != 'directory' LIMIT 1`,
209+
)
210+
.get(symbolName, targetFile);
211+
if (hasDef) return targetFile;
212+
213+
// Recurse through barrel chains
214+
if (isBarrelFile(db, targetFile)) {
215+
const deeper = resolveBarrelTarget(db, targetFile, symbolName, visited);
216+
if (deeper) return deeper;
217+
}
218+
}
219+
return null;
220+
}
221+
222+
function buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeId, aliases, db) {
30223
let edgesAdded = 0;
31224
for (const imp of symbols.imports) {
32225
const resolvedPath = resolveImportPath(
@@ -40,6 +233,24 @@ function buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeId, aliases)
40233
const edgeKind = imp.reexport ? 'reexports' : imp.typeOnly ? 'imports-type' : 'imports';
41234
stmts.insertEdge.run(fileNodeId, targetRow.id, edgeKind, 1.0, 0);
42235
edgesAdded++;
236+
237+
// Barrel resolution: create edges through re-export chains
238+
if (!imp.reexport && db && isBarrelFile(db, resolvedPath)) {
239+
const resolvedSources = new Set();
240+
for (const name of imp.names) {
241+
const cleanName = name.replace(/^\*\s+as\s+/, '');
242+
const actualSource = resolveBarrelTarget(db, resolvedPath, cleanName);
243+
if (actualSource && actualSource !== resolvedPath && !resolvedSources.has(actualSource)) {
244+
resolvedSources.add(actualSource);
245+
const actualRow = stmts.getNodeId.get(actualSource, 'file', actualSource, 0);
246+
if (actualRow) {
247+
const kind = edgeKind === 'imports-type' ? 'imports-type' : 'imports';
248+
stmts.insertEdge.run(fileNodeId, actualRow.id, kind, 0.9, 0);
249+
edgesAdded++;
250+
}
251+
}
252+
}
253+
}
43254
}
44255
}
45256
return edgesAdded;
@@ -156,12 +367,17 @@ function buildCallEdges(stmts, relPath, symbols, fileNodeRow, importedNames) {
156367
* @param {Function} [options.diffSymbols] - Symbol diff function
157368
* @returns {Promise<object|null>} Update result or null on failure
158369
*/
159-
export async function rebuildFile(_db, rootDir, filePath, stmts, engineOpts, cache, options = {}) {
370+
export async function rebuildFile(db, rootDir, filePath, stmts, engineOpts, cache, options = {}) {
160371
const { diffSymbols } = options;
161372
const relPath = normalizePath(path.relative(rootDir, filePath));
162373
const oldNodes = stmts.countNodes.get(relPath)?.c || 0;
163374
const oldSymbols = diffSymbols ? stmts.listSymbols.all(relPath) : [];
164375

376+
// Find reverse-deps BEFORE purging (edges still reference the old nodes)
377+
const reverseDeps = findReverseDeps(db, relPath);
378+
379+
// Purge ancillary tables, then edges, then nodes
380+
purgeAncillaryData(db, relPath);
165381
stmts.deleteEdgesForFile.run(relPath);
166382
stmts.deleteNodes.run(relPath);
167383

@@ -203,10 +419,61 @@ export async function rebuildFile(_db, rootDir, filePath, stmts, engineOpts, cac
203419

204420
const aliases = { baseUrl: null, paths: {} };
205421

206-
let edgesAdded = buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeRow.id, aliases);
422+
let edgesAdded = buildContainmentEdges(db, stmts, relPath, symbols);
423+
edgesAdded += rebuildDirContainment(db, stmts, relPath);
424+
edgesAdded += buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeRow.id, aliases, db);
207425
const importedNames = buildImportedNamesMap(symbols, rootDir, relPath, aliases);
208426
edgesAdded += buildCallEdges(stmts, relPath, symbols, fileNodeRow, importedNames);
209427

428+
// Cascade: rebuild outgoing edges for reverse-dep files.
429+
// Two-pass approach: first rebuild direct edges (creating reexports edges for barrels),
430+
// then add barrel import edges (which need reexports edges to exist for resolution).
431+
const depSymbols = new Map();
432+
for (const depRelPath of reverseDeps) {
433+
deleteOutgoingEdges(db, depRelPath);
434+
const symbols_ = await parseReverseDep(rootDir, depRelPath, engineOpts, cache);
435+
if (symbols_) depSymbols.set(depRelPath, symbols_);
436+
}
437+
// Pass 1: direct edges only (no barrel resolution) — creates reexports edges
438+
for (const [depRelPath, symbols_] of depSymbols) {
439+
edgesAdded += rebuildReverseDepEdges(db, rootDir, depRelPath, symbols_, stmts, true);
440+
}
441+
// Pass 2: add barrel import edges (reexports edges now exist)
442+
for (const [depRelPath, symbols_] of depSymbols) {
443+
const fileNodeRow_ = stmts.getNodeId.get(depRelPath, 'file', depRelPath, 0);
444+
if (!fileNodeRow_) continue;
445+
const aliases_ = { baseUrl: null, paths: {} };
446+
for (const imp of symbols_.imports) {
447+
if (imp.reexport) continue;
448+
const resolvedPath = resolveImportPath(
449+
path.join(rootDir, depRelPath),
450+
imp.source,
451+
rootDir,
452+
aliases_,
453+
);
454+
if (db && isBarrelFile(db, resolvedPath)) {
455+
const resolvedSources = new Set();
456+
for (const name of imp.names) {
457+
const cleanName = name.replace(/^\*\s+as\s+/, '');
458+
const actualSource = resolveBarrelTarget(db, resolvedPath, cleanName);
459+
if (
460+
actualSource &&
461+
actualSource !== resolvedPath &&
462+
!resolvedSources.has(actualSource)
463+
) {
464+
resolvedSources.add(actualSource);
465+
const actualRow = stmts.getNodeId.get(actualSource, 'file', actualSource, 0);
466+
if (actualRow) {
467+
const kind = imp.typeOnly ? 'imports-type' : 'imports';
468+
stmts.insertEdge.run(fileNodeRow_.id, actualRow.id, kind, 0.9, 0);
469+
edgesAdded++;
470+
}
471+
}
472+
}
473+
}
474+
}
475+
}
476+
210477
const symbolDiff = diffSymbols ? diffSymbols(oldSymbols, newSymbols) : null;
211478
const event = oldNodes === 0 ? 'added' : 'modified';
212479

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { runQuery } from './features/query.js';
2+
import { formatOutput } from './features/format.js';
3+
import { MAX_ITEMS } from './shared/constants.js';
4+
5+
export function main(input, page) {
6+
const results = runQuery(input, page);
7+
const label = formatOutput(input);
8+
return { label, results, max: MAX_ITEMS };
9+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { parseItems } from './parser.js';
2+
export { resolve } from './resolver.js';
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { MAX_ITEMS, clamp } from '../shared/index.js';
2+
3+
export function parseItems(raw) {
4+
const items = raw.split(',').map(s => s.trim());
5+
return items.slice(0, clamp(items.length, 0, MAX_ITEMS));
6+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { DEFAULT_NAME } from '../shared/constants.js';
2+
import { formatName } from '../shared/helpers.js';
3+
4+
export function resolve(input) {
5+
const name = input || DEFAULT_NAME;
6+
return formatName(name);
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { resolve } from '../domain/resolver.js';
2+
import { DEFAULT_NAME } from '../shared/index.js';
3+
4+
export function formatOutput(input) {
5+
const resolved = resolve(input);
6+
return resolved === DEFAULT_NAME ? 'default' : resolved;
7+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { parseItems } from '../domain/index.js';
2+
import { paginate } from '../shared/helpers.js';
3+
import { clamp } from '../shared/constants.js';
4+
5+
export function runQuery(raw, page) {
6+
const items = parseItems(raw);
7+
const safePage = clamp(page, 0, 100);
8+
return paginate(items, safePage, 10);
9+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// The deeply-imported leaf file
2+
export const MAX_ITEMS = 100;
3+
export const DEFAULT_NAME = 'codegraph';
4+
5+
export function clamp(value, min, max) {
6+
return Math.min(Math.max(value, min), max);
7+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { clamp, MAX_ITEMS } from './constants.js';
2+
3+
export function paginate(items, page, size) {
4+
const safeSize = clamp(size, 1, MAX_ITEMS);
5+
const start = page * safeSize;
6+
return items.slice(start, start + safeSize);
7+
}
8+
9+
export function formatName(name) {
10+
return name.trim().toLowerCase();
11+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { MAX_ITEMS, DEFAULT_NAME, clamp } from './constants.js';
2+
export { paginate, formatName } from './helpers.js';

0 commit comments

Comments
 (0)