@optave/codegraph 3.1.4 → 3.2.0

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 (210) hide show
  1. package/README.md +29 -72
  2. package/package.json +10 -8
  3. package/src/ast-analysis/engine.js +260 -246
  4. package/src/ast-analysis/shared.js +2 -14
  5. package/src/ast-analysis/visitors/cfg-visitor.js +635 -649
  6. package/src/ast-analysis/visitors/complexity-visitor.js +135 -139
  7. package/src/ast-analysis/visitors/dataflow-visitor.js +230 -224
  8. package/src/cli/commands/ast.js +4 -7
  9. package/src/cli/commands/audit.js +11 -11
  10. package/src/cli/commands/batch.js +6 -5
  11. package/src/cli/commands/branch-compare.js +1 -1
  12. package/src/cli/commands/brief.js +12 -0
  13. package/src/cli/commands/build.js +1 -1
  14. package/src/cli/commands/cfg.js +5 -8
  15. package/src/cli/commands/check.js +28 -36
  16. package/src/cli/commands/children.js +9 -7
  17. package/src/cli/commands/co-change.js +5 -3
  18. package/src/cli/commands/communities.js +2 -6
  19. package/src/cli/commands/complexity.js +5 -3
  20. package/src/cli/commands/context.js +9 -8
  21. package/src/cli/commands/cycles.js +12 -8
  22. package/src/cli/commands/dataflow.js +5 -8
  23. package/src/cli/commands/deps.js +9 -8
  24. package/src/cli/commands/diff-impact.js +2 -6
  25. package/src/cli/commands/embed.js +1 -1
  26. package/src/cli/commands/export.js +34 -31
  27. package/src/cli/commands/exports.js +2 -6
  28. package/src/cli/commands/flow.js +5 -8
  29. package/src/cli/commands/fn-impact.js +9 -8
  30. package/src/cli/commands/impact.js +2 -6
  31. package/src/cli/commands/info.js +2 -2
  32. package/src/cli/commands/map.js +1 -1
  33. package/src/cli/commands/mcp.js +1 -1
  34. package/src/cli/commands/models.js +1 -1
  35. package/src/cli/commands/owners.js +5 -3
  36. package/src/cli/commands/path.js +2 -2
  37. package/src/cli/commands/plot.js +40 -31
  38. package/src/cli/commands/query.js +9 -8
  39. package/src/cli/commands/registry.js +2 -2
  40. package/src/cli/commands/roles.js +5 -8
  41. package/src/cli/commands/search.js +9 -3
  42. package/src/cli/commands/sequence.js +5 -8
  43. package/src/cli/commands/snapshot.js +6 -1
  44. package/src/cli/commands/stats.js +1 -1
  45. package/src/cli/commands/structure.js +5 -4
  46. package/src/cli/commands/triage.js +41 -30
  47. package/src/cli/commands/watch.js +1 -1
  48. package/src/cli/commands/where.js +2 -6
  49. package/src/cli/index.js +11 -5
  50. package/src/cli/shared/open-graph.js +13 -0
  51. package/src/cli/shared/options.js +22 -2
  52. package/src/cli.js +1 -1
  53. package/src/db/connection.js +140 -11
  54. package/src/{db.js → db/index.js} +12 -5
  55. package/src/db/migrations.js +42 -65
  56. package/src/db/query-builder.js +72 -9
  57. package/src/db/repository/base.js +1 -1
  58. package/src/db/repository/graph-read.js +3 -3
  59. package/src/db/repository/in-memory-repository.js +30 -28
  60. package/src/db/repository/nodes.js +10 -17
  61. package/src/domain/analysis/brief.js +155 -0
  62. package/src/domain/analysis/context.js +392 -0
  63. package/src/domain/analysis/dependencies.js +395 -0
  64. package/src/{analysis → domain/analysis}/exports.js +11 -6
  65. package/src/domain/analysis/impact.js +581 -0
  66. package/src/domain/analysis/module-map.js +348 -0
  67. package/src/{analysis → domain/analysis}/roles.js +12 -9
  68. package/src/{analysis → domain/analysis}/symbol-lookup.js +19 -11
  69. package/src/{builder → domain/graph/builder}/helpers.js +4 -4
  70. package/src/{builder → domain/graph/builder}/incremental.js +119 -93
  71. package/src/domain/graph/builder/pipeline.js +156 -0
  72. package/src/domain/graph/builder/stages/build-edges.js +376 -0
  73. package/src/{builder → domain/graph/builder}/stages/build-structure.js +4 -4
  74. package/src/{builder → domain/graph/builder}/stages/collect-files.js +2 -2
  75. package/src/{builder → domain/graph/builder}/stages/detect-changes.js +204 -183
  76. package/src/{builder → domain/graph/builder}/stages/finalize.js +4 -4
  77. package/src/domain/graph/builder/stages/insert-nodes.js +203 -0
  78. package/src/{builder → domain/graph/builder}/stages/parse-files.js +2 -2
  79. package/src/{builder → domain/graph/builder}/stages/resolve-imports.js +1 -1
  80. package/src/{builder → domain/graph/builder}/stages/run-analyses.js +2 -2
  81. package/src/{change-journal.js → domain/graph/change-journal.js} +1 -1
  82. package/src/{cycles.js → domain/graph/cycles.js} +4 -4
  83. package/src/{journal.js → domain/graph/journal.js} +1 -1
  84. package/src/{resolve.js → domain/graph/resolve.js} +2 -2
  85. package/src/{watcher.js → domain/graph/watcher.js} +7 -7
  86. package/src/{parser.js → domain/parser.js} +24 -15
  87. package/src/{queries.js → domain/queries.js} +17 -16
  88. package/src/{embeddings → domain/search}/generator.js +3 -3
  89. package/src/{embeddings → domain/search}/models.js +2 -2
  90. package/src/{embeddings → domain/search}/search/cli-formatter.js +1 -1
  91. package/src/{embeddings → domain/search}/search/filters.js +9 -5
  92. package/src/{embeddings → domain/search}/search/hybrid.js +1 -1
  93. package/src/{embeddings → domain/search}/search/keyword.js +13 -6
  94. package/src/{embeddings → domain/search}/search/prepare.js +15 -7
  95. package/src/{embeddings → domain/search}/search/semantic.js +1 -1
  96. package/src/{embeddings → domain/search}/strategies/structured.js +1 -1
  97. package/src/extractors/csharp.js +224 -207
  98. package/src/extractors/go.js +176 -172
  99. package/src/extractors/hcl.js +94 -78
  100. package/src/extractors/java.js +213 -207
  101. package/src/extractors/javascript.js +275 -305
  102. package/src/extractors/php.js +234 -221
  103. package/src/extractors/python.js +252 -250
  104. package/src/extractors/ruby.js +192 -185
  105. package/src/extractors/rust.js +182 -167
  106. package/src/{ast.js → features/ast.js} +13 -11
  107. package/src/{audit.js → features/audit.js} +20 -46
  108. package/src/{batch.js → features/batch.js} +5 -5
  109. package/src/{boundaries.js → features/boundaries.js} +100 -85
  110. package/src/{branch-compare.js → features/branch-compare.js} +3 -3
  111. package/src/{cfg.js → features/cfg.js} +141 -150
  112. package/src/{check.js → features/check.js} +13 -30
  113. package/src/{cochange.js → features/cochange.js} +5 -5
  114. package/src/{communities.js → features/communities.js} +72 -57
  115. package/src/{complexity.js → features/complexity.js} +154 -143
  116. package/src/{dataflow.js → features/dataflow.js} +155 -158
  117. package/src/{export.js → features/export.js} +6 -6
  118. package/src/{flow.js → features/flow.js} +4 -4
  119. package/src/{viewer.js → features/graph-enrichment.js} +8 -8
  120. package/src/{manifesto.js → features/manifesto.js} +15 -12
  121. package/src/{owners.js → features/owners.js} +6 -5
  122. package/src/features/sequence.js +300 -0
  123. package/src/features/shared/find-nodes.js +31 -0
  124. package/src/{snapshot.js → features/snapshot.js} +3 -3
  125. package/src/{structure.js → features/structure.js} +139 -108
  126. package/src/features/triage.js +141 -0
  127. package/src/graph/builders/dependency.js +33 -14
  128. package/src/graph/classifiers/risk.js +3 -2
  129. package/src/graph/classifiers/roles.js +6 -3
  130. package/src/index.cjs +16 -0
  131. package/src/index.js +40 -39
  132. package/src/{native.js → infrastructure/native.js} +1 -1
  133. package/src/mcp/middleware.js +1 -1
  134. package/src/mcp/server.js +68 -59
  135. package/src/mcp/tool-registry.js +15 -2
  136. package/src/mcp/tools/ast-query.js +1 -1
  137. package/src/mcp/tools/audit.js +1 -1
  138. package/src/mcp/tools/batch-query.js +1 -1
  139. package/src/mcp/tools/branch-compare.js +3 -1
  140. package/src/mcp/tools/brief.js +8 -0
  141. package/src/mcp/tools/cfg.js +1 -1
  142. package/src/mcp/tools/check.js +3 -3
  143. package/src/mcp/tools/co-changes.js +1 -1
  144. package/src/mcp/tools/code-owners.js +1 -1
  145. package/src/mcp/tools/communities.js +1 -1
  146. package/src/mcp/tools/complexity.js +1 -1
  147. package/src/mcp/tools/dataflow.js +2 -2
  148. package/src/mcp/tools/execution-flow.js +2 -2
  149. package/src/mcp/tools/export-graph.js +2 -2
  150. package/src/mcp/tools/find-cycles.js +2 -2
  151. package/src/mcp/tools/index.js +2 -0
  152. package/src/mcp/tools/list-repos.js +1 -1
  153. package/src/mcp/tools/sequence.js +1 -1
  154. package/src/mcp/tools/structure.js +1 -1
  155. package/src/mcp/tools/triage.js +2 -2
  156. package/src/{commands → presentation}/audit.js +2 -2
  157. package/src/{commands → presentation}/batch.js +1 -1
  158. package/src/{commands → presentation}/branch-compare.js +2 -2
  159. package/src/presentation/brief.js +51 -0
  160. package/src/{commands → presentation}/cfg.js +1 -1
  161. package/src/{commands → presentation}/check.js +2 -2
  162. package/src/{commands → presentation}/communities.js +1 -1
  163. package/src/{commands → presentation}/complexity.js +1 -1
  164. package/src/{commands → presentation}/dataflow.js +1 -1
  165. package/src/{commands → presentation}/flow.js +2 -2
  166. package/src/{commands → presentation}/manifesto.js +1 -1
  167. package/src/{commands → presentation}/owners.js +1 -1
  168. package/src/presentation/queries-cli/exports.js +53 -0
  169. package/src/presentation/queries-cli/impact.js +214 -0
  170. package/src/presentation/queries-cli/index.js +5 -0
  171. package/src/presentation/queries-cli/inspect.js +329 -0
  172. package/src/presentation/queries-cli/overview.js +196 -0
  173. package/src/presentation/queries-cli/path.js +65 -0
  174. package/src/presentation/queries-cli.js +27 -0
  175. package/src/{commands → presentation}/query.js +1 -1
  176. package/src/presentation/result-formatter.js +126 -3
  177. package/src/{commands → presentation}/sequence.js +2 -2
  178. package/src/{commands → presentation}/structure.js +1 -1
  179. package/src/presentation/table.js +0 -8
  180. package/src/{commands → presentation}/triage.js +1 -1
  181. package/src/{constants.js → shared/constants.js} +1 -1
  182. package/src/shared/file-utils.js +2 -2
  183. package/src/shared/generators.js +9 -5
  184. package/src/shared/hierarchy.js +1 -1
  185. package/src/{kinds.js → shared/kinds.js} +1 -1
  186. package/src/analysis/context.js +0 -408
  187. package/src/analysis/dependencies.js +0 -341
  188. package/src/analysis/impact.js +0 -463
  189. package/src/analysis/module-map.js +0 -322
  190. package/src/builder/pipeline.js +0 -130
  191. package/src/builder/stages/build-edges.js +0 -297
  192. package/src/builder/stages/insert-nodes.js +0 -195
  193. package/src/mcp.js +0 -2
  194. package/src/queries-cli.js +0 -866
  195. package/src/sequence.js +0 -289
  196. package/src/triage.js +0 -126
  197. /package/src/{builder → domain/graph/builder}/context.js +0 -0
  198. /package/src/{builder.js → domain/graph/builder.js} +0 -0
  199. /package/src/{embeddings → domain/search}/index.js +0 -0
  200. /package/src/{embeddings → domain/search}/stores/fts5.js +0 -0
  201. /package/src/{embeddings → domain/search}/stores/sqlite-blob.js +0 -0
  202. /package/src/{embeddings → domain/search}/strategies/source.js +0 -0
  203. /package/src/{embeddings → domain/search}/strategies/text-utils.js +0 -0
  204. /package/src/{config.js → infrastructure/config.js} +0 -0
  205. /package/src/{logger.js → infrastructure/logger.js} +0 -0
  206. /package/src/{registry.js → infrastructure/registry.js} +0 -0
  207. /package/src/{update-check.js → infrastructure/update-check.js} +0 -0
  208. /package/src/{commands → presentation}/cochange.js +0 -0
  209. /package/src/{errors.js → shared/errors.js} +0 -0
  210. /package/src/{paginate.js → shared/paginate.js} +0 -0
@@ -0,0 +1,581 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {
5
+ findDbPath,
6
+ findDistinctCallers,
7
+ findFileNodes,
8
+ findImportDependents,
9
+ findNodeById,
10
+ openReadonlyOrFail,
11
+ } from '../../db/index.js';
12
+ import { evaluateBoundaries } from '../../features/boundaries.js';
13
+ import { coChangeForFiles } from '../../features/cochange.js';
14
+ import { ownersForFiles } from '../../features/owners.js';
15
+ import { loadConfig } from '../../infrastructure/config.js';
16
+ import { debug } from '../../infrastructure/logger.js';
17
+ import { isTestFile } from '../../infrastructure/test-filter.js';
18
+ import { normalizeSymbol } from '../../shared/normalize.js';
19
+ import { paginateResult } from '../../shared/paginate.js';
20
+ import { findMatchingNodes } from './symbol-lookup.js';
21
+
22
+ // ─── Shared BFS: transitive callers ────────────────────────────────────
23
+
24
+ /**
25
+ * BFS traversal to find transitive callers of a node.
26
+ *
27
+ * @param {import('better-sqlite3').Database} db - Open read-only SQLite database handle (not a Repository)
28
+ * @param {number} startId - Starting node ID
29
+ * @param {{ noTests?: boolean, maxDepth?: number, onVisit?: (caller: object, parentId: number, depth: number) => void }} options
30
+ * @returns {{ totalDependents: number, levels: Record<number, Array<{name:string, kind:string, file:string, line:number}>> }}
31
+ */
32
+ export function bfsTransitiveCallers(db, startId, { noTests = false, maxDepth = 3, onVisit } = {}) {
33
+ const visited = new Set([startId]);
34
+ const levels = {};
35
+ let frontier = [startId];
36
+
37
+ for (let d = 1; d <= maxDepth; d++) {
38
+ const nextFrontier = [];
39
+ for (const fid of frontier) {
40
+ const callers = findDistinctCallers(db, fid);
41
+ for (const c of callers) {
42
+ if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
43
+ visited.add(c.id);
44
+ nextFrontier.push(c.id);
45
+ if (!levels[d]) levels[d] = [];
46
+ levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
47
+ if (onVisit) onVisit(c, fid, d);
48
+ }
49
+ }
50
+ }
51
+ frontier = nextFrontier;
52
+ if (frontier.length === 0) break;
53
+ }
54
+
55
+ return { totalDependents: visited.size - 1, levels };
56
+ }
57
+
58
+ export function impactAnalysisData(file, customDbPath, opts = {}) {
59
+ const db = openReadonlyOrFail(customDbPath);
60
+ try {
61
+ const noTests = opts.noTests || false;
62
+ const fileNodes = findFileNodes(db, `%${file}%`);
63
+ if (fileNodes.length === 0) {
64
+ return { file, sources: [], levels: {}, totalDependents: 0 };
65
+ }
66
+
67
+ const visited = new Set();
68
+ const queue = [];
69
+ const levels = new Map();
70
+
71
+ for (const fn of fileNodes) {
72
+ visited.add(fn.id);
73
+ queue.push(fn.id);
74
+ levels.set(fn.id, 0);
75
+ }
76
+
77
+ while (queue.length > 0) {
78
+ const current = queue.shift();
79
+ const level = levels.get(current);
80
+ const dependents = findImportDependents(db, current);
81
+ for (const dep of dependents) {
82
+ if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) {
83
+ visited.add(dep.id);
84
+ queue.push(dep.id);
85
+ levels.set(dep.id, level + 1);
86
+ }
87
+ }
88
+ }
89
+
90
+ const byLevel = {};
91
+ for (const [id, level] of levels) {
92
+ if (level === 0) continue;
93
+ if (!byLevel[level]) byLevel[level] = [];
94
+ const node = findNodeById(db, id);
95
+ if (node) byLevel[level].push({ file: node.file });
96
+ }
97
+
98
+ return {
99
+ file,
100
+ sources: fileNodes.map((f) => f.file),
101
+ levels: byLevel,
102
+ totalDependents: visited.size - fileNodes.length,
103
+ };
104
+ } finally {
105
+ db.close();
106
+ }
107
+ }
108
+
109
+ export function fnImpactData(name, customDbPath, opts = {}) {
110
+ const db = openReadonlyOrFail(customDbPath);
111
+ try {
112
+ const maxDepth = opts.depth || 5;
113
+ const noTests = opts.noTests || false;
114
+ const hc = new Map();
115
+
116
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
117
+ if (nodes.length === 0) {
118
+ return { name, results: [] };
119
+ }
120
+
121
+ const results = nodes.map((node) => {
122
+ const { levels, totalDependents } = bfsTransitiveCallers(db, node.id, { noTests, maxDepth });
123
+ return {
124
+ ...normalizeSymbol(node, db, hc),
125
+ levels,
126
+ totalDependents,
127
+ };
128
+ });
129
+
130
+ const base = { name, results };
131
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
132
+ } finally {
133
+ db.close();
134
+ }
135
+ }
136
+
137
+ // ─── diffImpactData helpers ─────────────────────────────────────────────
138
+
139
+ /**
140
+ * Walk up from repoRoot until a .git directory is found.
141
+ * Returns true if a git root exists, false otherwise.
142
+ *
143
+ * @param {string} repoRoot
144
+ * @returns {boolean}
145
+ */
146
+ function findGitRoot(repoRoot) {
147
+ let checkDir = repoRoot;
148
+ while (checkDir) {
149
+ if (fs.existsSync(path.join(checkDir, '.git'))) {
150
+ return true;
151
+ }
152
+ const parent = path.dirname(checkDir);
153
+ if (parent === checkDir) break;
154
+ checkDir = parent;
155
+ }
156
+ return false;
157
+ }
158
+
159
+ /**
160
+ * Execute git diff and return the raw output string.
161
+ * Returns `{ output: string }` on success or `{ error: string }` on failure.
162
+ *
163
+ * @param {string} repoRoot
164
+ * @param {{ staged?: boolean, ref?: string }} opts
165
+ * @returns {{ output: string } | { error: string }}
166
+ */
167
+ function runGitDiff(repoRoot, opts) {
168
+ try {
169
+ const args = opts.staged
170
+ ? ['diff', '--cached', '--unified=0', '--no-color']
171
+ : ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color'];
172
+ const output = execFileSync('git', args, {
173
+ cwd: repoRoot,
174
+ encoding: 'utf-8',
175
+ maxBuffer: 10 * 1024 * 1024,
176
+ stdio: ['pipe', 'pipe', 'pipe'],
177
+ });
178
+ return { output };
179
+ } catch (e) {
180
+ return { error: `Failed to run git diff: ${e.message}` };
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Parse raw git diff output into a changedRanges map and newFiles set.
186
+ *
187
+ * @param {string} diffOutput
188
+ * @returns {{ changedRanges: Map<string, Array<{start: number, end: number}>>, newFiles: Set<string> }}
189
+ */
190
+ function parseGitDiff(diffOutput) {
191
+ const changedRanges = new Map();
192
+ const newFiles = new Set();
193
+ let currentFile = null;
194
+ let prevIsDevNull = false;
195
+
196
+ for (const line of diffOutput.split('\n')) {
197
+ if (line.startsWith('--- /dev/null')) {
198
+ prevIsDevNull = true;
199
+ continue;
200
+ }
201
+ if (line.startsWith('--- ')) {
202
+ prevIsDevNull = false;
203
+ continue;
204
+ }
205
+ const fileMatch = line.match(/^\+\+\+ b\/(.+)/);
206
+ if (fileMatch) {
207
+ currentFile = fileMatch[1];
208
+ if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []);
209
+ if (prevIsDevNull) newFiles.add(currentFile);
210
+ prevIsDevNull = false;
211
+ continue;
212
+ }
213
+ const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/);
214
+ if (hunkMatch && currentFile) {
215
+ const start = parseInt(hunkMatch[1], 10);
216
+ const count = parseInt(hunkMatch[2] || '1', 10);
217
+ changedRanges.get(currentFile).push({ start, end: start + count - 1 });
218
+ }
219
+ }
220
+
221
+ return { changedRanges, newFiles };
222
+ }
223
+
224
+ /**
225
+ * Find all function/method/class nodes whose line ranges overlap any changed range.
226
+ *
227
+ * @param {import('better-sqlite3').Database} db
228
+ * @param {Map<string, Array<{start: number, end: number}>} changedRanges
229
+ * @param {boolean} noTests
230
+ * @returns {Array<object>}
231
+ */
232
+ function findAffectedFunctions(db, changedRanges, noTests) {
233
+ const affectedFunctions = [];
234
+ for (const [file, ranges] of changedRanges) {
235
+ if (noTests && isTestFile(file)) continue;
236
+ const defs = db
237
+ .prepare(
238
+ `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`,
239
+ )
240
+ .all(file);
241
+ for (let i = 0; i < defs.length; i++) {
242
+ const def = defs[i];
243
+ const endLine = def.end_line || (defs[i + 1] ? defs[i + 1].line - 1 : 999999);
244
+ for (const range of ranges) {
245
+ if (range.start <= endLine && range.end >= def.line) {
246
+ affectedFunctions.push(def);
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ }
252
+ return affectedFunctions;
253
+ }
254
+
255
+ /**
256
+ * Run BFS per affected function, collecting per-function results and the full affected set.
257
+ *
258
+ * @param {import('better-sqlite3').Database} db
259
+ * @param {Array<object>} affectedFunctions
260
+ * @param {boolean} noTests
261
+ * @param {number} maxDepth
262
+ * @returns {{ functionResults: Array<object>, allAffected: Set<string> }}
263
+ */
264
+ function buildFunctionImpactResults(db, affectedFunctions, noTests, maxDepth) {
265
+ const allAffected = new Set();
266
+ const functionResults = affectedFunctions.map((fn) => {
267
+ const edges = [];
268
+ const idToKey = new Map();
269
+ idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`);
270
+
271
+ const { levels, totalDependents } = bfsTransitiveCallers(db, fn.id, {
272
+ noTests,
273
+ maxDepth,
274
+ onVisit(c, parentId) {
275
+ allAffected.add(`${c.file}:${c.name}`);
276
+ const callerKey = `${c.file}::${c.name}:${c.line}`;
277
+ idToKey.set(c.id, callerKey);
278
+ edges.push({ from: idToKey.get(parentId), to: callerKey });
279
+ },
280
+ });
281
+
282
+ return {
283
+ name: fn.name,
284
+ kind: fn.kind,
285
+ file: fn.file,
286
+ line: fn.line,
287
+ transitiveCallers: totalDependents,
288
+ levels,
289
+ edges,
290
+ };
291
+ });
292
+
293
+ return { functionResults, allAffected };
294
+ }
295
+
296
+ /**
297
+ * Look up historically co-changed files for the set of changed files.
298
+ * Returns an empty array if the co_changes table is unavailable.
299
+ *
300
+ * @param {import('better-sqlite3').Database} db
301
+ * @param {Map<string, any>} changedRanges
302
+ * @param {Set<string>} affectedFiles
303
+ * @param {boolean} noTests
304
+ * @returns {Array<object>}
305
+ */
306
+ function lookupCoChanges(db, changedRanges, affectedFiles, noTests) {
307
+ try {
308
+ db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
309
+ const changedFilesList = [...changedRanges.keys()];
310
+ const coResults = coChangeForFiles(changedFilesList, db, {
311
+ minJaccard: 0.3,
312
+ limit: 20,
313
+ noTests,
314
+ });
315
+ return coResults.filter((r) => !affectedFiles.has(r.file));
316
+ } catch (e) {
317
+ debug(`co_changes lookup skipped: ${e.message}`);
318
+ return [];
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Look up CODEOWNERS for changed and affected files.
324
+ * Returns null if no owners are found or lookup fails.
325
+ *
326
+ * @param {Map<string, any>} changedRanges
327
+ * @param {Set<string>} affectedFiles
328
+ * @param {string} repoRoot
329
+ * @returns {{ owners: object, affectedOwners: Array<string>, suggestedReviewers: Array<string> } | null}
330
+ */
331
+ function lookupOwnership(changedRanges, affectedFiles, repoRoot) {
332
+ try {
333
+ const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])];
334
+ const ownerResult = ownersForFiles(allFilePaths, repoRoot);
335
+ if (ownerResult.affectedOwners.length > 0) {
336
+ return {
337
+ owners: Object.fromEntries(ownerResult.owners),
338
+ affectedOwners: ownerResult.affectedOwners,
339
+ suggestedReviewers: ownerResult.suggestedReviewers,
340
+ };
341
+ }
342
+ return null;
343
+ } catch (e) {
344
+ debug(`CODEOWNERS lookup skipped: ${e.message}`);
345
+ return null;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Check manifesto boundary violations scoped to the changed files.
351
+ * Returns `{ boundaryViolations, boundaryViolationCount }`.
352
+ *
353
+ * @param {import('better-sqlite3').Database} db
354
+ * @param {Map<string, any>} changedRanges
355
+ * @param {boolean} noTests
356
+ * @param {object} opts — full diffImpactData opts (may contain `opts.config`)
357
+ * @param {string} repoRoot
358
+ * @returns {{ boundaryViolations: Array<object>, boundaryViolationCount: number }}
359
+ */
360
+ function checkBoundaryViolations(db, changedRanges, noTests, opts, repoRoot) {
361
+ try {
362
+ const cfg = opts.config || loadConfig(repoRoot);
363
+ const boundaryConfig = cfg.manifesto?.boundaries;
364
+ if (boundaryConfig) {
365
+ const result = evaluateBoundaries(db, boundaryConfig, {
366
+ scopeFiles: [...changedRanges.keys()],
367
+ noTests,
368
+ });
369
+ return {
370
+ boundaryViolations: result.violations,
371
+ boundaryViolationCount: result.violationCount,
372
+ };
373
+ }
374
+ } catch (e) {
375
+ debug(`boundary check skipped: ${e.message}`);
376
+ }
377
+ return { boundaryViolations: [], boundaryViolationCount: 0 };
378
+ }
379
+
380
+ // ─── diffImpactData ─────────────────────────────────────────────────────
381
+
382
+ /**
383
+ * Fix #2: Shell injection vulnerability.
384
+ * Uses execFileSync instead of execSync to prevent shell interpretation of user input.
385
+ */
386
+ export function diffImpactData(customDbPath, opts = {}) {
387
+ const db = openReadonlyOrFail(customDbPath);
388
+ try {
389
+ const noTests = opts.noTests || false;
390
+ const maxDepth = opts.depth || 3;
391
+
392
+ const dbPath = findDbPath(customDbPath);
393
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
394
+
395
+ if (!findGitRoot(repoRoot)) {
396
+ return { error: `Not a git repository: ${repoRoot}` };
397
+ }
398
+
399
+ const gitResult = runGitDiff(repoRoot, opts);
400
+ if (gitResult.error) return { error: gitResult.error };
401
+
402
+ if (!gitResult.output.trim()) {
403
+ return {
404
+ changedFiles: 0,
405
+ newFiles: [],
406
+ affectedFunctions: [],
407
+ affectedFiles: [],
408
+ summary: null,
409
+ };
410
+ }
411
+
412
+ const { changedRanges, newFiles } = parseGitDiff(gitResult.output);
413
+
414
+ if (changedRanges.size === 0) {
415
+ return {
416
+ changedFiles: 0,
417
+ newFiles: [],
418
+ affectedFunctions: [],
419
+ affectedFiles: [],
420
+ summary: null,
421
+ };
422
+ }
423
+
424
+ const affectedFunctions = findAffectedFunctions(db, changedRanges, noTests);
425
+ const { functionResults, allAffected } = buildFunctionImpactResults(
426
+ db,
427
+ affectedFunctions,
428
+ noTests,
429
+ maxDepth,
430
+ );
431
+
432
+ const affectedFiles = new Set();
433
+ for (const key of allAffected) affectedFiles.add(key.split(':')[0]);
434
+
435
+ const historicallyCoupled = lookupCoChanges(db, changedRanges, affectedFiles, noTests);
436
+ const ownership = lookupOwnership(changedRanges, affectedFiles, repoRoot);
437
+ const { boundaryViolations, boundaryViolationCount } = checkBoundaryViolations(
438
+ db,
439
+ changedRanges,
440
+ noTests,
441
+ opts,
442
+ repoRoot,
443
+ );
444
+
445
+ const base = {
446
+ changedFiles: changedRanges.size,
447
+ newFiles: [...newFiles],
448
+ affectedFunctions: functionResults,
449
+ affectedFiles: [...affectedFiles],
450
+ historicallyCoupled,
451
+ ownership,
452
+ boundaryViolations,
453
+ boundaryViolationCount,
454
+ summary: {
455
+ functionsChanged: affectedFunctions.length,
456
+ callersAffected: allAffected.size,
457
+ filesAffected: affectedFiles.size,
458
+ historicallyCoupledCount: historicallyCoupled.length,
459
+ ownersAffected: ownership ? ownership.affectedOwners.length : 0,
460
+ boundaryViolationCount,
461
+ },
462
+ };
463
+ return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset });
464
+ } finally {
465
+ db.close();
466
+ }
467
+ }
468
+
469
+ export function diffImpactMermaid(customDbPath, opts = {}) {
470
+ const data = diffImpactData(customDbPath, opts);
471
+ if (data.error) return data.error;
472
+ if (data.changedFiles === 0 || data.affectedFunctions.length === 0) {
473
+ return 'flowchart TB\n none["No impacted functions detected"]';
474
+ }
475
+
476
+ const newFileSet = new Set(data.newFiles || []);
477
+ const lines = ['flowchart TB'];
478
+
479
+ // Assign stable Mermaid node IDs
480
+ let nodeCounter = 0;
481
+ const nodeIdMap = new Map();
482
+ const nodeLabels = new Map();
483
+ function nodeId(key, label) {
484
+ if (!nodeIdMap.has(key)) {
485
+ nodeIdMap.set(key, `n${nodeCounter++}`);
486
+ if (label) nodeLabels.set(key, label);
487
+ }
488
+ return nodeIdMap.get(key);
489
+ }
490
+
491
+ // Register all nodes (changed functions + their callers)
492
+ for (const fn of data.affectedFunctions) {
493
+ nodeId(`${fn.file}::${fn.name}:${fn.line}`, fn.name);
494
+ for (const callers of Object.values(fn.levels || {})) {
495
+ for (const c of callers) {
496
+ nodeId(`${c.file}::${c.name}:${c.line}`, c.name);
497
+ }
498
+ }
499
+ }
500
+
501
+ // Collect all edges and determine blast radius
502
+ const allEdges = new Set();
503
+ const edgeFromNodes = new Set();
504
+ const edgeToNodes = new Set();
505
+ const changedKeys = new Set();
506
+
507
+ for (const fn of data.affectedFunctions) {
508
+ changedKeys.add(`${fn.file}::${fn.name}:${fn.line}`);
509
+ for (const edge of fn.edges || []) {
510
+ const edgeKey = `${edge.from}|${edge.to}`;
511
+ if (!allEdges.has(edgeKey)) {
512
+ allEdges.add(edgeKey);
513
+ edgeFromNodes.add(edge.from);
514
+ edgeToNodes.add(edge.to);
515
+ }
516
+ }
517
+ }
518
+
519
+ // Blast radius: caller nodes that are never a source (leaf nodes of the impact tree)
520
+ const blastRadiusKeys = new Set();
521
+ for (const key of edgeToNodes) {
522
+ if (!edgeFromNodes.has(key) && !changedKeys.has(key)) {
523
+ blastRadiusKeys.add(key);
524
+ }
525
+ }
526
+
527
+ // Intermediate callers: not changed, not blast radius
528
+ const intermediateKeys = new Set();
529
+ for (const key of edgeToNodes) {
530
+ if (!changedKeys.has(key) && !blastRadiusKeys.has(key)) {
531
+ intermediateKeys.add(key);
532
+ }
533
+ }
534
+
535
+ // Group changed functions by file
536
+ const fileGroups = new Map();
537
+ for (const fn of data.affectedFunctions) {
538
+ if (!fileGroups.has(fn.file)) fileGroups.set(fn.file, []);
539
+ fileGroups.get(fn.file).push(fn);
540
+ }
541
+
542
+ // Emit changed-file subgraphs
543
+ let sgCounter = 0;
544
+ for (const [file, fns] of fileGroups) {
545
+ const isNew = newFileSet.has(file);
546
+ const tag = isNew ? 'new' : 'modified';
547
+ const sgId = `sg${sgCounter++}`;
548
+ lines.push(` subgraph ${sgId}["${file} **(${tag})**"]`);
549
+ for (const fn of fns) {
550
+ const key = `${fn.file}::${fn.name}:${fn.line}`;
551
+ lines.push(` ${nodeIdMap.get(key)}["${fn.name}"]`);
552
+ }
553
+ lines.push(' end');
554
+ const style = isNew ? 'fill:#e8f5e9,stroke:#4caf50' : 'fill:#fff3e0,stroke:#ff9800';
555
+ lines.push(` style ${sgId} ${style}`);
556
+ }
557
+
558
+ // Emit intermediate caller nodes (outside subgraphs)
559
+ for (const key of intermediateKeys) {
560
+ lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`);
561
+ }
562
+
563
+ // Emit blast radius subgraph
564
+ if (blastRadiusKeys.size > 0) {
565
+ const sgId = `sg${sgCounter++}`;
566
+ lines.push(` subgraph ${sgId}["Callers **(blast radius)**"]`);
567
+ for (const key of blastRadiusKeys) {
568
+ lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`);
569
+ }
570
+ lines.push(' end');
571
+ lines.push(` style ${sgId} fill:#f3e5f5,stroke:#9c27b0`);
572
+ }
573
+
574
+ // Emit edges (impact flows from changed fn toward callers)
575
+ for (const edgeKey of allEdges) {
576
+ const [from, to] = edgeKey.split('|');
577
+ lines.push(` ${nodeIdMap.get(from)} --> ${nodeIdMap.get(to)}`);
578
+ }
579
+
580
+ return lines.join('\n');
581
+ }