diff --git a/src/domain/graph/builder/incremental.js b/src/domain/graph/builder/incremental.js index 778356242..2be5cefa5 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,252 @@ 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 ──────────────────────────────────────────────── + +// 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'`, + ); + _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) { + const { deleteOutEdgesStmt } = getRevDepStmts(db); + deleteOutEdgesStmt.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 (err) { + if (!err?.message?.includes('no such table')) throw err; + } + }; + 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) { +// 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'`, + ); + _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; +} + +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 = reexportTargetsStmt.all(barrelPath); + + for (const { file: targetFile } of reexportTargets) { + // Check if the symbol is defined in this target file + const hasDef = hasDefStmt.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; +} + +/** + * 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) { const resolvedPath = resolveImportPath( @@ -40,6 +281,11 @@ 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) { + edgesAdded += resolveBarrelImportEdges(db, stmts, fileNodeId, resolvedPath, imp); + } } } return edgesAdded; @@ -116,7 +362,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; @@ -146,7 +398,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 @@ -156,12 +408,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 +460,44 @@ 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) { + const symbols_ = await parseReverseDep(rootDir, depRelPath, engineOpts, cache); + 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) { + 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_, + ); + edgesAdded += resolveBarrelImportEdges(db, stmts, fileNodeRow_.id, resolvedPath, imp); + } + } + const symbolDiff = diffSymbols ? diffSymbols(oldSymbols, newSymbols) : null; const event = oldNodes === 0 ? 'added' : 'modified'; 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 new file mode 100644 index 000000000..b557f27cd --- /dev/null +++ b/tests/fixtures/deep-deps-project/app.js @@ -0,0 +1,9 @@ +import { formatOutput } from './features/format.js'; +import { runQuery } from './features/query.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..b21ae1d1f --- /dev/null +++ b/tests/fixtures/deep-deps-project/domain/parser.js @@ -0,0 +1,6 @@ +import { clamp, MAX_ITEMS } 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..216b23ac3 --- /dev/null +++ b/tests/fixtures/deep-deps-project/features/query.js @@ -0,0 +1,9 @@ +import { parseItems } from '../domain/index.js'; +import { clamp } from '../shared/constants.js'; +import { paginate } from '../shared/helpers.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..b26feefbf --- /dev/null +++ b/tests/fixtures/deep-deps-project/shared/index.js @@ -0,0 +1,2 @@ +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 new file mode 100644 index 000000000..0a4f4eb37 --- /dev/null +++ b/tests/integration/incr-edge-gap.test.js @@ -0,0 +1,137 @@ +/** + * 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..b2f031422 --- /dev/null +++ b/tests/integration/watcher-rebuild.test.js @@ -0,0 +1,169 @@ +/** + * 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 { 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'); + +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 }); + 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. */ +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); + }); +});