@optave/codegraph 3.1.5 → 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 (91) hide show
  1. package/README.md +3 -2
  2. package/package.json +7 -7
  3. package/src/ast-analysis/engine.js +252 -258
  4. package/src/ast-analysis/shared.js +0 -12
  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 +2 -1
  9. package/src/cli/commands/audit.js +2 -1
  10. package/src/cli/commands/batch.js +2 -1
  11. package/src/cli/commands/brief.js +12 -0
  12. package/src/cli/commands/cfg.js +2 -1
  13. package/src/cli/commands/check.js +20 -23
  14. package/src/cli/commands/children.js +6 -1
  15. package/src/cli/commands/complexity.js +2 -1
  16. package/src/cli/commands/context.js +6 -1
  17. package/src/cli/commands/dataflow.js +2 -1
  18. package/src/cli/commands/deps.js +8 -3
  19. package/src/cli/commands/flow.js +2 -1
  20. package/src/cli/commands/fn-impact.js +6 -1
  21. package/src/cli/commands/owners.js +4 -2
  22. package/src/cli/commands/query.js +6 -1
  23. package/src/cli/commands/roles.js +2 -1
  24. package/src/cli/commands/search.js +8 -2
  25. package/src/cli/commands/sequence.js +2 -1
  26. package/src/cli/commands/triage.js +38 -27
  27. package/src/db/connection.js +18 -12
  28. package/src/db/migrations.js +41 -64
  29. package/src/db/query-builder.js +60 -4
  30. package/src/db/repository/in-memory-repository.js +27 -16
  31. package/src/db/repository/nodes.js +8 -10
  32. package/src/domain/analysis/brief.js +155 -0
  33. package/src/domain/analysis/context.js +174 -190
  34. package/src/domain/analysis/dependencies.js +200 -146
  35. package/src/domain/analysis/exports.js +3 -2
  36. package/src/domain/analysis/impact.js +267 -152
  37. package/src/domain/analysis/module-map.js +247 -221
  38. package/src/domain/analysis/roles.js +8 -5
  39. package/src/domain/analysis/symbol-lookup.js +7 -5
  40. package/src/domain/graph/builder/helpers.js +1 -1
  41. package/src/domain/graph/builder/incremental.js +116 -90
  42. package/src/domain/graph/builder/pipeline.js +106 -80
  43. package/src/domain/graph/builder/stages/build-edges.js +318 -239
  44. package/src/domain/graph/builder/stages/detect-changes.js +198 -177
  45. package/src/domain/graph/builder/stages/insert-nodes.js +147 -139
  46. package/src/domain/graph/watcher.js +2 -2
  47. package/src/domain/parser.js +20 -11
  48. package/src/domain/queries.js +1 -0
  49. package/src/domain/search/search/filters.js +9 -5
  50. package/src/domain/search/search/keyword.js +12 -5
  51. package/src/domain/search/search/prepare.js +13 -5
  52. package/src/extractors/csharp.js +224 -207
  53. package/src/extractors/go.js +176 -172
  54. package/src/extractors/hcl.js +94 -78
  55. package/src/extractors/java.js +213 -207
  56. package/src/extractors/javascript.js +274 -304
  57. package/src/extractors/php.js +234 -221
  58. package/src/extractors/python.js +252 -250
  59. package/src/extractors/ruby.js +192 -185
  60. package/src/extractors/rust.js +182 -167
  61. package/src/features/ast.js +5 -3
  62. package/src/features/audit.js +4 -2
  63. package/src/features/boundaries.js +98 -83
  64. package/src/features/cfg.js +134 -143
  65. package/src/features/communities.js +68 -53
  66. package/src/features/complexity.js +143 -132
  67. package/src/features/dataflow.js +146 -149
  68. package/src/features/export.js +3 -3
  69. package/src/features/graph-enrichment.js +2 -2
  70. package/src/features/manifesto.js +9 -6
  71. package/src/features/owners.js +4 -3
  72. package/src/features/sequence.js +152 -141
  73. package/src/features/shared/find-nodes.js +31 -0
  74. package/src/features/structure.js +130 -99
  75. package/src/features/triage.js +83 -68
  76. package/src/graph/classifiers/risk.js +3 -2
  77. package/src/graph/classifiers/roles.js +6 -3
  78. package/src/index.js +1 -0
  79. package/src/mcp/server.js +65 -56
  80. package/src/mcp/tool-registry.js +13 -0
  81. package/src/mcp/tools/brief.js +8 -0
  82. package/src/mcp/tools/index.js +2 -0
  83. package/src/presentation/brief.js +51 -0
  84. package/src/presentation/queries-cli/exports.js +21 -14
  85. package/src/presentation/queries-cli/impact.js +55 -39
  86. package/src/presentation/queries-cli/inspect.js +184 -189
  87. package/src/presentation/queries-cli/overview.js +57 -58
  88. package/src/presentation/queries-cli/path.js +36 -29
  89. package/src/presentation/table.js +0 -8
  90. package/src/shared/generators.js +7 -3
  91. package/src/shared/kinds.js +1 -1
@@ -13,6 +13,7 @@ import { evaluateBoundaries } from '../../features/boundaries.js';
13
13
  import { coChangeForFiles } from '../../features/cochange.js';
14
14
  import { ownersForFiles } from '../../features/owners.js';
15
15
  import { loadConfig } from '../../infrastructure/config.js';
16
+ import { debug } from '../../infrastructure/logger.js';
16
17
  import { isTestFile } from '../../infrastructure/test-filter.js';
17
18
  import { normalizeSymbol } from '../../shared/normalize.js';
18
19
  import { paginateResult } from '../../shared/paginate.js';
@@ -133,6 +134,251 @@ export function fnImpactData(name, customDbPath, opts = {}) {
133
134
  }
134
135
  }
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
+
136
382
  /**
137
383
  * Fix #2: Shell injection vulnerability.
138
384
  * Uses execFileSync instead of execSync to prevent shell interpretation of user input.
@@ -146,38 +392,14 @@ export function diffImpactData(customDbPath, opts = {}) {
146
392
  const dbPath = findDbPath(customDbPath);
147
393
  const repoRoot = path.resolve(path.dirname(dbPath), '..');
148
394
 
149
- // Verify we're in a git repository before running git diff
150
- let checkDir = repoRoot;
151
- let isGitRepo = false;
152
- while (checkDir) {
153
- if (fs.existsSync(path.join(checkDir, '.git'))) {
154
- isGitRepo = true;
155
- break;
156
- }
157
- const parent = path.dirname(checkDir);
158
- if (parent === checkDir) break;
159
- checkDir = parent;
160
- }
161
- if (!isGitRepo) {
395
+ if (!findGitRoot(repoRoot)) {
162
396
  return { error: `Not a git repository: ${repoRoot}` };
163
397
  }
164
398
 
165
- let diffOutput;
166
- try {
167
- const args = opts.staged
168
- ? ['diff', '--cached', '--unified=0', '--no-color']
169
- : ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color'];
170
- diffOutput = execFileSync('git', args, {
171
- cwd: repoRoot,
172
- encoding: 'utf-8',
173
- maxBuffer: 10 * 1024 * 1024,
174
- stdio: ['pipe', 'pipe', 'pipe'],
175
- });
176
- } catch (e) {
177
- return { error: `Failed to run git diff: ${e.message}` };
178
- }
399
+ const gitResult = runGitDiff(repoRoot, opts);
400
+ if (gitResult.error) return { error: gitResult.error };
179
401
 
180
- if (!diffOutput.trim()) {
402
+ if (!gitResult.output.trim()) {
181
403
  return {
182
404
  changedFiles: 0,
183
405
  newFiles: [],
@@ -187,34 +409,7 @@ export function diffImpactData(customDbPath, opts = {}) {
187
409
  };
188
410
  }
189
411
 
190
- const changedRanges = new Map();
191
- const newFiles = new Set();
192
- let currentFile = null;
193
- let prevIsDevNull = false;
194
- for (const line of diffOutput.split('\n')) {
195
- if (line.startsWith('--- /dev/null')) {
196
- prevIsDevNull = true;
197
- continue;
198
- }
199
- if (line.startsWith('--- ')) {
200
- prevIsDevNull = false;
201
- continue;
202
- }
203
- const fileMatch = line.match(/^\+\+\+ b\/(.+)/);
204
- if (fileMatch) {
205
- currentFile = fileMatch[1];
206
- if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []);
207
- if (prevIsDevNull) newFiles.add(currentFile);
208
- prevIsDevNull = false;
209
- continue;
210
- }
211
- const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/);
212
- if (hunkMatch && currentFile) {
213
- const start = parseInt(hunkMatch[1], 10);
214
- const count = parseInt(hunkMatch[2] || '1', 10);
215
- changedRanges.get(currentFile).push({ start, end: start + count - 1 });
216
- }
217
- }
412
+ const { changedRanges, newFiles } = parseGitDiff(gitResult.output);
218
413
 
219
414
  if (changedRanges.size === 0) {
220
415
  return {
@@ -226,106 +421,26 @@ export function diffImpactData(customDbPath, opts = {}) {
226
421
  };
227
422
  }
228
423
 
229
- const affectedFunctions = [];
230
- for (const [file, ranges] of changedRanges) {
231
- if (noTests && isTestFile(file)) continue;
232
- const defs = db
233
- .prepare(
234
- `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`,
235
- )
236
- .all(file);
237
- for (let i = 0; i < defs.length; i++) {
238
- const def = defs[i];
239
- const endLine = def.end_line || (defs[i + 1] ? defs[i + 1].line - 1 : 999999);
240
- for (const range of ranges) {
241
- if (range.start <= endLine && range.end >= def.line) {
242
- affectedFunctions.push(def);
243
- break;
244
- }
245
- }
246
- }
247
- }
248
-
249
- const allAffected = new Set();
250
- const functionResults = affectedFunctions.map((fn) => {
251
- const edges = [];
252
- const idToKey = new Map();
253
- idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`);
254
-
255
- const { levels, totalDependents } = bfsTransitiveCallers(db, fn.id, {
256
- noTests,
257
- maxDepth,
258
- onVisit(c, parentId) {
259
- allAffected.add(`${c.file}:${c.name}`);
260
- const callerKey = `${c.file}::${c.name}:${c.line}`;
261
- idToKey.set(c.id, callerKey);
262
- edges.push({ from: idToKey.get(parentId), to: callerKey });
263
- },
264
- });
265
-
266
- return {
267
- name: fn.name,
268
- kind: fn.kind,
269
- file: fn.file,
270
- line: fn.line,
271
- transitiveCallers: totalDependents,
272
- levels,
273
- edges,
274
- };
275
- });
424
+ const affectedFunctions = findAffectedFunctions(db, changedRanges, noTests);
425
+ const { functionResults, allAffected } = buildFunctionImpactResults(
426
+ db,
427
+ affectedFunctions,
428
+ noTests,
429
+ maxDepth,
430
+ );
276
431
 
277
432
  const affectedFiles = new Set();
278
433
  for (const key of allAffected) affectedFiles.add(key.split(':')[0]);
279
434
 
280
- // Look up historically coupled files from co-change data
281
- let historicallyCoupled = [];
282
- try {
283
- db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
284
- const changedFilesList = [...changedRanges.keys()];
285
- const coResults = coChangeForFiles(changedFilesList, db, {
286
- minJaccard: 0.3,
287
- limit: 20,
288
- noTests,
289
- });
290
- // Exclude files already found via static analysis
291
- historicallyCoupled = coResults.filter((r) => !affectedFiles.has(r.file));
292
- } catch {
293
- /* co_changes table doesn't exist — skip silently */
294
- }
295
-
296
- // Look up CODEOWNERS for changed + affected files
297
- let ownership = null;
298
- try {
299
- const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])];
300
- const ownerResult = ownersForFiles(allFilePaths, repoRoot);
301
- if (ownerResult.affectedOwners.length > 0) {
302
- ownership = {
303
- owners: Object.fromEntries(ownerResult.owners),
304
- affectedOwners: ownerResult.affectedOwners,
305
- suggestedReviewers: ownerResult.suggestedReviewers,
306
- };
307
- }
308
- } catch {
309
- /* CODEOWNERS missing or unreadable — skip silently */
310
- }
311
-
312
- // Check boundary violations scoped to changed files
313
- let boundaryViolations = [];
314
- let boundaryViolationCount = 0;
315
- try {
316
- const cfg = opts.config || loadConfig(repoRoot);
317
- const boundaryConfig = cfg.manifesto?.boundaries;
318
- if (boundaryConfig) {
319
- const result = evaluateBoundaries(db, boundaryConfig, {
320
- scopeFiles: [...changedRanges.keys()],
321
- noTests,
322
- });
323
- boundaryViolations = result.violations;
324
- boundaryViolationCount = result.violationCount;
325
- }
326
- } catch {
327
- /* boundary check failed — skip silently */
328
- }
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
+ );
329
444
 
330
445
  const base = {
331
446
  changedFiles: changedRanges.size,