@optave/codegraph 3.1.3 → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +17 -19
  2. package/package.json +10 -7
  3. package/src/analysis/context.js +408 -0
  4. package/src/analysis/dependencies.js +341 -0
  5. package/src/analysis/exports.js +130 -0
  6. package/src/analysis/impact.js +463 -0
  7. package/src/analysis/module-map.js +322 -0
  8. package/src/analysis/roles.js +45 -0
  9. package/src/analysis/symbol-lookup.js +232 -0
  10. package/src/ast-analysis/shared.js +5 -4
  11. package/src/batch.js +2 -1
  12. package/src/builder/context.js +85 -0
  13. package/src/builder/helpers.js +218 -0
  14. package/src/builder/incremental.js +178 -0
  15. package/src/builder/pipeline.js +130 -0
  16. package/src/builder/stages/build-edges.js +297 -0
  17. package/src/builder/stages/build-structure.js +113 -0
  18. package/src/builder/stages/collect-files.js +44 -0
  19. package/src/builder/stages/detect-changes.js +413 -0
  20. package/src/builder/stages/finalize.js +139 -0
  21. package/src/builder/stages/insert-nodes.js +195 -0
  22. package/src/builder/stages/parse-files.js +28 -0
  23. package/src/builder/stages/resolve-imports.js +143 -0
  24. package/src/builder/stages/run-analyses.js +44 -0
  25. package/src/builder.js +10 -1485
  26. package/src/cfg.js +1 -2
  27. package/src/cli/commands/ast.js +26 -0
  28. package/src/cli/commands/audit.js +46 -0
  29. package/src/cli/commands/batch.js +68 -0
  30. package/src/cli/commands/branch-compare.js +21 -0
  31. package/src/cli/commands/build.js +26 -0
  32. package/src/cli/commands/cfg.js +30 -0
  33. package/src/cli/commands/check.js +79 -0
  34. package/src/cli/commands/children.js +31 -0
  35. package/src/cli/commands/co-change.js +65 -0
  36. package/src/cli/commands/communities.js +23 -0
  37. package/src/cli/commands/complexity.js +45 -0
  38. package/src/cli/commands/context.js +34 -0
  39. package/src/cli/commands/cycles.js +28 -0
  40. package/src/cli/commands/dataflow.js +32 -0
  41. package/src/cli/commands/deps.js +16 -0
  42. package/src/cli/commands/diff-impact.js +30 -0
  43. package/src/cli/commands/embed.js +30 -0
  44. package/src/cli/commands/export.js +75 -0
  45. package/src/cli/commands/exports.js +18 -0
  46. package/src/cli/commands/flow.js +36 -0
  47. package/src/cli/commands/fn-impact.js +30 -0
  48. package/src/cli/commands/impact.js +16 -0
  49. package/src/cli/commands/info.js +76 -0
  50. package/src/cli/commands/map.js +19 -0
  51. package/src/cli/commands/mcp.js +18 -0
  52. package/src/cli/commands/models.js +19 -0
  53. package/src/cli/commands/owners.js +25 -0
  54. package/src/cli/commands/path.js +36 -0
  55. package/src/cli/commands/plot.js +80 -0
  56. package/src/cli/commands/query.js +49 -0
  57. package/src/cli/commands/registry.js +100 -0
  58. package/src/cli/commands/roles.js +34 -0
  59. package/src/cli/commands/search.js +42 -0
  60. package/src/cli/commands/sequence.js +32 -0
  61. package/src/cli/commands/snapshot.js +61 -0
  62. package/src/cli/commands/stats.js +15 -0
  63. package/src/cli/commands/structure.js +32 -0
  64. package/src/cli/commands/triage.js +78 -0
  65. package/src/cli/commands/watch.js +12 -0
  66. package/src/cli/commands/where.js +24 -0
  67. package/src/cli/index.js +118 -0
  68. package/src/cli/shared/options.js +39 -0
  69. package/src/cli/shared/output.js +1 -0
  70. package/src/cli.js +11 -1522
  71. package/src/commands/check.js +5 -5
  72. package/src/commands/manifesto.js +3 -3
  73. package/src/commands/structure.js +1 -1
  74. package/src/communities.js +15 -87
  75. package/src/cycles.js +30 -85
  76. package/src/dataflow.js +1 -2
  77. package/src/db/connection.js +4 -4
  78. package/src/db/migrations.js +41 -0
  79. package/src/db/query-builder.js +6 -5
  80. package/src/db/repository/base.js +201 -0
  81. package/src/db/repository/graph-read.js +5 -2
  82. package/src/db/repository/in-memory-repository.js +584 -0
  83. package/src/db/repository/index.js +5 -1
  84. package/src/db/repository/nodes.js +63 -4
  85. package/src/db/repository/sqlite-repository.js +219 -0
  86. package/src/db.js +5 -0
  87. package/src/embeddings/generator.js +163 -0
  88. package/src/embeddings/index.js +13 -0
  89. package/src/embeddings/models.js +218 -0
  90. package/src/embeddings/search/cli-formatter.js +151 -0
  91. package/src/embeddings/search/filters.js +46 -0
  92. package/src/embeddings/search/hybrid.js +121 -0
  93. package/src/embeddings/search/keyword.js +68 -0
  94. package/src/embeddings/search/prepare.js +66 -0
  95. package/src/embeddings/search/semantic.js +145 -0
  96. package/src/embeddings/stores/fts5.js +27 -0
  97. package/src/embeddings/stores/sqlite-blob.js +24 -0
  98. package/src/embeddings/strategies/source.js +14 -0
  99. package/src/embeddings/strategies/structured.js +43 -0
  100. package/src/embeddings/strategies/text-utils.js +43 -0
  101. package/src/errors.js +78 -0
  102. package/src/export.js +217 -520
  103. package/src/extractors/csharp.js +10 -2
  104. package/src/extractors/go.js +3 -1
  105. package/src/extractors/helpers.js +71 -0
  106. package/src/extractors/java.js +9 -2
  107. package/src/extractors/javascript.js +38 -1
  108. package/src/extractors/php.js +3 -1
  109. package/src/extractors/python.js +14 -3
  110. package/src/extractors/rust.js +3 -1
  111. package/src/graph/algorithms/bfs.js +49 -0
  112. package/src/graph/algorithms/centrality.js +16 -0
  113. package/src/graph/algorithms/index.js +5 -0
  114. package/src/graph/algorithms/louvain.js +26 -0
  115. package/src/graph/algorithms/shortest-path.js +41 -0
  116. package/src/graph/algorithms/tarjan.js +49 -0
  117. package/src/graph/builders/dependency.js +91 -0
  118. package/src/graph/builders/index.js +3 -0
  119. package/src/graph/builders/structure.js +40 -0
  120. package/src/graph/builders/temporal.js +33 -0
  121. package/src/graph/classifiers/index.js +2 -0
  122. package/src/graph/classifiers/risk.js +85 -0
  123. package/src/graph/classifiers/roles.js +64 -0
  124. package/src/graph/index.js +13 -0
  125. package/src/graph/model.js +230 -0
  126. package/src/index.js +33 -210
  127. package/src/infrastructure/result-formatter.js +2 -21
  128. package/src/mcp/index.js +2 -0
  129. package/src/mcp/middleware.js +26 -0
  130. package/src/mcp/server.js +128 -0
  131. package/src/mcp/tool-registry.js +801 -0
  132. package/src/mcp/tools/ast-query.js +14 -0
  133. package/src/mcp/tools/audit.js +21 -0
  134. package/src/mcp/tools/batch-query.js +11 -0
  135. package/src/mcp/tools/branch-compare.js +10 -0
  136. package/src/mcp/tools/cfg.js +21 -0
  137. package/src/mcp/tools/check.js +43 -0
  138. package/src/mcp/tools/co-changes.js +20 -0
  139. package/src/mcp/tools/code-owners.js +12 -0
  140. package/src/mcp/tools/communities.js +15 -0
  141. package/src/mcp/tools/complexity.js +18 -0
  142. package/src/mcp/tools/context.js +17 -0
  143. package/src/mcp/tools/dataflow.js +26 -0
  144. package/src/mcp/tools/diff-impact.js +24 -0
  145. package/src/mcp/tools/execution-flow.js +26 -0
  146. package/src/mcp/tools/export-graph.js +57 -0
  147. package/src/mcp/tools/file-deps.js +12 -0
  148. package/src/mcp/tools/file-exports.js +13 -0
  149. package/src/mcp/tools/find-cycles.js +15 -0
  150. package/src/mcp/tools/fn-impact.js +15 -0
  151. package/src/mcp/tools/impact-analysis.js +12 -0
  152. package/src/mcp/tools/index.js +71 -0
  153. package/src/mcp/tools/list-functions.js +14 -0
  154. package/src/mcp/tools/list-repos.js +11 -0
  155. package/src/mcp/tools/module-map.js +6 -0
  156. package/src/mcp/tools/node-roles.js +14 -0
  157. package/src/mcp/tools/path.js +12 -0
  158. package/src/mcp/tools/query.js +30 -0
  159. package/src/mcp/tools/semantic-search.js +65 -0
  160. package/src/mcp/tools/sequence.js +17 -0
  161. package/src/mcp/tools/structure.js +15 -0
  162. package/src/mcp/tools/symbol-children.js +14 -0
  163. package/src/mcp/tools/triage.js +35 -0
  164. package/src/mcp/tools/where.js +13 -0
  165. package/src/mcp.js +2 -1470
  166. package/src/native.js +3 -1
  167. package/src/presentation/colors.js +44 -0
  168. package/src/presentation/export.js +444 -0
  169. package/src/presentation/result-formatter.js +21 -0
  170. package/src/presentation/sequence-renderer.js +43 -0
  171. package/src/presentation/table.js +47 -0
  172. package/src/presentation/viewer.js +634 -0
  173. package/src/queries.js +35 -2276
  174. package/src/resolve.js +1 -1
  175. package/src/sequence.js +2 -38
  176. package/src/shared/file-utils.js +153 -0
  177. package/src/shared/generators.js +125 -0
  178. package/src/shared/hierarchy.js +27 -0
  179. package/src/shared/normalize.js +59 -0
  180. package/src/snapshot.js +6 -5
  181. package/src/structure.js +15 -40
  182. package/src/triage.js +20 -72
  183. package/src/viewer.js +35 -656
  184. package/src/watcher.js +8 -148
  185. package/src/embedder.js +0 -1097
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Stage: detectChanges
3
+ *
4
+ * Three-tier change detection cascade + incremental reverse-dependency handling.
5
+ * Sets ctx.parseChanges, ctx.metadataUpdates, ctx.removed, ctx.isFullBuild, ctx.earlyExit.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { normalizePath } from '../../constants.js';
10
+ import { closeDb } from '../../db.js';
11
+ import { readJournal, writeJournalHeader } from '../../journal.js';
12
+ import { debug, info } from '../../logger.js';
13
+ import { parseFilesAuto } from '../../parser.js';
14
+ import { fileHash, fileStat, purgeFilesFromGraph, readFileSafe } from '../helpers.js';
15
+
16
+ /**
17
+ * Determine which files have changed since last build.
18
+ * Three-tier cascade:
19
+ * Tier 0 — Journal: O(changed) when watcher was running
20
+ * Tier 1 — mtime+size: O(n) stats, O(changed) reads
21
+ * Tier 2 — Hash comparison: O(changed) reads (fallback from Tier 1)
22
+ */
23
+ function getChangedFiles(db, allFiles, rootDir) {
24
+ let hasTable = false;
25
+ try {
26
+ db.prepare('SELECT 1 FROM file_hashes LIMIT 1').get();
27
+ hasTable = true;
28
+ } catch {
29
+ /* table doesn't exist */
30
+ }
31
+
32
+ if (!hasTable) {
33
+ return {
34
+ changed: allFiles.map((f) => ({ file: f })),
35
+ removed: [],
36
+ isFullBuild: true,
37
+ };
38
+ }
39
+
40
+ const existing = new Map(
41
+ db
42
+ .prepare('SELECT file, hash, mtime, size FROM file_hashes')
43
+ .all()
44
+ .map((r) => [r.file, r]),
45
+ );
46
+
47
+ const currentFiles = new Set();
48
+ for (const file of allFiles) {
49
+ currentFiles.add(normalizePath(path.relative(rootDir, file)));
50
+ }
51
+
52
+ const removed = [];
53
+ for (const existingFile of existing.keys()) {
54
+ if (!currentFiles.has(existingFile)) {
55
+ removed.push(existingFile);
56
+ }
57
+ }
58
+
59
+ // ── Tier 0: Journal ──────────────────────────────────────────────
60
+ const journal = readJournal(rootDir);
61
+ if (journal.valid) {
62
+ const dbMtimes = db.prepare('SELECT MAX(mtime) as latest FROM file_hashes').get();
63
+ const latestDbMtime = dbMtimes?.latest || 0;
64
+ const hasJournalEntries = journal.changed.length > 0 || journal.removed.length > 0;
65
+
66
+ if (hasJournalEntries && journal.timestamp >= latestDbMtime) {
67
+ debug(
68
+ `Tier 0: journal valid, ${journal.changed.length} changed, ${journal.removed.length} removed`,
69
+ );
70
+ const changed = [];
71
+
72
+ for (const relPath of journal.changed) {
73
+ const absPath = path.join(rootDir, relPath);
74
+ const stat = fileStat(absPath);
75
+ if (!stat) continue;
76
+
77
+ let content;
78
+ try {
79
+ content = readFileSafe(absPath);
80
+ } catch {
81
+ continue;
82
+ }
83
+ const hash = fileHash(content);
84
+ const record = existing.get(relPath);
85
+ if (!record || record.hash !== hash) {
86
+ changed.push({ file: absPath, content, hash, relPath, stat });
87
+ }
88
+ }
89
+
90
+ const removedSet = new Set(removed);
91
+ for (const relPath of journal.removed) {
92
+ if (existing.has(relPath)) removedSet.add(relPath);
93
+ }
94
+
95
+ return { changed, removed: [...removedSet], isFullBuild: false };
96
+ }
97
+ debug(
98
+ `Tier 0: skipped (${hasJournalEntries ? 'timestamp stale' : 'no entries'}), falling to Tier 1`,
99
+ );
100
+ }
101
+
102
+ // ── Tier 1: mtime+size fast-path ─────────────────────────────────
103
+ const needsHash = [];
104
+ const skipped = [];
105
+
106
+ for (const file of allFiles) {
107
+ const relPath = normalizePath(path.relative(rootDir, file));
108
+ const record = existing.get(relPath);
109
+
110
+ if (!record) {
111
+ needsHash.push({ file, relPath });
112
+ continue;
113
+ }
114
+
115
+ const stat = fileStat(file);
116
+ if (!stat) continue;
117
+
118
+ const storedMtime = record.mtime || 0;
119
+ const storedSize = record.size || 0;
120
+
121
+ if (storedSize > 0 && Math.floor(stat.mtimeMs) === storedMtime && stat.size === storedSize) {
122
+ skipped.push(relPath);
123
+ continue;
124
+ }
125
+
126
+ needsHash.push({ file, relPath, stat });
127
+ }
128
+
129
+ if (needsHash.length > 0) {
130
+ debug(`Tier 1: ${skipped.length} skipped by mtime+size, ${needsHash.length} need hash check`);
131
+ }
132
+
133
+ // ── Tier 2: Hash comparison ──────────────────────────────────────
134
+ const changed = [];
135
+
136
+ for (const item of needsHash) {
137
+ let content;
138
+ try {
139
+ content = readFileSafe(item.file);
140
+ } catch {
141
+ continue;
142
+ }
143
+ const hash = fileHash(content);
144
+ const stat = item.stat || fileStat(item.file);
145
+ const record = existing.get(item.relPath);
146
+
147
+ if (!record || record.hash !== hash) {
148
+ changed.push({ file: item.file, content, hash, relPath: item.relPath, stat });
149
+ } else if (stat) {
150
+ changed.push({
151
+ file: item.file,
152
+ content,
153
+ hash,
154
+ relPath: item.relPath,
155
+ stat,
156
+ metadataOnly: true,
157
+ });
158
+ }
159
+ }
160
+
161
+ const parseChanged = changed.filter((c) => !c.metadataOnly);
162
+ if (needsHash.length > 0) {
163
+ debug(
164
+ `Tier 2: ${parseChanged.length} actually changed, ${changed.length - parseChanged.length} metadata-only`,
165
+ );
166
+ }
167
+
168
+ return { changed, removed, isFullBuild: false };
169
+ }
170
+
171
+ /**
172
+ * Run pending analysis pass when no file changes but analysis tables are empty.
173
+ * @returns {boolean} true if analysis was run and we should early-exit
174
+ */
175
+ async function runPendingAnalysis(ctx) {
176
+ const { db, opts, engineOpts, allFiles, rootDir } = ctx;
177
+
178
+ const needsCfg =
179
+ opts.cfg !== false &&
180
+ (() => {
181
+ try {
182
+ return db.prepare('SELECT COUNT(*) as c FROM cfg_blocks').get().c === 0;
183
+ } catch {
184
+ return true;
185
+ }
186
+ })();
187
+ const needsDataflow =
188
+ opts.dataflow !== false &&
189
+ (() => {
190
+ try {
191
+ return db.prepare('SELECT COUNT(*) as c FROM dataflow').get().c === 0;
192
+ } catch {
193
+ return true;
194
+ }
195
+ })();
196
+
197
+ if (!needsCfg && !needsDataflow) return false;
198
+
199
+ info('No file changes. Running pending analysis pass...');
200
+ const analysisOpts = {
201
+ ...engineOpts,
202
+ dataflow: needsDataflow && opts.dataflow !== false,
203
+ };
204
+ const analysisSymbols = await parseFilesAuto(allFiles, rootDir, analysisOpts);
205
+ if (needsCfg) {
206
+ const { buildCFGData } = await import('../../cfg.js');
207
+ await buildCFGData(db, analysisSymbols, rootDir, engineOpts);
208
+ }
209
+ if (needsDataflow) {
210
+ const { buildDataflowEdges } = await import('../../dataflow.js');
211
+ await buildDataflowEdges(db, analysisSymbols, rootDir, engineOpts);
212
+ }
213
+ return true;
214
+ }
215
+
216
+ /**
217
+ * Self-heal metadata-only updates (mtime/size) without re-parsing.
218
+ */
219
+ function healMetadata(ctx) {
220
+ const { db, metadataUpdates } = ctx;
221
+ if (!metadataUpdates || metadataUpdates.length === 0) return;
222
+ try {
223
+ const healHash = db.prepare(
224
+ 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)',
225
+ );
226
+ const healTx = db.transaction(() => {
227
+ for (const item of metadataUpdates) {
228
+ const mtime = item.stat ? Math.floor(item.stat.mtimeMs) : 0;
229
+ const size = item.stat ? item.stat.size : 0;
230
+ healHash.run(item.relPath, item.hash, mtime, size);
231
+ }
232
+ });
233
+ healTx();
234
+ debug(`Self-healed mtime/size for ${metadataUpdates.length} files`);
235
+ } catch {
236
+ /* ignore heal errors */
237
+ }
238
+ }
239
+
240
+ /**
241
+ * @param {import('../context.js').PipelineContext} ctx
242
+ */
243
+ export async function detectChanges(ctx) {
244
+ const { db, allFiles, rootDir, incremental, forceFullRebuild, opts } = ctx;
245
+
246
+ // Scoped builds already set parseChanges in collectFiles.
247
+ // Still need to purge removed files and set hasEmbeddings.
248
+ if (opts.scope) {
249
+ let hasEmbeddings = false;
250
+ try {
251
+ db.prepare('SELECT 1 FROM embeddings LIMIT 1').get();
252
+ hasEmbeddings = true;
253
+ } catch {
254
+ /* table doesn't exist */
255
+ }
256
+ ctx.hasEmbeddings = hasEmbeddings;
257
+
258
+ // Reverse-dependency cascade BEFORE purging (needs existing edges to find importers)
259
+ const changePaths = ctx.parseChanges.map(
260
+ (item) => item.relPath || normalizePath(path.relative(rootDir, item.file)),
261
+ );
262
+ const reverseDeps = new Set();
263
+ if (!opts.noReverseDeps) {
264
+ const changedRelPaths = new Set([...changePaths, ...ctx.removed]);
265
+ if (changedRelPaths.size > 0) {
266
+ const findReverseDeps = db.prepare(`
267
+ SELECT DISTINCT n_src.file FROM edges e
268
+ JOIN nodes n_src ON e.source_id = n_src.id
269
+ JOIN nodes n_tgt ON e.target_id = n_tgt.id
270
+ WHERE n_tgt.file = ? AND n_src.file != n_tgt.file AND n_src.kind != 'directory'
271
+ `);
272
+ for (const relPath of changedRelPaths) {
273
+ for (const row of findReverseDeps.all(relPath)) {
274
+ if (!changedRelPaths.has(row.file) && !reverseDeps.has(row.file)) {
275
+ const absPath = path.join(rootDir, row.file);
276
+ if (fs.existsSync(absPath)) {
277
+ reverseDeps.add(row.file);
278
+ }
279
+ }
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ // Now purge changed + removed files
286
+ if (changePaths.length > 0 || ctx.removed.length > 0) {
287
+ purgeFilesFromGraph(db, [...ctx.removed, ...changePaths], { purgeHashes: false });
288
+ }
289
+
290
+ // Delete outgoing edges for reverse-dep files and add to parse list
291
+ if (reverseDeps.size > 0) {
292
+ const deleteOutgoingEdgesForFile = db.prepare(
293
+ 'DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)',
294
+ );
295
+ for (const relPath of reverseDeps) {
296
+ deleteOutgoingEdgesForFile.run(relPath);
297
+ }
298
+ for (const relPath of reverseDeps) {
299
+ const absPath = path.join(rootDir, relPath);
300
+ ctx.parseChanges.push({ file: absPath, relPath, _reverseDepOnly: true });
301
+ }
302
+ info(
303
+ `Scoped rebuild: ${changePaths.length} changed, ${ctx.removed.length} removed, ${reverseDeps.size} reverse-deps`,
304
+ );
305
+ }
306
+ return;
307
+ }
308
+
309
+ const increResult =
310
+ incremental && !forceFullRebuild
311
+ ? getChangedFiles(db, allFiles, rootDir)
312
+ : { changed: allFiles.map((f) => ({ file: f })), removed: [], isFullBuild: true };
313
+
314
+ ctx.removed = increResult.removed;
315
+ ctx.isFullBuild = increResult.isFullBuild;
316
+ ctx.parseChanges = increResult.changed.filter((c) => !c.metadataOnly);
317
+ ctx.metadataUpdates = increResult.changed.filter((c) => c.metadataOnly);
318
+
319
+ // Early exit: no changes detected
320
+ if (!ctx.isFullBuild && ctx.parseChanges.length === 0 && ctx.removed.length === 0) {
321
+ const ranAnalysis = await runPendingAnalysis(ctx);
322
+ if (ranAnalysis) {
323
+ closeDb(db);
324
+ writeJournalHeader(rootDir, Date.now());
325
+ ctx.earlyExit = true;
326
+ return;
327
+ }
328
+
329
+ healMetadata(ctx);
330
+ info('No changes detected. Graph is up to date.');
331
+ closeDb(db);
332
+ writeJournalHeader(rootDir, Date.now());
333
+ ctx.earlyExit = true;
334
+ return;
335
+ }
336
+
337
+ // ── Full build: truncate all tables ──────────────────────────────
338
+ let hasEmbeddings = false;
339
+ try {
340
+ db.prepare('SELECT 1 FROM embeddings LIMIT 1').get();
341
+ hasEmbeddings = true;
342
+ } catch {
343
+ /* table doesn't exist */
344
+ }
345
+ ctx.hasEmbeddings = hasEmbeddings;
346
+
347
+ if (ctx.isFullBuild) {
348
+ const deletions =
349
+ 'PRAGMA foreign_keys = OFF; DELETE FROM cfg_edges; DELETE FROM cfg_blocks; DELETE FROM node_metrics; DELETE FROM edges; DELETE FROM function_complexity; DELETE FROM dataflow; DELETE FROM ast_nodes; DELETE FROM nodes; PRAGMA foreign_keys = ON;';
350
+ db.exec(
351
+ hasEmbeddings
352
+ ? `${deletions.replace('PRAGMA foreign_keys = ON;', '')} DELETE FROM embeddings; PRAGMA foreign_keys = ON;`
353
+ : deletions,
354
+ );
355
+ return;
356
+ }
357
+
358
+ // ── Reverse-dependency cascade (incremental) ─────────────────────
359
+ const reverseDeps = new Set();
360
+ if (!opts.noReverseDeps) {
361
+ const changedRelPaths = new Set();
362
+ for (const item of ctx.parseChanges) {
363
+ changedRelPaths.add(item.relPath || normalizePath(path.relative(rootDir, item.file)));
364
+ }
365
+ for (const relPath of ctx.removed) {
366
+ changedRelPaths.add(relPath);
367
+ }
368
+
369
+ if (changedRelPaths.size > 0) {
370
+ const findReverseDeps = db.prepare(`
371
+ SELECT DISTINCT n_src.file FROM edges e
372
+ JOIN nodes n_src ON e.source_id = n_src.id
373
+ JOIN nodes n_tgt ON e.target_id = n_tgt.id
374
+ WHERE n_tgt.file = ? AND n_src.file != n_tgt.file AND n_src.kind != 'directory'
375
+ `);
376
+ for (const relPath of changedRelPaths) {
377
+ for (const row of findReverseDeps.all(relPath)) {
378
+ if (!changedRelPaths.has(row.file) && !reverseDeps.has(row.file)) {
379
+ const absPath = path.join(rootDir, row.file);
380
+ if (fs.existsSync(absPath)) {
381
+ reverseDeps.add(row.file);
382
+ }
383
+ }
384
+ }
385
+ }
386
+ }
387
+ }
388
+
389
+ info(
390
+ `Incremental: ${ctx.parseChanges.length} changed, ${ctx.removed.length} removed${reverseDeps.size > 0 ? `, ${reverseDeps.size} reverse-deps` : ''}`,
391
+ );
392
+ if (ctx.parseChanges.length > 0)
393
+ debug(`Changed files: ${ctx.parseChanges.map((c) => c.relPath).join(', ')}`);
394
+ if (ctx.removed.length > 0) debug(`Removed files: ${ctx.removed.join(', ')}`);
395
+
396
+ // Purge changed and removed files
397
+ const changePaths = ctx.parseChanges.map(
398
+ (item) => item.relPath || normalizePath(path.relative(rootDir, item.file)),
399
+ );
400
+ purgeFilesFromGraph(db, [...ctx.removed, ...changePaths], { purgeHashes: false });
401
+
402
+ // Delete outgoing edges for reverse-dep files, then add them to parse list
403
+ const deleteOutgoingEdgesForFile = db.prepare(
404
+ 'DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)',
405
+ );
406
+ for (const relPath of reverseDeps) {
407
+ deleteOutgoingEdgesForFile.run(relPath);
408
+ }
409
+ for (const relPath of reverseDeps) {
410
+ const absPath = path.join(rootDir, relPath);
411
+ ctx.parseChanges.push({ file: absPath, relPath, _reverseDepOnly: true });
412
+ }
413
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Stage: finalize
3
+ *
4
+ * WASM cleanup, stats logging, drift detection, build metadata, registry, journal.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { performance } from 'node:perf_hooks';
9
+ import { closeDb, getBuildMeta, setBuildMeta } from '../../db.js';
10
+ import { writeJournalHeader } from '../../journal.js';
11
+ import { debug, info, warn } from '../../logger.js';
12
+
13
+ const __builderDir = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/i, '$1'));
14
+ const CODEGRAPH_VERSION = JSON.parse(
15
+ fs.readFileSync(path.join(__builderDir, '..', '..', '..', 'package.json'), 'utf-8'),
16
+ ).version;
17
+
18
+ /**
19
+ * @param {import('../context.js').PipelineContext} ctx
20
+ */
21
+ export async function finalize(ctx) {
22
+ const { db, allSymbols, rootDir, isFullBuild, hasEmbeddings, config, opts, schemaVersion } = ctx;
23
+
24
+ const t0 = performance.now();
25
+
26
+ // Release cached WASM trees
27
+ for (const [, symbols] of allSymbols) {
28
+ if (symbols._tree && typeof symbols._tree.delete === 'function') {
29
+ try {
30
+ symbols._tree.delete();
31
+ } catch {}
32
+ }
33
+ symbols._tree = null;
34
+ symbols._langId = null;
35
+ }
36
+
37
+ const nodeCount = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c;
38
+ const actualEdgeCount = db.prepare('SELECT COUNT(*) as c FROM edges').get().c;
39
+ info(`Graph built: ${nodeCount} nodes, ${actualEdgeCount} edges`);
40
+ info(`Stored in ${ctx.dbPath}`);
41
+
42
+ // Incremental drift detection
43
+ if (!isFullBuild) {
44
+ const prevNodes = getBuildMeta(db, 'node_count');
45
+ const prevEdges = getBuildMeta(db, 'edge_count');
46
+ if (prevNodes && prevEdges) {
47
+ const prevN = Number(prevNodes);
48
+ const prevE = Number(prevEdges);
49
+ if (prevN > 0) {
50
+ const nodeDrift = Math.abs(nodeCount - prevN) / prevN;
51
+ const edgeDrift = prevE > 0 ? Math.abs(actualEdgeCount - prevE) / prevE : 0;
52
+ const driftThreshold = config.build?.driftThreshold ?? 0.2;
53
+ if (nodeDrift > driftThreshold || edgeDrift > driftThreshold) {
54
+ warn(
55
+ `Incremental build diverged significantly from previous counts (nodes: ${prevN}→${nodeCount} [${(nodeDrift * 100).toFixed(1)}%], edges: ${prevE}→${actualEdgeCount} [${(edgeDrift * 100).toFixed(1)}%], threshold: ${(driftThreshold * 100).toFixed(0)}%). Consider rebuilding with --no-incremental.`,
56
+ );
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ // Orphaned embeddings warning
63
+ if (hasEmbeddings) {
64
+ try {
65
+ const orphaned = db
66
+ .prepare('SELECT COUNT(*) as c FROM embeddings WHERE node_id NOT IN (SELECT id FROM nodes)')
67
+ .get().c;
68
+ if (orphaned > 0) {
69
+ warn(
70
+ `${orphaned} embeddings are orphaned (nodes changed). Run "codegraph embed" to refresh.`,
71
+ );
72
+ }
73
+ } catch {
74
+ /* ignore — embeddings table may have been dropped */
75
+ }
76
+ }
77
+
78
+ // Unused exports warning
79
+ try {
80
+ const unusedCount = db
81
+ .prepare(
82
+ `SELECT COUNT(*) as c FROM nodes
83
+ WHERE exported = 1 AND kind != 'file'
84
+ AND id NOT IN (
85
+ SELECT DISTINCT e.target_id FROM edges e
86
+ JOIN nodes caller ON e.source_id = caller.id
87
+ JOIN nodes target ON e.target_id = target.id
88
+ WHERE e.kind = 'calls' AND caller.file != target.file
89
+ )`,
90
+ )
91
+ .get().c;
92
+ if (unusedCount > 0) {
93
+ warn(
94
+ `${unusedCount} exported symbol${unusedCount > 1 ? 's have' : ' has'} zero cross-file consumers. Run "codegraph exports <file> --unused" to inspect.`,
95
+ );
96
+ }
97
+ } catch {
98
+ /* exported column may not exist on older DBs */
99
+ }
100
+
101
+ // Persist build metadata
102
+ try {
103
+ setBuildMeta(db, {
104
+ engine: ctx.engineName,
105
+ engine_version: ctx.engineVersion || '',
106
+ codegraph_version: CODEGRAPH_VERSION,
107
+ schema_version: String(schemaVersion),
108
+ built_at: new Date().toISOString(),
109
+ node_count: nodeCount,
110
+ edge_count: actualEdgeCount,
111
+ });
112
+ } catch (err) {
113
+ warn(`Failed to write build metadata: ${err.message}`);
114
+ }
115
+
116
+ closeDb(db);
117
+
118
+ // Write journal header after successful build
119
+ writeJournalHeader(rootDir, Date.now());
120
+
121
+ // Auto-registration
122
+ if (!opts.skipRegistry) {
123
+ const { tmpdir } = await import('node:os');
124
+ const tmpDir = path.resolve(tmpdir());
125
+ const resolvedRoot = path.resolve(rootDir);
126
+ if (resolvedRoot.startsWith(tmpDir)) {
127
+ debug(`Skipping auto-registration for temp directory: ${resolvedRoot}`);
128
+ } else {
129
+ try {
130
+ const { registerRepo } = await import('../../registry.js');
131
+ registerRepo(rootDir);
132
+ } catch (err) {
133
+ debug(`Auto-registration failed: ${err.message}`);
134
+ }
135
+ }
136
+ }
137
+
138
+ ctx.timing.finalizeMs = performance.now() - t0;
139
+ }