@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
@@ -72,6 +72,55 @@ export function escapeLike(s) {
72
72
  return s.replace(/[%_\\]/g, '\\$&');
73
73
  }
74
74
 
75
+ /**
76
+ * Normalize a file filter value (string, string[], or falsy) into a flat array.
77
+ * Returns an empty array when the input is falsy.
78
+ * @param {string|string[]|undefined|null} file
79
+ * @returns {string[]}
80
+ */
81
+ export function normalizeFileFilter(file) {
82
+ if (!file) return [];
83
+ return Array.isArray(file) ? file : [file];
84
+ }
85
+
86
+ /**
87
+ * Build a SQL condition + params for a multi-value file LIKE filter.
88
+ * Returns `{ sql: '', params: [] }` when the filter is empty.
89
+ *
90
+ * @param {string|string[]} file - One or more partial file paths
91
+ * @param {string} [column='file'] - The column name to filter on (e.g. 'n.file', 'a.file')
92
+ * @returns {{ sql: string, params: string[] }}
93
+ */
94
+ export function buildFileConditionSQL(file, column = 'file') {
95
+ validateColumn(column);
96
+ const files = normalizeFileFilter(file);
97
+ if (files.length === 0) return { sql: '', params: [] };
98
+ if (files.length === 1) {
99
+ return {
100
+ sql: ` AND ${column} LIKE ? ESCAPE '\\'`,
101
+ params: [`%${escapeLike(files[0])}%`],
102
+ };
103
+ }
104
+ const clauses = files.map(() => `${column} LIKE ? ESCAPE '\\'`);
105
+ return {
106
+ sql: ` AND (${clauses.join(' OR ')})`,
107
+ params: files.map((f) => `%${escapeLike(f)}%`),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Commander option accumulator for repeatable `--file` flag.
113
+ * Use as: `['-f, --file <path>', 'Scope to file (partial match, repeatable)', collectFile]`
114
+ * @param {string} val - New value from Commander
115
+ * @param {string[]} acc - Accumulated values (undefined on first call)
116
+ * @returns {string[]}
117
+ */
118
+ export function collectFile(val, acc) {
119
+ acc = acc || [];
120
+ acc.push(val);
121
+ return acc;
122
+ }
123
+
75
124
  // ─── Standalone Helpers ──────────────────────────────────────────────
76
125
 
77
126
  /**
@@ -171,11 +220,18 @@ export class NodeQuery {
171
220
  return this;
172
221
  }
173
222
 
174
- /** WHERE n.file LIKE ? (no-op if falsy). Escapes LIKE wildcards in the value. */
223
+ /** WHERE n.file LIKE ? (no-op if falsy). Accepts a single string or string[]. */
175
224
  fileFilter(file) {
176
- if (!file) return this;
177
- this.#conditions.push("n.file LIKE ? ESCAPE '\\'");
178
- this.#params.push(`%${escapeLike(file)}%`);
225
+ const files = normalizeFileFilter(file);
226
+ if (files.length === 0) return this;
227
+ if (files.length === 1) {
228
+ this.#conditions.push("n.file LIKE ? ESCAPE '\\'");
229
+ this.#params.push(`%${escapeLike(files[0])}%`);
230
+ } else {
231
+ const clauses = files.map(() => "n.file LIKE ? ESCAPE '\\'");
232
+ this.#conditions.push(`(${clauses.join(' OR ')})`);
233
+ this.#params.push(...files.map((f) => `%${escapeLike(f)}%`));
234
+ }
179
235
  return this;
180
236
  }
181
237
 
@@ -1,6 +1,6 @@
1
1
  import { ConfigError } from '../../shared/errors.js';
2
2
  import { CORE_SYMBOL_KINDS, EVERY_SYMBOL_KIND, VALID_ROLES } from '../../shared/kinds.js';
3
- import { escapeLike } from '../query-builder.js';
3
+ import { escapeLike, normalizeFileFilter } from '../query-builder.js';
4
4
  import { Repository } from './base.js';
5
5
 
6
6
  /**
@@ -27,6 +27,17 @@ function likeToRegex(pattern) {
27
27
  return new RegExp(`^${regex}$`, 'i');
28
28
  }
29
29
 
30
+ /**
31
+ * Build a filter predicate for file matching.
32
+ * Accepts string, string[], or falsy. Returns null when no filtering needed.
33
+ */
34
+ function buildFileFilterFn(file) {
35
+ const files = normalizeFileFilter(file);
36
+ if (files.length === 0) return null;
37
+ const regexes = files.map((f) => likeToRegex(`%${escapeLike(f)}%`));
38
+ return (filePath) => regexes.some((re) => re.test(filePath));
39
+ }
40
+
30
41
  /**
31
42
  * In-memory Repository implementation backed by Maps.
32
43
  * No SQLite dependency — suitable for fast unit tests.
@@ -121,9 +132,9 @@ export class InMemoryRepository extends Repository {
121
132
  if (opts.kinds) {
122
133
  nodes = nodes.filter((n) => opts.kinds.includes(n.kind));
123
134
  }
124
- if (opts.file) {
125
- const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`);
126
- nodes = nodes.filter((n) => fileRe.test(n.file));
135
+ {
136
+ const fileFn = buildFileFilterFn(opts.file);
137
+ if (fileFn) nodes = nodes.filter((n) => fileFn(n.file));
127
138
  }
128
139
 
129
140
  // Compute fan-in per node
@@ -197,9 +208,9 @@ export class InMemoryRepository extends Repository {
197
208
  if (opts.kind) {
198
209
  nodes = nodes.filter((n) => n.kind === opts.kind);
199
210
  }
200
- if (opts.file) {
201
- const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`);
202
- nodes = nodes.filter((n) => fileRe.test(n.file));
211
+ {
212
+ const fileFn = buildFileFilterFn(opts.file);
213
+ if (fileFn) nodes = nodes.filter((n) => fileFn(n.file));
203
214
  }
204
215
 
205
216
  return nodes.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
@@ -208,9 +219,9 @@ export class InMemoryRepository extends Repository {
208
219
  findNodeByQualifiedName(qualifiedName, opts = {}) {
209
220
  let nodes = [...this.#nodes.values()].filter((n) => n.qualified_name === qualifiedName);
210
221
 
211
- if (opts.file) {
212
- const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`);
213
- nodes = nodes.filter((n) => fileRe.test(n.file));
222
+ {
223
+ const fileFn = buildFileFilterFn(opts.file);
224
+ if (fileFn) nodes = nodes.filter((n) => fileFn(n.file));
214
225
  }
215
226
 
216
227
  return nodes.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
@@ -248,9 +259,9 @@ export class InMemoryRepository extends Repository {
248
259
  !n.file.includes('.stories.'),
249
260
  );
250
261
  }
251
- if (opts.file) {
252
- const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`);
253
- nodes = nodes.filter((n) => fileRe.test(n.file));
262
+ {
263
+ const fileFn = buildFileFilterFn(opts.file);
264
+ if (fileFn) nodes = nodes.filter((n) => fileFn(n.file));
254
265
  }
255
266
  if (opts.role) {
256
267
  nodes = nodes.filter((n) => n.role === opts.role);
@@ -541,9 +552,9 @@ export class InMemoryRepository extends Repository {
541
552
  ['function', 'method', 'class'].includes(n.kind),
542
553
  );
543
554
 
544
- if (opts.file) {
545
- const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`);
546
- nodes = nodes.filter((n) => fileRe.test(n.file));
555
+ {
556
+ const fileFn = buildFileFilterFn(opts.file);
557
+ if (fileFn) nodes = nodes.filter((n) => fileFn(n.file));
547
558
  }
548
559
  if (opts.pattern) {
549
560
  const patternRe = likeToRegex(`%${escapeLike(opts.pattern)}%`);
@@ -1,6 +1,6 @@
1
1
  import { ConfigError } from '../../shared/errors.js';
2
2
  import { EVERY_SYMBOL_KIND, VALID_ROLES } from '../../shared/kinds.js';
3
- import { escapeLike, NodeQuery } from '../query-builder.js';
3
+ import { buildFileConditionSQL, NodeQuery } from '../query-builder.js';
4
4
  import { cachedStmt } from './cached-stmt.js';
5
5
 
6
6
  // ─── Query-builder based lookups (moved from src/db/repository.js) ─────
@@ -267,10 +267,9 @@ export function findNodesByScope(db, scopeName, opts = {}) {
267
267
  sql += ' AND kind = ?';
268
268
  params.push(opts.kind);
269
269
  }
270
- if (opts.file) {
271
- sql += " AND file LIKE ? ESCAPE '\\'";
272
- params.push(`%${escapeLike(opts.file)}%`);
273
- }
270
+ const fc = buildFileConditionSQL(opts.file, 'file');
271
+ sql += fc.sql;
272
+ params.push(...fc.params);
274
273
  sql += ' ORDER BY file, line';
275
274
  return db.prepare(sql).all(...params);
276
275
  }
@@ -286,12 +285,11 @@ export function findNodesByScope(db, scopeName, opts = {}) {
286
285
  * @returns {object[]}
287
286
  */
288
287
  export function findNodeByQualifiedName(db, qualifiedName, opts = {}) {
289
- if (opts.file) {
288
+ const fc = buildFileConditionSQL(opts.file, 'file');
289
+ if (fc.sql) {
290
290
  return db
291
- .prepare(
292
- "SELECT * FROM nodes WHERE qualified_name = ? AND file LIKE ? ESCAPE '\\' ORDER BY file, line",
293
- )
294
- .all(qualifiedName, `%${escapeLike(opts.file)}%`);
291
+ .prepare(`SELECT * FROM nodes WHERE qualified_name = ?${fc.sql} ORDER BY file, line`)
292
+ .all(qualifiedName, ...fc.params);
295
293
  }
296
294
  return cachedStmt(
297
295
  _findNodeByQualifiedNameStmt,
@@ -0,0 +1,155 @@
1
+ import {
2
+ findDistinctCallers,
3
+ findFileNodes,
4
+ findImportDependents,
5
+ findImportSources,
6
+ findImportTargets,
7
+ findNodesByFile,
8
+ openReadonlyOrFail,
9
+ } from '../../db/index.js';
10
+ import { isTestFile } from '../../infrastructure/test-filter.js';
11
+
12
+ /** Symbol kinds meaningful for a file brief — excludes parameters, properties, constants. */
13
+ const BRIEF_KINDS = new Set([
14
+ 'function',
15
+ 'method',
16
+ 'class',
17
+ 'interface',
18
+ 'type',
19
+ 'struct',
20
+ 'enum',
21
+ 'trait',
22
+ 'record',
23
+ 'module',
24
+ ]);
25
+
26
+ /**
27
+ * Compute file risk tier from symbol roles and max fan-in.
28
+ * @param {{ role: string|null, callerCount: number }[]} symbols
29
+ * @returns {'high'|'medium'|'low'}
30
+ */
31
+ function computeRiskTier(symbols) {
32
+ let maxCallers = 0;
33
+ let hasCoreRole = false;
34
+ for (const s of symbols) {
35
+ if (s.callerCount > maxCallers) maxCallers = s.callerCount;
36
+ if (s.role === 'core') hasCoreRole = true;
37
+ }
38
+ if (maxCallers >= 10 || hasCoreRole) return 'high';
39
+ if (maxCallers >= 3) return 'medium';
40
+ return 'low';
41
+ }
42
+
43
+ /**
44
+ * BFS to count transitive callers for a single node.
45
+ * Lightweight variant — only counts, does not collect details.
46
+ */
47
+ function countTransitiveCallers(db, startId, noTests, maxDepth = 5) {
48
+ const visited = new Set([startId]);
49
+ let frontier = [startId];
50
+
51
+ for (let d = 1; d <= maxDepth; d++) {
52
+ const nextFrontier = [];
53
+ for (const fid of frontier) {
54
+ const callers = findDistinctCallers(db, fid);
55
+ for (const c of callers) {
56
+ if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
57
+ visited.add(c.id);
58
+ nextFrontier.push(c.id);
59
+ }
60
+ }
61
+ }
62
+ frontier = nextFrontier;
63
+ if (frontier.length === 0) break;
64
+ }
65
+
66
+ return visited.size - 1;
67
+ }
68
+
69
+ /**
70
+ * Count transitive file-level import dependents via BFS.
71
+ * Depth-bounded to match countTransitiveCallers and keep hook latency predictable.
72
+ */
73
+ function countTransitiveImporters(db, fileNodeIds, noTests, maxDepth = 5) {
74
+ const visited = new Set(fileNodeIds);
75
+ let frontier = [...fileNodeIds];
76
+
77
+ for (let d = 1; d <= maxDepth; d++) {
78
+ const nextFrontier = [];
79
+ for (const current of frontier) {
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
+ nextFrontier.push(dep.id);
85
+ }
86
+ }
87
+ }
88
+ frontier = nextFrontier;
89
+ if (frontier.length === 0) break;
90
+ }
91
+
92
+ return visited.size - fileNodeIds.length;
93
+ }
94
+
95
+ /**
96
+ * Produce a token-efficient file brief: symbols with roles and caller counts,
97
+ * importer info with transitive count, and file risk tier.
98
+ *
99
+ * @param {string} file - File path (partial match)
100
+ * @param {string} customDbPath - Path to graph.db
101
+ * @param {{ noTests?: boolean }} opts
102
+ * @returns {{ file: string, results: object[] }}
103
+ */
104
+ export function briefData(file, customDbPath, opts = {}) {
105
+ const db = openReadonlyOrFail(customDbPath);
106
+ try {
107
+ const noTests = opts.noTests || false;
108
+ const fileNodes = findFileNodes(db, `%${file}%`);
109
+ if (fileNodes.length === 0) {
110
+ return { file, results: [] };
111
+ }
112
+
113
+ const results = fileNodes.map((fn) => {
114
+ // Direct importers
115
+ let importedBy = findImportSources(db, fn.id);
116
+ if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
117
+ const directImporters = [...new Set(importedBy.map((i) => i.file))];
118
+
119
+ // Transitive importer count
120
+ const totalImporterCount = countTransitiveImporters(db, [fn.id], noTests);
121
+
122
+ // Direct imports
123
+ let importsTo = findImportTargets(db, fn.id);
124
+ if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file));
125
+
126
+ // Symbol definitions with roles and caller counts
127
+ const defs = findNodesByFile(db, fn.file).filter((d) => BRIEF_KINDS.has(d.kind));
128
+ const symbols = defs.map((d) => {
129
+ const callerCount = countTransitiveCallers(db, d.id, noTests);
130
+ return {
131
+ name: d.name,
132
+ kind: d.kind,
133
+ line: d.line,
134
+ role: d.role || null,
135
+ callerCount,
136
+ };
137
+ });
138
+
139
+ const riskTier = computeRiskTier(symbols);
140
+
141
+ return {
142
+ file: fn.file,
143
+ risk: riskTier,
144
+ imports: importsTo.map((i) => i.file),
145
+ importedBy: directImporters,
146
+ totalImporterCount,
147
+ symbols,
148
+ };
149
+ });
150
+
151
+ return { file, results };
152
+ } finally {
153
+ db.close();
154
+ }
155
+ }