From 2b9fb3ffe02540671ca299d5b082226c51cdf4f8 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Thu, 19 Mar 2026 04:09:58 -0600 Subject: [PATCH 1/6] fix: close edge gap in watcher's single-file rebuild (#533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/domain/graph/builder/incremental.js | 273 +++++++++++++++++- tests/fixtures/deep-deps-project/app.js | 9 + .../deep-deps-project/domain/index.js | 2 + .../deep-deps-project/domain/parser.js | 6 + .../deep-deps-project/domain/resolver.js | 7 + .../deep-deps-project/features/format.js | 7 + .../deep-deps-project/features/query.js | 9 + .../deep-deps-project/shared/constants.js | 7 + .../deep-deps-project/shared/helpers.js | 11 + .../deep-deps-project/shared/index.js | 2 + tests/integration/incr-edge-gap.test.js | 135 +++++++++ tests/integration/watcher-rebuild.test.js | 168 +++++++++++ 12 files changed, 633 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/deep-deps-project/app.js create mode 100644 tests/fixtures/deep-deps-project/domain/index.js create mode 100644 tests/fixtures/deep-deps-project/domain/parser.js create mode 100644 tests/fixtures/deep-deps-project/domain/resolver.js create mode 100644 tests/fixtures/deep-deps-project/features/format.js create mode 100644 tests/fixtures/deep-deps-project/features/query.js create mode 100644 tests/fixtures/deep-deps-project/shared/constants.js create mode 100644 tests/fixtures/deep-deps-project/shared/helpers.js create mode 100644 tests/fixtures/deep-deps-project/shared/index.js create mode 100644 tests/integration/incr-edge-gap.test.js create mode 100644 tests/integration/watcher-rebuild.test.js diff --git a/src/domain/graph/builder/incremental.js b/src/domain/graph/builder/incremental.js index 778356242..c5d33b5e6 100644 --- a/src/domain/graph/builder/incremental.js +++ b/src/domain/graph/builder/incremental.js @@ -3,9 +3,13 @@ * * Reuses pipeline helpers instead of duplicating node insertion and edge building * logic from the main builder. This eliminates the watcher.js divergence (ROADMAP 3.9). + * + * Reverse-dep cascade: when a file changes, files that have edges targeting it + * must have their outgoing edges rebuilt (since the changed file's node IDs change). */ import fs from 'node:fs'; import path from 'node:path'; +import { bulkNodeIdsByFile } from '../../../db/index.js'; import { warn } from '../../../infrastructure/logger.js'; import { normalizePath } from '../../../shared/constants.js'; import { parseFileIncremental } from '../../parser.js'; @@ -18,15 +22,204 @@ function insertFileNodes(stmts, relPath, symbols) { stmts.insertNode.run(relPath, 'file', relPath, 0, null); for (const def of symbols.definitions) { stmts.insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null); + if (def.children?.length) { + for (const child of def.children) { + stmts.insertNode.run( + child.name, + child.kind, + relPath, + child.line, + child.endLine || null, + ); + } + } } for (const exp of symbols.exports) { stmts.insertNode.run(exp.name, exp.kind, relPath, exp.line, null); } } +// ── Containment edges ────────────────────────────────────────────────── + +function buildContainmentEdges(db, stmts, relPath, symbols) { + const nodeIdMap = new Map(); + for (const row of bulkNodeIdsByFile(db, relPath)) { + nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id); + } + const fileId = nodeIdMap.get(`${relPath}|file|0`); + let edgesAdded = 0; + for (const def of symbols.definitions) { + const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`); + if (fileId && defId) { + stmts.insertEdge.run(fileId, defId, 'contains', 1.0, 0); + edgesAdded++; + } + if (def.children?.length && defId) { + for (const child of def.children) { + const childId = nodeIdMap.get(`${child.name}|${child.kind}|${child.line}`); + if (childId) { + stmts.insertEdge.run(defId, childId, 'contains', 1.0, 0); + edgesAdded++; + if (child.kind === 'parameter') { + stmts.insertEdge.run(childId, defId, 'parameter_of', 1.0, 0); + edgesAdded++; + } + } + } + } + } + return edgesAdded; +} + +// ── Reverse-dep cascade ──────────────────────────────────────────────── + +function findReverseDeps(db, relPath) { + return db + .prepare( + `SELECT DISTINCT n_src.file FROM edges e + JOIN nodes n_src ON e.source_id = n_src.id + JOIN nodes n_tgt ON e.target_id = n_tgt.id + WHERE n_tgt.file = ? AND n_src.file != ? AND n_src.kind != 'directory'`, + ) + .all(relPath, relPath) + .map((r) => r.file); +} + +function deleteOutgoingEdges(db, relPath) { + db.prepare('DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)').run( + relPath, + ); +} + +async function parseReverseDep(rootDir, depRelPath, engineOpts, cache) { + const absPath = path.join(rootDir, depRelPath); + if (!fs.existsSync(absPath)) return null; + + let code; + try { + code = readFileSafe(absPath); + } catch { + return null; + } + + return parseFileIncremental(cache, absPath, code, engineOpts); +} + +function rebuildReverseDepEdges(db, rootDir, depRelPath, symbols, stmts, skipBarrel) { + const fileNodeRow = stmts.getNodeId.get(depRelPath, 'file', depRelPath, 0); + if (!fileNodeRow) return 0; + + const aliases = { baseUrl: null, paths: {} }; + let edgesAdded = buildContainmentEdges(db, stmts, depRelPath, symbols); + // Don't rebuild dir→file containment for reverse-deps (it was never deleted) + edgesAdded += buildImportEdges( + stmts, + depRelPath, + symbols, + rootDir, + fileNodeRow.id, + aliases, + skipBarrel ? null : db, + ); + const importedNames = buildImportedNamesMap(symbols, rootDir, depRelPath, aliases); + edgesAdded += buildCallEdges(stmts, depRelPath, symbols, fileNodeRow, importedNames); + return edgesAdded; +} + +// ── Directory containment edges ──────────────────────────────────────── + +function rebuildDirContainment(db, stmts, relPath) { + const dir = normalizePath(path.dirname(relPath)); + if (!dir || dir === '.') return 0; + const dirRow = stmts.getNodeId.get(dir, 'directory', dir, 0); + const fileRow = stmts.getNodeId.get(relPath, 'file', relPath, 0); + if (dirRow && fileRow) { + stmts.insertEdge.run(dirRow.id, fileRow.id, 'contains', 1.0, 0); + return 1; + } + return 0; +} + +// ── Ancillary table cleanup ──────────────────────────────────────────── + +function purgeAncillaryData(db, relPath) { + const tryExec = (sql, ...args) => { + try { + db.prepare(sql).run(...args); + } catch { + /* table may not exist */ + } + }; + tryExec( + 'DELETE FROM function_complexity WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)', + relPath, + ); + tryExec( + 'DELETE FROM node_metrics WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)', + relPath, + ); + tryExec( + 'DELETE FROM cfg_edges WHERE function_node_id IN (SELECT id FROM nodes WHERE file = ?)', + relPath, + ); + tryExec( + 'DELETE FROM cfg_blocks WHERE function_node_id IN (SELECT id FROM nodes WHERE file = ?)', + relPath, + ); + tryExec( + 'DELETE FROM dataflow WHERE source_id IN (SELECT id FROM nodes WHERE file = ?) OR target_id IN (SELECT id FROM nodes WHERE file = ?)', + relPath, + relPath, + ); + tryExec('DELETE FROM ast_nodes WHERE file = ?', relPath); +} + // ── Import edge building ──────────────────────────────────────────────── -function buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeId, aliases) { +function isBarrelFile(db, relPath) { + const reexportCount = db + .prepare( + `SELECT COUNT(*) as c FROM edges e + JOIN nodes n ON e.source_id = n.id + WHERE e.kind = 'reexports' AND n.file = ? AND n.kind = 'file'`, + ) + .get(relPath)?.c; + return (reexportCount || 0) > 0; +} + +function resolveBarrelTarget(db, barrelPath, symbolName, visited = new Set()) { + if (visited.has(barrelPath)) return null; + visited.add(barrelPath); + + // Find re-export targets from this barrel + const reexportTargets = db + .prepare( + `SELECT DISTINCT n2.file FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind = 'reexports' AND n1.file = ? AND n1.kind = 'file'`, + ) + .all(barrelPath); + + for (const { file: targetFile } of reexportTargets) { + // Check if the symbol is defined in this target file + const hasDef = db + .prepare( + `SELECT 1 FROM nodes WHERE name = ? AND file = ? AND kind != 'file' AND kind != 'directory' LIMIT 1`, + ) + .get(symbolName, targetFile); + if (hasDef) return targetFile; + + // Recurse through barrel chains + if (isBarrelFile(db, targetFile)) { + const deeper = resolveBarrelTarget(db, targetFile, symbolName, visited); + if (deeper) return deeper; + } + } + return null; +} + +function buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeId, aliases, db) { let edgesAdded = 0; for (const imp of symbols.imports) { const resolvedPath = resolveImportPath( @@ -40,6 +233,24 @@ function buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeId, aliases) const edgeKind = imp.reexport ? 'reexports' : imp.typeOnly ? 'imports-type' : 'imports'; stmts.insertEdge.run(fileNodeId, targetRow.id, edgeKind, 1.0, 0); edgesAdded++; + + // Barrel resolution: create edges through re-export chains + if (!imp.reexport && db && isBarrelFile(db, resolvedPath)) { + const resolvedSources = new Set(); + for (const name of imp.names) { + const cleanName = name.replace(/^\*\s+as\s+/, ''); + const actualSource = resolveBarrelTarget(db, resolvedPath, cleanName); + if (actualSource && actualSource !== resolvedPath && !resolvedSources.has(actualSource)) { + resolvedSources.add(actualSource); + const actualRow = stmts.getNodeId.get(actualSource, 'file', actualSource, 0); + if (actualRow) { + const kind = edgeKind === 'imports-type' ? 'imports-type' : 'imports'; + stmts.insertEdge.run(fileNodeId, actualRow.id, kind, 0.9, 0); + edgesAdded++; + } + } + } + } } } return edgesAdded; @@ -156,12 +367,17 @@ function buildCallEdges(stmts, relPath, symbols, fileNodeRow, importedNames) { * @param {Function} [options.diffSymbols] - Symbol diff function * @returns {Promise} Update result or null on failure */ -export async function rebuildFile(_db, rootDir, filePath, stmts, engineOpts, cache, options = {}) { +export async function rebuildFile(db, rootDir, filePath, stmts, engineOpts, cache, options = {}) { const { diffSymbols } = options; const relPath = normalizePath(path.relative(rootDir, filePath)); const oldNodes = stmts.countNodes.get(relPath)?.c || 0; const oldSymbols = diffSymbols ? stmts.listSymbols.all(relPath) : []; + // Find reverse-deps BEFORE purging (edges still reference the old nodes) + const reverseDeps = findReverseDeps(db, relPath); + + // Purge ancillary tables, then edges, then nodes + purgeAncillaryData(db, relPath); stmts.deleteEdgesForFile.run(relPath); stmts.deleteNodes.run(relPath); @@ -203,10 +419,61 @@ export async function rebuildFile(_db, rootDir, filePath, stmts, engineOpts, cac const aliases = { baseUrl: null, paths: {} }; - let edgesAdded = buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeRow.id, aliases); + let edgesAdded = buildContainmentEdges(db, stmts, relPath, symbols); + edgesAdded += rebuildDirContainment(db, stmts, relPath); + edgesAdded += buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeRow.id, aliases, db); const importedNames = buildImportedNamesMap(symbols, rootDir, relPath, aliases); edgesAdded += buildCallEdges(stmts, relPath, symbols, fileNodeRow, importedNames); + // Cascade: rebuild outgoing edges for reverse-dep files. + // Two-pass approach: first rebuild direct edges (creating reexports edges for barrels), + // then add barrel import edges (which need reexports edges to exist for resolution). + const depSymbols = new Map(); + for (const depRelPath of reverseDeps) { + deleteOutgoingEdges(db, depRelPath); + const symbols_ = await parseReverseDep(rootDir, depRelPath, engineOpts, cache); + if (symbols_) depSymbols.set(depRelPath, symbols_); + } + // Pass 1: direct edges only (no barrel resolution) — creates reexports edges + for (const [depRelPath, symbols_] of depSymbols) { + edgesAdded += rebuildReverseDepEdges(db, rootDir, depRelPath, symbols_, stmts, true); + } + // Pass 2: add barrel import edges (reexports edges now exist) + for (const [depRelPath, symbols_] of depSymbols) { + const fileNodeRow_ = stmts.getNodeId.get(depRelPath, 'file', depRelPath, 0); + if (!fileNodeRow_) continue; + const aliases_ = { baseUrl: null, paths: {} }; + for (const imp of symbols_.imports) { + if (imp.reexport) continue; + const resolvedPath = resolveImportPath( + path.join(rootDir, depRelPath), + imp.source, + rootDir, + aliases_, + ); + if (db && isBarrelFile(db, resolvedPath)) { + const resolvedSources = new Set(); + for (const name of imp.names) { + const cleanName = name.replace(/^\*\s+as\s+/, ''); + const actualSource = resolveBarrelTarget(db, resolvedPath, cleanName); + if ( + actualSource && + actualSource !== resolvedPath && + !resolvedSources.has(actualSource) + ) { + resolvedSources.add(actualSource); + const actualRow = stmts.getNodeId.get(actualSource, 'file', actualSource, 0); + if (actualRow) { + const kind = imp.typeOnly ? 'imports-type' : 'imports'; + stmts.insertEdge.run(fileNodeRow_.id, actualRow.id, kind, 0.9, 0); + edgesAdded++; + } + } + } + } + } + } + const symbolDiff = diffSymbols ? diffSymbols(oldSymbols, newSymbols) : null; const event = oldNodes === 0 ? 'added' : 'modified'; diff --git a/tests/fixtures/deep-deps-project/app.js b/tests/fixtures/deep-deps-project/app.js new file mode 100644 index 000000000..834d3438c --- /dev/null +++ b/tests/fixtures/deep-deps-project/app.js @@ -0,0 +1,9 @@ +import { runQuery } from './features/query.js'; +import { formatOutput } from './features/format.js'; +import { MAX_ITEMS } from './shared/constants.js'; + +export function main(input, page) { + const results = runQuery(input, page); + const label = formatOutput(input); + return { label, results, max: MAX_ITEMS }; +} diff --git a/tests/fixtures/deep-deps-project/domain/index.js b/tests/fixtures/deep-deps-project/domain/index.js new file mode 100644 index 000000000..18ae4e72e --- /dev/null +++ b/tests/fixtures/deep-deps-project/domain/index.js @@ -0,0 +1,2 @@ +export { parseItems } from './parser.js'; +export { resolve } from './resolver.js'; diff --git a/tests/fixtures/deep-deps-project/domain/parser.js b/tests/fixtures/deep-deps-project/domain/parser.js new file mode 100644 index 000000000..090357515 --- /dev/null +++ b/tests/fixtures/deep-deps-project/domain/parser.js @@ -0,0 +1,6 @@ +import { MAX_ITEMS, clamp } from '../shared/index.js'; + +export function parseItems(raw) { + const items = raw.split(',').map(s => s.trim()); + return items.slice(0, clamp(items.length, 0, MAX_ITEMS)); +} diff --git a/tests/fixtures/deep-deps-project/domain/resolver.js b/tests/fixtures/deep-deps-project/domain/resolver.js new file mode 100644 index 000000000..619c2ca33 --- /dev/null +++ b/tests/fixtures/deep-deps-project/domain/resolver.js @@ -0,0 +1,7 @@ +import { DEFAULT_NAME } from '../shared/constants.js'; +import { formatName } from '../shared/helpers.js'; + +export function resolve(input) { + const name = input || DEFAULT_NAME; + return formatName(name); +} diff --git a/tests/fixtures/deep-deps-project/features/format.js b/tests/fixtures/deep-deps-project/features/format.js new file mode 100644 index 000000000..17e6419ea --- /dev/null +++ b/tests/fixtures/deep-deps-project/features/format.js @@ -0,0 +1,7 @@ +import { resolve } from '../domain/resolver.js'; +import { DEFAULT_NAME } from '../shared/index.js'; + +export function formatOutput(input) { + const resolved = resolve(input); + return resolved === DEFAULT_NAME ? 'default' : resolved; +} diff --git a/tests/fixtures/deep-deps-project/features/query.js b/tests/fixtures/deep-deps-project/features/query.js new file mode 100644 index 000000000..954cf8837 --- /dev/null +++ b/tests/fixtures/deep-deps-project/features/query.js @@ -0,0 +1,9 @@ +import { parseItems } from '../domain/index.js'; +import { paginate } from '../shared/helpers.js'; +import { clamp } from '../shared/constants.js'; + +export function runQuery(raw, page) { + const items = parseItems(raw); + const safePage = clamp(page, 0, 100); + return paginate(items, safePage, 10); +} diff --git a/tests/fixtures/deep-deps-project/shared/constants.js b/tests/fixtures/deep-deps-project/shared/constants.js new file mode 100644 index 000000000..8228aa8a5 --- /dev/null +++ b/tests/fixtures/deep-deps-project/shared/constants.js @@ -0,0 +1,7 @@ +// The deeply-imported leaf file +export const MAX_ITEMS = 100; +export const DEFAULT_NAME = 'codegraph'; + +export function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} diff --git a/tests/fixtures/deep-deps-project/shared/helpers.js b/tests/fixtures/deep-deps-project/shared/helpers.js new file mode 100644 index 000000000..eeef13fbb --- /dev/null +++ b/tests/fixtures/deep-deps-project/shared/helpers.js @@ -0,0 +1,11 @@ +import { clamp, MAX_ITEMS } from './constants.js'; + +export function paginate(items, page, size) { + const safeSize = clamp(size, 1, MAX_ITEMS); + const start = page * safeSize; + return items.slice(start, start + safeSize); +} + +export function formatName(name) { + return name.trim().toLowerCase(); +} diff --git a/tests/fixtures/deep-deps-project/shared/index.js b/tests/fixtures/deep-deps-project/shared/index.js new file mode 100644 index 000000000..714d7295f --- /dev/null +++ b/tests/fixtures/deep-deps-project/shared/index.js @@ -0,0 +1,2 @@ +export { MAX_ITEMS, DEFAULT_NAME, clamp } from './constants.js'; +export { paginate, formatName } from './helpers.js'; diff --git a/tests/integration/incr-edge-gap.test.js b/tests/integration/incr-edge-gap.test.js new file mode 100644 index 000000000..6554fb958 --- /dev/null +++ b/tests/integration/incr-edge-gap.test.js @@ -0,0 +1,135 @@ +/** + * Reproduction test for #533: incremental builds produce fewer edges than full builds. + * Uses a deeper dependency graph to exercise reverse-dep cascade. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; + +const FIXTURE_DIR = path.join(import.meta.dirname, '..', 'fixtures', 'deep-deps-project'); + +function copyDirSync(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, entry.name); + const d = path.join(dest, entry.name); + if (entry.isDirectory()) copyDirSync(s, d); + else fs.copyFileSync(s, d); + } +} + +function readEdges(dbPath) { + const db = new Database(dbPath, { readonly: true }); + const edges = db.prepare(` + SELECT n1.name AS src_name, n1.kind AS src_kind, n1.file AS src_file, + n2.name AS tgt_name, n2.kind AS tgt_kind, n2.file AS tgt_file, + e.kind AS edge_kind, e.confidence + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + ORDER BY src_file, src_name, tgt_file, tgt_name, e.kind + `).all(); + const nodes = db.prepare( + 'SELECT name, kind, file, line FROM nodes ORDER BY name, kind, file, line' + ).all(); + db.close(); + return { edges, nodes }; +} + +function edgeKey(e) { + return `[${e.edge_kind}] ${e.src_name}(${e.src_kind}@${e.src_file}) -> ${e.tgt_name}(${e.tgt_kind}@${e.tgt_file})`; +} + +describe('Issue #533: incremental edge gap', () => { + it('touching leaf file: full vs incremental produce identical edges', async () => { + const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-533-')); + const fullDir = path.join(tmpBase, 'full'); + const incrDir = path.join(tmpBase, 'incr'); + copyDirSync(FIXTURE_DIR, fullDir); + copyDirSync(FIXTURE_DIR, incrDir); + + try { + // Initial full build on incr copy + await buildGraph(incrDir, { incremental: false, skipRegistry: true }); + + // Touch the deeply-imported leaf file + fs.appendFileSync(path.join(incrDir, 'shared', 'constants.js'), '\n// touched\n'); + // Incremental rebuild + await buildGraph(incrDir, { incremental: true, skipRegistry: true }); + + // Full build on full copy (with same change) + fs.appendFileSync(path.join(fullDir, 'shared', 'constants.js'), '\n// touched\n'); + await buildGraph(fullDir, { incremental: false, skipRegistry: true }); + + const fullGraph = readEdges(path.join(fullDir, '.codegraph', 'graph.db')); + const incrGraph = readEdges(path.join(incrDir, '.codegraph', 'graph.db')); + + // Nodes should match + expect(incrGraph.nodes.length).toBe(fullGraph.nodes.length); + + // Detailed edge comparison + const fullKeys = new Set(fullGraph.edges.map(edgeKey)); + const incrKeys = new Set(incrGraph.edges.map(edgeKey)); + const missing = [...fullKeys].filter(k => !incrKeys.has(k)); + const extra = [...incrKeys].filter(k => !fullKeys.has(k)); + + if (missing.length > 0 || extra.length > 0) { + console.log(`\nFull build: ${fullGraph.edges.length} edges`); + console.log(`Incremental: ${incrGraph.edges.length} edges`); + console.log(`\nMissing in incremental (${missing.length}):`); + for (const e of missing) console.log(` - ${e}`); + console.log(`\nExtra in incremental (${extra.length}):`); + for (const e of extra) console.log(` + ${e}`); + } + + expect(missing).toEqual([]); + expect(extra).toEqual([]); + expect(incrGraph.edges.length).toBe(fullGraph.edges.length); + } finally { + fs.rmSync(tmpBase, { recursive: true, force: true }); + } + }, 60_000); + + it('touching mid-level file: full vs incremental produce identical edges', async () => { + const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-533b-')); + const fullDir = path.join(tmpBase, 'full'); + const incrDir = path.join(tmpBase, 'incr'); + copyDirSync(FIXTURE_DIR, fullDir); + copyDirSync(FIXTURE_DIR, incrDir); + + try { + await buildGraph(incrDir, { incremental: false, skipRegistry: true }); + + // Touch a mid-level file (imported by features but imports from shared) + fs.appendFileSync(path.join(incrDir, 'shared', 'helpers.js'), '\n// touched\n'); + await buildGraph(incrDir, { incremental: true, skipRegistry: true }); + + fs.appendFileSync(path.join(fullDir, 'shared', 'helpers.js'), '\n// touched\n'); + await buildGraph(fullDir, { incremental: false, skipRegistry: true }); + + const fullGraph = readEdges(path.join(fullDir, '.codegraph', 'graph.db')); + const incrGraph = readEdges(path.join(incrDir, '.codegraph', 'graph.db')); + + const fullKeys = new Set(fullGraph.edges.map(edgeKey)); + const incrKeys = new Set(incrGraph.edges.map(edgeKey)); + const missing = [...fullKeys].filter(k => !incrKeys.has(k)); + const extra = [...incrKeys].filter(k => !fullKeys.has(k)); + + if (missing.length > 0 || extra.length > 0) { + console.log(`\nFull: ${fullGraph.edges.length}, Incr: ${incrGraph.edges.length}`); + console.log(`Missing (${missing.length}):`); + for (const e of missing) console.log(` - ${e}`); + console.log(`Extra (${extra.length}):`); + for (const e of extra) console.log(` + ${e}`); + } + + expect(missing).toEqual([]); + expect(extra).toEqual([]); + } finally { + fs.rmSync(tmpBase, { recursive: true, force: true }); + } + }, 60_000); +}); diff --git a/tests/integration/watcher-rebuild.test.js b/tests/integration/watcher-rebuild.test.js new file mode 100644 index 000000000..2f5f86c89 --- /dev/null +++ b/tests/integration/watcher-rebuild.test.js @@ -0,0 +1,168 @@ +/** + * Watcher rebuild parity test (#533). + * + * Exercises the `rebuildFile` function (the watcher code path) directly, + * verifying that single-file rebuilds with reverse-dep cascade produce + * identical nodes and edges to a full build. + * + * This is distinct from incr-edge-gap.test.js which tests the build + * pipeline's incremental path (detect-changes.js). + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; +import { rebuildFile } from '../../src/domain/graph/builder/incremental.js'; +import { getNodeId as getNodeIdQuery, initSchema, openDb } from '../../src/db/index.js'; + +const FIXTURE_DIR = path.join(import.meta.dirname, '..', 'fixtures', 'deep-deps-project'); + +function copyDirSync(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, entry.name); + const d = path.join(dest, entry.name); + if (entry.isDirectory()) copyDirSync(s, d); + else fs.copyFileSync(s, d); + } +} + +function readGraph(dbPath) { + const db = new Database(dbPath, { readonly: true }); + const nodes = db + .prepare('SELECT name, kind, file, line FROM nodes ORDER BY name, kind, file, line') + .all(); + const edges = db + .prepare( + `SELECT n1.name AS src, n2.name AS tgt, e.kind + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + ORDER BY n1.name, n2.name, e.kind`, + ) + .all(); + db.close(); + return { nodes, edges }; +} + +/** Build the prepared statements object that watcher.js normally provides. */ +function makeStmts(db) { + const stmts = { + insertNode: db.prepare( + 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)', + ), + getNodeId: { + get: (name, kind, file, line) => { + const id = getNodeIdQuery(db, name, kind, file, line); + return id != null ? { id } : undefined; + }, + }, + insertEdge: db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)', + ), + deleteNodes: db.prepare('DELETE FROM nodes WHERE file = ?'), + deleteEdgesForFile: null, + countNodes: db.prepare('SELECT COUNT(*) as c FROM nodes WHERE file = ?'), + countEdgesForFile: null, + findNodeInFile: db.prepare( + "SELECT id, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant') AND file = ?", + ), + findNodeByName: db.prepare( + "SELECT id, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant')", + ), + listSymbols: db.prepare( + "SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'", + ), + }; + + const origDeleteEdges = db.prepare( + `DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = @f) OR target_id IN (SELECT id FROM nodes WHERE file = @f)`, + ); + const origCountEdges = db.prepare( + `SELECT COUNT(*) as c FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = @f) OR target_id IN (SELECT id FROM nodes WHERE file = @f)`, + ); + stmts.deleteEdgesForFile = { run: (f) => origDeleteEdges.run({ f }) }; + stmts.countEdgesForFile = { get: (f) => origCountEdges.get({ f }) }; + + return stmts; +} + +describe('Watcher rebuildFile parity (#533)', () => { + let fullDir; + let watcherDir; + let tmpBase; + + beforeAll(async () => { + tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-watcher-533-')); + fullDir = path.join(tmpBase, 'full'); + watcherDir = path.join(tmpBase, 'watcher'); + copyDirSync(FIXTURE_DIR, fullDir); + copyDirSync(FIXTURE_DIR, watcherDir); + + // Step 1: Full build both copies + await buildGraph(fullDir, { incremental: false, skipRegistry: true }); + await buildGraph(watcherDir, { incremental: false, skipRegistry: true }); + + // Step 2: Touch the leaf file (shared/constants.js) in the watcher copy + const leafPath = path.join(watcherDir, 'shared', 'constants.js'); + fs.appendFileSync(leafPath, '\n// touched\n'); + + // Step 3: Use rebuildFile (the watcher code path) to rebuild + const dbPath = path.join(watcherDir, '.codegraph', 'graph.db'); + const db = openDb(dbPath); + initSchema(db); + const stmts = makeStmts(db); + await rebuildFile(db, watcherDir, leafPath, stmts, { engine: 'auto' }, null); + db.close(); + + // Step 4: Apply same change to full copy and do a full rebuild + const fullLeafPath = path.join(fullDir, 'shared', 'constants.js'); + fs.appendFileSync(fullLeafPath, '\n// touched\n'); + await buildGraph(fullDir, { incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + try { + if (tmpBase) fs.rmSync(tmpBase, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + + it('produces identical node count', () => { + const fullGraph = readGraph(path.join(fullDir, '.codegraph', 'graph.db')); + const watcherGraph = readGraph(path.join(watcherDir, '.codegraph', 'graph.db')); + expect(watcherGraph.nodes.length).toBe(fullGraph.nodes.length); + }); + + it('produces identical edge count', () => { + const fullGraph = readGraph(path.join(fullDir, '.codegraph', 'graph.db')); + const watcherGraph = readGraph(path.join(watcherDir, '.codegraph', 'graph.db')); + + if (watcherGraph.edges.length !== fullGraph.edges.length) { + const fSet = new Set(fullGraph.edges.map((e) => `${e.src}->${e.tgt}[${e.kind}]`)); + const wSet = new Set(watcherGraph.edges.map((e) => `${e.src}->${e.tgt}[${e.kind}]`)); + const missing = [...fSet].filter((k) => !wSet.has(k)); + const extra = [...wSet].filter((k) => !fSet.has(k)); + console.log(`Missing in watcher (${missing.length}):`, missing.slice(0, 10)); + console.log(`Extra in watcher (${extra.length}):`, extra.slice(0, 10)); + } + + expect(watcherGraph.edges.length).toBe(fullGraph.edges.length); + }); + + it('produces identical nodes', () => { + const fullGraph = readGraph(path.join(fullDir, '.codegraph', 'graph.db')); + const watcherGraph = readGraph(path.join(watcherDir, '.codegraph', 'graph.db')); + expect(watcherGraph.nodes).toEqual(fullGraph.nodes); + }); + + it('produces identical edges', () => { + const fullGraph = readGraph(path.join(fullDir, '.codegraph', 'graph.db')); + const watcherGraph = readGraph(path.join(watcherDir, '.codegraph', 'graph.db')); + expect(watcherGraph.edges).toEqual(fullGraph.edges); + }); +}); From 94dd0f98305b449ca5d3cb79ae9214cc30f2b59b Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Thu, 19 Mar 2026 04:22:02 -0600 Subject: [PATCH 2/6] fix: address review feedback in incremental rebuild (#542) - purgeAncillaryData: only catch "no such table" errors instead of swallowing all exceptions (P1 from Greptile) - Cache barrel resolution prepared statements to avoid re-preparing inside hot loops (P2 from Greptile) - Fix stale @param _db JSDoc tag - Prefix unused db param with underscore in rebuildDirContainment --- src/domain/graph/builder/incremental.js | 75 ++++++++++++++----------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/src/domain/graph/builder/incremental.js b/src/domain/graph/builder/incremental.js index c5d33b5e6..c332362fa 100644 --- a/src/domain/graph/builder/incremental.js +++ b/src/domain/graph/builder/incremental.js @@ -24,13 +24,7 @@ function insertFileNodes(stmts, relPath, symbols) { stmts.insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null); if (def.children?.length) { for (const child of def.children) { - stmts.insertNode.run( - child.name, - child.kind, - relPath, - child.line, - child.endLine || null, - ); + stmts.insertNode.run(child.name, child.kind, relPath, child.line, child.endLine || null); } } } @@ -128,7 +122,7 @@ function rebuildReverseDepEdges(db, rootDir, depRelPath, symbols, stmts, skipBar // ── Directory containment edges ──────────────────────────────────────── -function rebuildDirContainment(db, stmts, relPath) { +function rebuildDirContainment(_db, stmts, relPath) { const dir = normalizePath(path.dirname(relPath)); if (!dir || dir === '.') return 0; const dirRow = stmts.getNodeId.get(dir, 'directory', dir, 0); @@ -146,8 +140,8 @@ function purgeAncillaryData(db, relPath) { const tryExec = (sql, ...args) => { try { db.prepare(sql).run(...args); - } catch { - /* table may not exist */ + } catch (err) { + if (!err?.message?.includes('no such table')) throw err; } }; tryExec( @@ -176,14 +170,40 @@ function purgeAncillaryData(db, relPath) { // ── Import edge building ──────────────────────────────────────────────── -function isBarrelFile(db, relPath) { - const reexportCount = db - .prepare( +// Lazily-cached prepared statements for barrel resolution (avoid re-preparing in hot loops) +let _barrelDb = null; +let _isBarrelStmt = null; +let _reexportTargetsStmt = null; +let _hasDefStmt = null; + +function getBarrelStmts(db) { + if (_barrelDb !== db) { + _barrelDb = db; + _isBarrelStmt = db.prepare( `SELECT COUNT(*) as c FROM edges e JOIN nodes n ON e.source_id = n.id WHERE e.kind = 'reexports' AND n.file = ? AND n.kind = 'file'`, - ) - .get(relPath)?.c; + ); + _reexportTargetsStmt = db.prepare( + `SELECT DISTINCT n2.file FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind = 'reexports' AND n1.file = ? AND n1.kind = 'file'`, + ); + _hasDefStmt = db.prepare( + `SELECT 1 FROM nodes WHERE name = ? AND file = ? AND kind != 'file' AND kind != 'directory' LIMIT 1`, + ); + } + return { + isBarrelStmt: _isBarrelStmt, + reexportTargetsStmt: _reexportTargetsStmt, + hasDefStmt: _hasDefStmt, + }; +} + +function isBarrelFile(db, relPath) { + const { isBarrelStmt } = getBarrelStmts(db); + const reexportCount = isBarrelStmt.get(relPath)?.c; return (reexportCount || 0) > 0; } @@ -191,23 +211,14 @@ function resolveBarrelTarget(db, barrelPath, symbolName, visited = new Set()) { if (visited.has(barrelPath)) return null; visited.add(barrelPath); + const { reexportTargetsStmt, hasDefStmt } = getBarrelStmts(db); + // Find re-export targets from this barrel - const reexportTargets = db - .prepare( - `SELECT DISTINCT n2.file FROM edges e - JOIN nodes n1 ON e.source_id = n1.id - JOIN nodes n2 ON e.target_id = n2.id - WHERE e.kind = 'reexports' AND n1.file = ? AND n1.kind = 'file'`, - ) - .all(barrelPath); + const reexportTargets = reexportTargetsStmt.all(barrelPath); for (const { file: targetFile } of reexportTargets) { // Check if the symbol is defined in this target file - const hasDef = db - .prepare( - `SELECT 1 FROM nodes WHERE name = ? AND file = ? AND kind != 'file' AND kind != 'directory' LIMIT 1`, - ) - .get(symbolName, targetFile); + const hasDef = hasDefStmt.get(symbolName, targetFile); if (hasDef) return targetFile; // Recurse through barrel chains @@ -357,7 +368,7 @@ function buildCallEdges(stmts, relPath, symbols, fileNodeRow, importedNames) { /** * Parse a single file and update the database incrementally. * - * @param {import('better-sqlite3').Database} _db + * @param {import('better-sqlite3').Database} db * @param {string} rootDir - Absolute root directory * @param {string} filePath - Absolute file path * @param {object} stmts - Prepared DB statements @@ -456,11 +467,7 @@ export async function rebuildFile(db, rootDir, filePath, stmts, engineOpts, cach for (const name of imp.names) { const cleanName = name.replace(/^\*\s+as\s+/, ''); const actualSource = resolveBarrelTarget(db, resolvedPath, cleanName); - if ( - actualSource && - actualSource !== resolvedPath && - !resolvedSources.has(actualSource) - ) { + if (actualSource && actualSource !== resolvedPath && !resolvedSources.has(actualSource)) { resolvedSources.add(actualSource); const actualRow = stmts.getNodeId.get(actualSource, 'file', actualSource, 0); if (actualRow) { From ca7ba2aebb1dfdd2217abe5cde16b10bf2c16cb5 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Thu, 19 Mar 2026 04:22:12 -0600 Subject: [PATCH 3/6] style: fix lint and formatting in fixtures and tests (#542) --- .../resolution/fixtures/typescript/index.ts | 2 +- tests/fixtures/deep-deps-project/app.js | 2 +- .../deep-deps-project/domain/parser.js | 4 ++-- .../deep-deps-project/features/query.js | 2 +- .../deep-deps-project/shared/index.js | 4 ++-- tests/integration/incr-edge-gap.test.js | 20 ++++++++++--------- tests/integration/watcher-rebuild.test.js | 8 +++----- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/benchmarks/resolution/fixtures/typescript/index.ts b/tests/benchmarks/resolution/fixtures/typescript/index.ts index fcc349784..c9e591062 100644 --- a/tests/benchmarks/resolution/fixtures/typescript/index.ts +++ b/tests/benchmarks/resolution/fixtures/typescript/index.ts @@ -1,5 +1,5 @@ import { JsonSerializer } from './serializer'; -import { createService, UserService } from './service'; +import { createService, type UserService } from './service'; export function main(): void { const svc = createService(); diff --git a/tests/fixtures/deep-deps-project/app.js b/tests/fixtures/deep-deps-project/app.js index 834d3438c..b557f27cd 100644 --- a/tests/fixtures/deep-deps-project/app.js +++ b/tests/fixtures/deep-deps-project/app.js @@ -1,5 +1,5 @@ -import { runQuery } from './features/query.js'; import { formatOutput } from './features/format.js'; +import { runQuery } from './features/query.js'; import { MAX_ITEMS } from './shared/constants.js'; export function main(input, page) { diff --git a/tests/fixtures/deep-deps-project/domain/parser.js b/tests/fixtures/deep-deps-project/domain/parser.js index 090357515..b21ae1d1f 100644 --- a/tests/fixtures/deep-deps-project/domain/parser.js +++ b/tests/fixtures/deep-deps-project/domain/parser.js @@ -1,6 +1,6 @@ -import { MAX_ITEMS, clamp } from '../shared/index.js'; +import { clamp, MAX_ITEMS } from '../shared/index.js'; export function parseItems(raw) { - const items = raw.split(',').map(s => s.trim()); + const items = raw.split(',').map((s) => s.trim()); return items.slice(0, clamp(items.length, 0, MAX_ITEMS)); } diff --git a/tests/fixtures/deep-deps-project/features/query.js b/tests/fixtures/deep-deps-project/features/query.js index 954cf8837..216b23ac3 100644 --- a/tests/fixtures/deep-deps-project/features/query.js +++ b/tests/fixtures/deep-deps-project/features/query.js @@ -1,6 +1,6 @@ import { parseItems } from '../domain/index.js'; -import { paginate } from '../shared/helpers.js'; import { clamp } from '../shared/constants.js'; +import { paginate } from '../shared/helpers.js'; export function runQuery(raw, page) { const items = parseItems(raw); diff --git a/tests/fixtures/deep-deps-project/shared/index.js b/tests/fixtures/deep-deps-project/shared/index.js index 714d7295f..b26feefbf 100644 --- a/tests/fixtures/deep-deps-project/shared/index.js +++ b/tests/fixtures/deep-deps-project/shared/index.js @@ -1,2 +1,2 @@ -export { MAX_ITEMS, DEFAULT_NAME, clamp } from './constants.js'; -export { paginate, formatName } from './helpers.js'; +export { clamp, DEFAULT_NAME, MAX_ITEMS } from './constants.js'; +export { formatName, paginate } from './helpers.js'; diff --git a/tests/integration/incr-edge-gap.test.js b/tests/integration/incr-edge-gap.test.js index 6554fb958..0a4f4eb37 100644 --- a/tests/integration/incr-edge-gap.test.js +++ b/tests/integration/incr-edge-gap.test.js @@ -23,7 +23,8 @@ function copyDirSync(src, dest) { function readEdges(dbPath) { const db = new Database(dbPath, { readonly: true }); - const edges = db.prepare(` + const edges = db + .prepare(` SELECT n1.name AS src_name, n1.kind AS src_kind, n1.file AS src_file, n2.name AS tgt_name, n2.kind AS tgt_kind, n2.file AS tgt_file, e.kind AS edge_kind, e.confidence @@ -31,10 +32,11 @@ function readEdges(dbPath) { JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id ORDER BY src_file, src_name, tgt_file, tgt_name, e.kind - `).all(); - const nodes = db.prepare( - 'SELECT name, kind, file, line FROM nodes ORDER BY name, kind, file, line' - ).all(); + `) + .all(); + const nodes = db + .prepare('SELECT name, kind, file, line FROM nodes ORDER BY name, kind, file, line') + .all(); db.close(); return { edges, nodes }; } @@ -73,8 +75,8 @@ describe('Issue #533: incremental edge gap', () => { // Detailed edge comparison const fullKeys = new Set(fullGraph.edges.map(edgeKey)); const incrKeys = new Set(incrGraph.edges.map(edgeKey)); - const missing = [...fullKeys].filter(k => !incrKeys.has(k)); - const extra = [...incrKeys].filter(k => !fullKeys.has(k)); + const missing = [...fullKeys].filter((k) => !incrKeys.has(k)); + const extra = [...incrKeys].filter((k) => !fullKeys.has(k)); if (missing.length > 0 || extra.length > 0) { console.log(`\nFull build: ${fullGraph.edges.length} edges`); @@ -115,8 +117,8 @@ describe('Issue #533: incremental edge gap', () => { const fullKeys = new Set(fullGraph.edges.map(edgeKey)); const incrKeys = new Set(incrGraph.edges.map(edgeKey)); - const missing = [...fullKeys].filter(k => !incrKeys.has(k)); - const extra = [...incrKeys].filter(k => !fullKeys.has(k)); + const missing = [...fullKeys].filter((k) => !incrKeys.has(k)); + const extra = [...incrKeys].filter((k) => !fullKeys.has(k)); if (missing.length > 0 || extra.length > 0) { console.log(`\nFull: ${fullGraph.edges.length}, Incr: ${incrGraph.edges.length}`); diff --git a/tests/integration/watcher-rebuild.test.js b/tests/integration/watcher-rebuild.test.js index 2f5f86c89..6c21e13e9 100644 --- a/tests/integration/watcher-rebuild.test.js +++ b/tests/integration/watcher-rebuild.test.js @@ -14,9 +14,9 @@ import os from 'node:os'; import path from 'node:path'; import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { buildGraph } from '../../src/domain/graph/builder.js'; -import { rebuildFile } from '../../src/domain/graph/builder/incremental.js'; import { getNodeId as getNodeIdQuery, initSchema, openDb } from '../../src/db/index.js'; +import { rebuildFile } from '../../src/domain/graph/builder/incremental.js'; +import { buildGraph } from '../../src/domain/graph/builder.js'; const FIXTURE_DIR = path.join(import.meta.dirname, '..', 'fixtures', 'deep-deps-project'); @@ -73,9 +73,7 @@ function makeStmts(db) { findNodeByName: db.prepare( "SELECT id, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant')", ), - listSymbols: db.prepare( - "SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'", - ), + listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"), }; const origDeleteEdges = db.prepare( From bdf3f77408656b31b0a0f82f6c1eae512223cf8b Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Thu, 19 Mar 2026 04:57:57 -0600 Subject: [PATCH 4/6] fix: coerce native typeMap array to Map in incremental buildCallEdges The native engine returns typeMap as a plain array, not a Map. After the TS-only backfill restriction (8e78e62), JS files no longer get their native typeMap converted to a Map, causing a TypeError on .get() during incremental reverse-dep edge rebuilds. Also moves deleteOutgoingEdges after parseReverseDep succeeds, preventing permanent edge loss when a reverse-dep file fails to parse. --- src/domain/graph/builder/incremental.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/domain/graph/builder/incremental.js b/src/domain/graph/builder/incremental.js index c332362fa..1363d1b66 100644 --- a/src/domain/graph/builder/incremental.js +++ b/src/domain/graph/builder/incremental.js @@ -338,7 +338,13 @@ function resolveCallTargets(stmts, call, relPath, importedNames, typeMap) { } function buildCallEdges(stmts, relPath, symbols, fileNodeRow, importedNames) { - const typeMap = symbols.typeMap || new Map(); + const rawTM = symbols.typeMap; + const typeMap = + rawTM instanceof Map + ? rawTM + : Array.isArray(rawTM) && rawTM.length > 0 + ? new Map(rawTM.map((e) => [e.name, e.typeName ?? e.type ?? null])) + : new Map(); let edgesAdded = 0; for (const call of symbols.calls) { if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue; @@ -441,9 +447,11 @@ export async function rebuildFile(db, rootDir, filePath, stmts, engineOpts, cach // then add barrel import edges (which need reexports edges to exist for resolution). const depSymbols = new Map(); for (const depRelPath of reverseDeps) { - deleteOutgoingEdges(db, depRelPath); const symbols_ = await parseReverseDep(rootDir, depRelPath, engineOpts, cache); - if (symbols_) depSymbols.set(depRelPath, symbols_); + if (symbols_) { + deleteOutgoingEdges(db, depRelPath); + depSymbols.set(depRelPath, symbols_); + } } // Pass 1: direct edges only (no barrel resolution) — creates reexports edges for (const [depRelPath, symbols_] of depSymbols) { From 11f4f0f4da613b019ad871f5fd8dc71d43f4a5e8 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:33:32 -0600 Subject: [PATCH 5/6] perf: cache findReverseDeps/deleteOutgoingEdges prepared statements --- src/domain/graph/builder/incremental.js | 89 ++++++++++++++----------- 1 file changed, 49 insertions(+), 40 deletions(-) diff --git a/src/domain/graph/builder/incremental.js b/src/domain/graph/builder/incremental.js index 1363d1b66..2be5cefa5 100644 --- a/src/domain/graph/builder/incremental.js +++ b/src/domain/graph/builder/incremental.js @@ -67,22 +67,35 @@ function buildContainmentEdges(db, stmts, relPath, symbols) { // ── Reverse-dep cascade ──────────────────────────────────────────────── -function findReverseDeps(db, relPath) { - return db - .prepare( +// Lazily-cached prepared statements for reverse-dep operations +let _revDepDb = null; +let _findRevDepsStmt = null; +let _deleteOutEdgesStmt = null; + +function getRevDepStmts(db) { + if (_revDepDb !== db) { + _revDepDb = db; + _findRevDepsStmt = db.prepare( `SELECT DISTINCT n_src.file FROM edges e JOIN nodes n_src ON e.source_id = n_src.id JOIN nodes n_tgt ON e.target_id = n_tgt.id WHERE n_tgt.file = ? AND n_src.file != ? AND n_src.kind != 'directory'`, - ) - .all(relPath, relPath) - .map((r) => r.file); + ); + _deleteOutEdgesStmt = db.prepare( + 'DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)', + ); + } + return { findRevDepsStmt: _findRevDepsStmt, deleteOutEdgesStmt: _deleteOutEdgesStmt }; +} + +function findReverseDeps(db, relPath) { + const { findRevDepsStmt } = getRevDepStmts(db); + return findRevDepsStmt.all(relPath, relPath).map((r) => r.file); } function deleteOutgoingEdges(db, relPath) { - db.prepare('DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)').run( - relPath, - ); + const { deleteOutEdgesStmt } = getRevDepStmts(db); + deleteOutEdgesStmt.run(relPath); } async function parseReverseDep(rootDir, depRelPath, engineOpts, cache) { @@ -230,6 +243,30 @@ function resolveBarrelTarget(db, barrelPath, symbolName, visited = new Set()) { return null; } +/** + * Resolve barrel imports for a single import statement and create edges to actual source files. + * Shared by buildImportEdges (primary file) and Pass 2 of the reverse-dep cascade. + */ +function resolveBarrelImportEdges(db, stmts, fileNodeId, resolvedPath, imp) { + let edgesAdded = 0; + if (!isBarrelFile(db, resolvedPath)) return edgesAdded; + const resolvedSources = new Set(); + for (const name of imp.names) { + const cleanName = name.replace(/^\*\s+as\s+/, ''); + const actualSource = resolveBarrelTarget(db, resolvedPath, cleanName); + if (actualSource && actualSource !== resolvedPath && !resolvedSources.has(actualSource)) { + resolvedSources.add(actualSource); + const actualRow = stmts.getNodeId.get(actualSource, 'file', actualSource, 0); + if (actualRow) { + const kind = imp.typeOnly ? 'imports-type' : 'imports'; + stmts.insertEdge.run(fileNodeId, actualRow.id, kind, 0.9, 0); + edgesAdded++; + } + } + } + return edgesAdded; +} + function buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeId, aliases, db) { let edgesAdded = 0; for (const imp of symbols.imports) { @@ -246,21 +283,8 @@ function buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeId, aliases, edgesAdded++; // Barrel resolution: create edges through re-export chains - if (!imp.reexport && db && isBarrelFile(db, resolvedPath)) { - const resolvedSources = new Set(); - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); - const actualSource = resolveBarrelTarget(db, resolvedPath, cleanName); - if (actualSource && actualSource !== resolvedPath && !resolvedSources.has(actualSource)) { - resolvedSources.add(actualSource); - const actualRow = stmts.getNodeId.get(actualSource, 'file', actualSource, 0); - if (actualRow) { - const kind = edgeKind === 'imports-type' ? 'imports-type' : 'imports'; - stmts.insertEdge.run(fileNodeId, actualRow.id, kind, 0.9, 0); - edgesAdded++; - } - } - } + if (!imp.reexport && db) { + edgesAdded += resolveBarrelImportEdges(db, stmts, fileNodeId, resolvedPath, imp); } } } @@ -470,22 +494,7 @@ export async function rebuildFile(db, rootDir, filePath, stmts, engineOpts, cach rootDir, aliases_, ); - if (db && isBarrelFile(db, resolvedPath)) { - const resolvedSources = new Set(); - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); - const actualSource = resolveBarrelTarget(db, resolvedPath, cleanName); - if (actualSource && actualSource !== resolvedPath && !resolvedSources.has(actualSource)) { - resolvedSources.add(actualSource); - const actualRow = stmts.getNodeId.get(actualSource, 'file', actualSource, 0); - if (actualRow) { - const kind = imp.typeOnly ? 'imports-type' : 'imports'; - stmts.insertEdge.run(fileNodeRow_.id, actualRow.id, kind, 0.9, 0); - edgesAdded++; - } - } - } - } + edgesAdded += resolveBarrelImportEdges(db, stmts, fileNodeRow_.id, resolvedPath, imp); } } From 4cf64d8d701d806b07773132746c1c8d00c76d37 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:33:40 -0600 Subject: [PATCH 6/6] test: include file paths in watcher-rebuild edge comparison --- tests/integration/watcher-rebuild.test.js | 31 +++++++++++++---------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/tests/integration/watcher-rebuild.test.js b/tests/integration/watcher-rebuild.test.js index 6c21e13e9..b2f031422 100644 --- a/tests/integration/watcher-rebuild.test.js +++ b/tests/integration/watcher-rebuild.test.js @@ -32,20 +32,23 @@ function copyDirSync(src, dest) { function readGraph(dbPath) { const db = new Database(dbPath, { readonly: true }); - const nodes = db - .prepare('SELECT name, kind, file, line FROM nodes ORDER BY name, kind, file, line') - .all(); - const edges = db - .prepare( - `SELECT n1.name AS src, n2.name AS tgt, e.kind - FROM edges e - JOIN nodes n1 ON e.source_id = n1.id - JOIN nodes n2 ON e.target_id = n2.id - ORDER BY n1.name, n2.name, e.kind`, - ) - .all(); - db.close(); - return { nodes, edges }; + try { + const nodes = db + .prepare('SELECT name, kind, file, line FROM nodes ORDER BY name, kind, file, line') + .all(); + const edges = db + .prepare( + `SELECT n1.name AS src, n1.file AS src_file, n2.name AS tgt, n2.file AS tgt_file, e.kind + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + ORDER BY n1.name, n1.file, n2.name, n2.file, e.kind`, + ) + .all(); + return { nodes, edges }; + } finally { + db.close(); + } } /** Build the prepared statements object that watcher.js normally provides. */