@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
package/README.md CHANGED
@@ -141,7 +141,7 @@ That's it. The graph is ready. Now connect your AI agent.
141
141
  Connect directly via MCP — your agent gets 30 tools to query the graph:
142
142
 
143
143
  ```bash
144
- codegraph mcp # 30-tool MCP server — AI queries the graph directly
144
+ codegraph mcp # 33-tool MCP server — AI queries the graph directly
145
145
  ```
146
146
 
147
147
  Or add codegraph to your agent's instructions (e.g. `CLAUDE.md`):
@@ -183,7 +183,7 @@ cd codegraph && npm install && npm link
183
183
 
184
184
  | | Feature | Description |
185
185
  |---|---|---|
186
- | 🤖 | **MCP server** | 30-tool MCP server for AI assistants; single-repo by default, opt-in multi-repo |
186
+ | 🤖 | **MCP server** | 33-tool MCP server for AI assistants; single-repo by default, opt-in multi-repo |
187
187
  | 🎯 | **Deep context** | `context` gives agents source, deps, callers, signature, and tests for a function in one call; `audit --quick` gives structural summaries |
188
188
  | 🏷️ | **Node role classification** | Every symbol auto-tagged as `entry`/`core`/`utility`/`adapter`/`dead`/`leaf` based on connectivity — agents instantly know architectural role |
189
189
  | 📦 | **Batch querying** | Accept a list of targets and return all results in one JSON payload — enables multi-agent parallel dispatch |
@@ -258,6 +258,7 @@ codegraph children <name> # List parameters, properties, constants of a
258
258
  ```bash
259
259
  codegraph context <name> # Full context: source, deps, callers, signature, tests
260
260
  codegraph context <name> --depth 2 --no-tests # Include callee source 2 levels deep
261
+ codegraph brief <file> # Token-efficient file summary: symbols, roles, risk tiers
261
262
  codegraph audit <file> --quick # Structural summary: public API, internals, data flow
262
263
  codegraph audit <function> --quick # Function summary: signature, calls, callers, tests
263
264
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optave/codegraph",
3
- "version": "3.1.5",
3
+ "version": "3.2.0",
4
4
  "description": "Local code graph CLI — parse codebases with tree-sitter, build dependency graphs, query them",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -76,12 +76,12 @@
76
76
  },
77
77
  "optionalDependencies": {
78
78
  "@modelcontextprotocol/sdk": "^1.0.0",
79
- "@optave/codegraph-darwin-arm64": "3.1.5",
80
- "@optave/codegraph-darwin-x64": "3.1.5",
81
- "@optave/codegraph-linux-arm64-gnu": "3.1.5",
82
- "@optave/codegraph-linux-x64-gnu": "3.1.5",
83
- "@optave/codegraph-linux-x64-musl": "3.1.5",
84
- "@optave/codegraph-win32-x64-msvc": "3.1.5"
79
+ "@optave/codegraph-darwin-arm64": "3.2.0",
80
+ "@optave/codegraph-darwin-x64": "3.2.0",
81
+ "@optave/codegraph-linux-arm64-gnu": "3.2.0",
82
+ "@optave/codegraph-linux-x64-gnu": "3.2.0",
83
+ "@optave/codegraph-linux-x64-musl": "3.2.0",
84
+ "@optave/codegraph-win32-x64-msvc": "3.2.0"
85
85
  },
86
86
  "devDependencies": {
87
87
  "@biomejs/biome": "^2.4.4",
@@ -50,294 +50,227 @@ async function getParserModule() {
50
50
  return _parserModule;
51
51
  }
52
52
 
53
- // ─── Public API ──────────────────────────────────────────────────────────
53
+ // ─── WASM pre-parse ─────────────────────────────────────────────────────
54
54
 
55
- /**
56
- * Run all enabled AST analyses in a coordinated pass.
57
- *
58
- * @param {object} db - open better-sqlite3 database (read-write)
59
- * @param {Map<string, object>} fileSymbols - Map<relPath, { definitions, calls, _tree, _langId, ... }>
60
- * @param {string} rootDir - absolute project root path
61
- * @param {object} opts - build options (ast, complexity, cfg, dataflow toggles)
62
- * @param {object} [engineOpts] - engine options
63
- * @returns {Promise<{ astMs: number, complexityMs: number, cfgMs: number, dataflowMs: number }>}
64
- */
65
- export async function runAnalyses(db, fileSymbols, rootDir, opts, engineOpts) {
66
- const timing = { astMs: 0, complexityMs: 0, cfgMs: 0, dataflowMs: 0 };
67
-
68
- const doAst = opts.ast !== false;
55
+ async function ensureWasmTreesIfNeeded(fileSymbols, opts, rootDir) {
69
56
  const doComplexity = opts.complexity !== false;
70
57
  const doCfg = opts.cfg !== false;
71
58
  const doDataflow = opts.dataflow !== false;
72
59
 
73
- if (!doAst && !doComplexity && !doCfg && !doDataflow) return timing;
74
-
75
- const extToLang = buildExtToLangMap();
76
-
77
- // ── WASM pre-parse for files that need it ───────────────────────────
78
- // The native engine only handles parsing (symbols, calls, imports).
79
- // Complexity, CFG, and dataflow all require a WASM tree-sitter tree
80
- // for their visitor walks. Without this, incremental rebuilds on the
81
- // native engine silently lose these analyses for changed files (#468).
82
- if (doComplexity || doCfg || doDataflow) {
83
- let needsWasmTrees = false;
84
- for (const [relPath, symbols] of fileSymbols) {
85
- if (symbols._tree) continue;
86
- const ext = path.extname(relPath).toLowerCase();
87
- const defs = symbols.definitions || [];
88
-
89
- const needsComplexity =
90
- doComplexity &&
91
- COMPLEXITY_EXTENSIONS.has(ext) &&
92
- defs.some((d) => (d.kind === 'function' || d.kind === 'method') && d.line && !d.complexity);
93
- const needsCfg =
94
- doCfg &&
95
- CFG_EXTENSIONS.has(ext) &&
96
- defs.some(
97
- (d) =>
98
- (d.kind === 'function' || d.kind === 'method') &&
99
- d.line &&
100
- d.cfg !== null &&
101
- !Array.isArray(d.cfg?.blocks),
102
- );
103
- const needsDataflow = doDataflow && !symbols.dataflow && DATAFLOW_EXTENSIONS.has(ext);
104
-
105
- if (needsComplexity || needsCfg || needsDataflow) {
106
- needsWasmTrees = true;
107
- break;
108
- }
109
- }
110
-
111
- if (needsWasmTrees) {
112
- try {
113
- const { ensureWasmTrees } = await getParserModule();
114
- await ensureWasmTrees(fileSymbols, rootDir);
115
- } catch (err) {
116
- debug(`ensureWasmTrees failed: ${err.message}`);
117
- }
118
- }
119
- }
120
-
121
- // ── Phase 7 Optimization: Unified pre-walk ─────────────────────────
122
- // For files with WASM trees, run all applicable visitors in a SINGLE
123
- // walkWithVisitors call. Store results in the format that buildXxx
124
- // functions already expect as pre-computed data (same fields as native
125
- // engine output). This eliminates ~3 redundant tree traversals per file.
126
- const t0walk = performance.now();
60
+ if (!doComplexity && !doCfg && !doDataflow) return;
127
61
 
62
+ let needsWasmTrees = false;
128
63
  for (const [relPath, symbols] of fileSymbols) {
129
- if (!symbols._tree) continue; // No WASM tree — native path handles it
130
-
64
+ if (symbols._tree) continue;
131
65
  const ext = path.extname(relPath).toLowerCase();
132
- const langId = symbols._langId || extToLang.get(ext);
133
- if (!langId) continue;
134
-
135
66
  const defs = symbols.definitions || [];
136
- const visitors = [];
137
- const walkerOpts = {
138
- functionNodeTypes: new Set(),
139
- nestingNodeTypes: new Set(),
140
- getFunctionName: (_node) => null,
141
- };
142
-
143
- // ─ AST-store visitor ─
144
- const astTypeMap = AST_TYPE_MAPS.get(langId);
145
- let astVisitor = null;
146
- if (doAst && astTypeMap && WALK_EXTENSIONS.has(ext) && !symbols.astNodes?.length) {
147
- const nodeIdMap = new Map();
148
- for (const row of bulkNodeIdsByFile(db, relPath)) {
149
- nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
150
- }
151
- astVisitor = createAstStoreVisitor(astTypeMap, defs, relPath, nodeIdMap);
152
- visitors.push(astVisitor);
153
- }
154
67
 
155
- // Complexity visitor (file-level mode) ─
156
- const cRules = COMPLEXITY_RULES.get(langId);
157
- const hRules = HALSTEAD_RULES.get(langId);
158
- let complexityVisitor = null;
159
- if (doComplexity && cRules) {
160
- // Only use visitor if some functions lack pre-computed complexity
161
- const needsWasmComplexity = defs.some(
162
- (d) => (d.kind === 'function' || d.kind === 'method') && d.line && !d.complexity,
163
- );
164
- if (needsWasmComplexity) {
165
- complexityVisitor = createComplexityVisitor(cRules, hRules, {
166
- fileLevelWalk: true,
167
- langId,
168
- });
169
- visitors.push(complexityVisitor);
170
-
171
- // Merge nesting nodes for complexity tracking
172
- // NOTE: do NOT add functionNodes here — funcDepth in the complexity
173
- // visitor already tracks function-level nesting. Adding them to
174
- // nestingNodeTypes would inflate context.nestingLevel by +1 inside
175
- // every function body, double-counting in cognitive += 1 + nestingLevel.
176
- for (const t of cRules.nestingNodes) walkerOpts.nestingNodeTypes.add(t);
177
-
178
- // Provide getFunctionName for complexity visitor
179
- const dfRules = DATAFLOW_RULES.get(langId);
180
- walkerOpts.getFunctionName = (node) => {
181
- // Try complexity rules' function name field first
182
- const nameNode = node.childForFieldName('name');
183
- if (nameNode) return nameNode.text;
184
- // Fall back to dataflow rules' richer name extraction
185
- if (dfRules) return getFuncName(node, dfRules);
186
- return null;
187
- };
188
- }
189
- }
190
-
191
- // ─ CFG visitor ─
192
- const cfgRulesForLang = CFG_RULES.get(langId);
193
- let cfgVisitor = null;
194
- if (doCfg && cfgRulesForLang && CFG_EXTENSIONS.has(ext)) {
195
- // Only use visitor if some functions lack pre-computed CFG
196
- const needsWasmCfg = defs.some(
68
+ const needsComplexity =
69
+ doComplexity &&
70
+ COMPLEXITY_EXTENSIONS.has(ext) &&
71
+ defs.some((d) => (d.kind === 'function' || d.kind === 'method') && d.line && !d.complexity);
72
+ const needsCfg =
73
+ doCfg &&
74
+ CFG_EXTENSIONS.has(ext) &&
75
+ defs.some(
197
76
  (d) =>
198
77
  (d.kind === 'function' || d.kind === 'method') &&
199
78
  d.line &&
200
79
  d.cfg !== null &&
201
80
  !Array.isArray(d.cfg?.blocks),
202
81
  );
203
- if (needsWasmCfg) {
204
- cfgVisitor = createCfgVisitor(cfgRulesForLang);
205
- visitors.push(cfgVisitor);
206
- }
82
+ const needsDataflow = doDataflow && !symbols.dataflow && DATAFLOW_EXTENSIONS.has(ext);
83
+
84
+ if (needsComplexity || needsCfg || needsDataflow) {
85
+ needsWasmTrees = true;
86
+ break;
207
87
  }
88
+ }
208
89
 
209
- // Dataflow visitor ─
210
- const dfRules = DATAFLOW_RULES.get(langId);
211
- let dataflowVisitor = null;
212
- if (doDataflow && dfRules && DATAFLOW_EXTENSIONS.has(ext) && !symbols.dataflow) {
213
- dataflowVisitor = createDataflowVisitor(dfRules);
214
- visitors.push(dataflowVisitor);
90
+ if (needsWasmTrees) {
91
+ try {
92
+ const { ensureWasmTrees } = await getParserModule();
93
+ await ensureWasmTrees(fileSymbols, rootDir);
94
+ } catch (err) {
95
+ debug(`ensureWasmTrees failed: ${err.message}`);
215
96
  }
97
+ }
98
+ }
216
99
 
217
- // Run unified walk if we have visitors ─
218
- if (visitors.length === 0) continue;
100
+ // ─── Per-file visitor setup ─────────────────────────────────────────────
219
101
 
220
- const results = walkWithVisitors(symbols._tree.rootNode, visitors, langId, walkerOpts);
102
+ function setupVisitors(db, relPath, symbols, langId, opts) {
103
+ const ext = path.extname(relPath).toLowerCase();
104
+ const defs = symbols.definitions || [];
105
+ const doAst = opts.ast !== false;
106
+ const doComplexity = opts.complexity !== false;
107
+ const doCfg = opts.cfg !== false;
108
+ const doDataflow = opts.dataflow !== false;
221
109
 
222
- // Store AST results (buildAstNodes will find symbols.astNodes and skip its walk) ─
223
- if (astVisitor) {
224
- const astRows = results['ast-store'] || [];
225
- if (astRows.length > 0) {
226
- // Store in the format buildAstNodes expects for the native path
227
- symbols.astNodes = astRows;
228
- }
110
+ const visitors = [];
111
+ const walkerOpts = {
112
+ functionNodeTypes: new Set(),
113
+ nestingNodeTypes: new Set(),
114
+ getFunctionName: (_node) => null,
115
+ };
116
+
117
+ // AST-store visitor
118
+ let astVisitor = null;
119
+ const astTypeMap = AST_TYPE_MAPS.get(langId);
120
+ if (doAst && astTypeMap && WALK_EXTENSIONS.has(ext) && !symbols.astNodes?.length) {
121
+ const nodeIdMap = new Map();
122
+ for (const row of bulkNodeIdsByFile(db, relPath)) {
123
+ nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
229
124
  }
125
+ astVisitor = createAstStoreVisitor(astTypeMap, defs, relPath, nodeIdMap);
126
+ visitors.push(astVisitor);
127
+ }
230
128
 
231
- // Store complexity results on definitions (buildComplexityMetrics will find def.complexity)
232
- if (complexityVisitor) {
233
- const complexityResults = results.complexity || [];
234
- // Match results back to definitions by function start line
235
- // Store the full result (metrics + funcNode) for O(1) lookup
236
- const resultByLine = new Map();
237
- for (const r of complexityResults) {
238
- if (r.funcNode) {
239
- const line = r.funcNode.startPosition.row + 1;
240
- if (!resultByLine.has(line)) resultByLine.set(line, []);
241
- resultByLine.get(line).push(r);
242
- }
243
- }
244
- for (const def of defs) {
245
- if ((def.kind === 'function' || def.kind === 'method') && def.line && !def.complexity) {
246
- const candidates = resultByLine.get(def.line);
247
- const funcResult = !candidates
248
- ? undefined
249
- : candidates.length === 1
250
- ? candidates[0]
251
- : (candidates.find((r) => {
252
- const n = r.funcNode.childForFieldName('name');
253
- return n && n.text === def.name;
254
- }) ?? candidates[0]);
255
- if (funcResult) {
256
- const { metrics } = funcResult;
257
- const loc = computeLOCMetrics(funcResult.funcNode, langId);
258
- const volume = metrics.halstead ? metrics.halstead.volume : 0;
259
- const commentRatio = loc.loc > 0 ? loc.commentLines / loc.loc : 0;
260
- const mi = computeMaintainabilityIndex(
261
- volume,
262
- metrics.cyclomatic,
263
- loc.sloc,
264
- commentRatio,
265
- );
266
-
267
- def.complexity = {
268
- cognitive: metrics.cognitive,
269
- cyclomatic: metrics.cyclomatic,
270
- maxNesting: metrics.maxNesting,
271
- halstead: metrics.halstead,
272
- loc,
273
- maintainabilityIndex: mi,
274
- };
275
- }
276
- }
277
- }
129
+ // Complexity visitor (file-level mode)
130
+ let complexityVisitor = null;
131
+ const cRules = COMPLEXITY_RULES.get(langId);
132
+ const hRules = HALSTEAD_RULES.get(langId);
133
+ if (doComplexity && cRules) {
134
+ const needsWasmComplexity = defs.some(
135
+ (d) => (d.kind === 'function' || d.kind === 'method') && d.line && !d.complexity,
136
+ );
137
+ if (needsWasmComplexity) {
138
+ complexityVisitor = createComplexityVisitor(cRules, hRules, { fileLevelWalk: true, langId });
139
+ visitors.push(complexityVisitor);
140
+
141
+ for (const t of cRules.nestingNodes) walkerOpts.nestingNodeTypes.add(t);
142
+
143
+ const dfRules = DATAFLOW_RULES.get(langId);
144
+ walkerOpts.getFunctionName = (node) => {
145
+ const nameNode = node.childForFieldName('name');
146
+ if (nameNode) return nameNode.text;
147
+ if (dfRules) return getFuncName(node, dfRules);
148
+ return null;
149
+ };
278
150
  }
151
+ }
279
152
 
280
- // ─ Store CFG results on definitions (buildCFGData will find def.cfg and skip its walk) ─
281
- if (cfgVisitor) {
282
- const cfgResults = results.cfg || [];
283
- const cfgByLine = new Map();
284
- for (const r of cfgResults) {
285
- if (r.funcNode) {
286
- const line = r.funcNode.startPosition.row + 1;
287
- if (!cfgByLine.has(line)) cfgByLine.set(line, []);
288
- cfgByLine.get(line).push(r);
289
- }
290
- }
291
- for (const def of defs) {
292
- if (
293
- (def.kind === 'function' || def.kind === 'method') &&
294
- def.line &&
295
- !def.cfg?.blocks?.length
296
- ) {
297
- const candidates = cfgByLine.get(def.line);
298
- const cfgResult = !candidates
299
- ? undefined
300
- : candidates.length === 1
301
- ? candidates[0]
302
- : (candidates.find((r) => {
303
- const n = r.funcNode.childForFieldName('name');
304
- return n && n.text === def.name;
305
- }) ?? candidates[0]);
306
- if (cfgResult) {
307
- def.cfg = { blocks: cfgResult.blocks, edges: cfgResult.edges };
308
-
309
- // Override complexity's cyclomatic with CFG-derived value (single source of truth)
310
- // and recompute maintainability index to stay consistent
311
- if (def.complexity && cfgResult.cyclomatic != null) {
312
- def.complexity.cyclomatic = cfgResult.cyclomatic;
313
- const { loc, halstead } = def.complexity;
314
- const volume = halstead ? halstead.volume : 0;
315
- const commentRatio = loc?.loc > 0 ? loc.commentLines / loc.loc : 0;
316
- def.complexity.maintainabilityIndex = computeMaintainabilityIndex(
317
- volume,
318
- cfgResult.cyclomatic,
319
- loc?.sloc ?? 0,
320
- commentRatio,
321
- );
322
- }
323
- }
324
- }
325
- }
153
+ // CFG visitor
154
+ let cfgVisitor = null;
155
+ const cfgRulesForLang = CFG_RULES.get(langId);
156
+ if (doCfg && cfgRulesForLang && CFG_EXTENSIONS.has(ext)) {
157
+ const needsWasmCfg = defs.some(
158
+ (d) =>
159
+ (d.kind === 'function' || d.kind === 'method') &&
160
+ d.line &&
161
+ d.cfg !== null &&
162
+ !Array.isArray(d.cfg?.blocks),
163
+ );
164
+ if (needsWasmCfg) {
165
+ cfgVisitor = createCfgVisitor(cfgRulesForLang);
166
+ visitors.push(cfgVisitor);
326
167
  }
168
+ }
169
+
170
+ // Dataflow visitor
171
+ let dataflowVisitor = null;
172
+ const dfRules = DATAFLOW_RULES.get(langId);
173
+ if (doDataflow && dfRules && DATAFLOW_EXTENSIONS.has(ext) && !symbols.dataflow) {
174
+ dataflowVisitor = createDataflowVisitor(dfRules);
175
+ visitors.push(dataflowVisitor);
176
+ }
177
+
178
+ return { visitors, walkerOpts, astVisitor, complexityVisitor, cfgVisitor, dataflowVisitor };
179
+ }
327
180
 
328
- // Store dataflow results (buildDataflowEdges will find symbols.dataflow and skip its walk) ─
329
- if (dataflowVisitor) {
330
- symbols.dataflow = results.dataflow;
181
+ // ─── Result storage helpers ─────────────────────────────────────────────
182
+
183
+ function storeComplexityResults(results, defs, langId) {
184
+ const complexityResults = results.complexity || [];
185
+ const resultByLine = new Map();
186
+ for (const r of complexityResults) {
187
+ if (r.funcNode) {
188
+ const line = r.funcNode.startPosition.row + 1;
189
+ if (!resultByLine.has(line)) resultByLine.set(line, []);
190
+ resultByLine.get(line).push(r);
331
191
  }
332
192
  }
193
+ for (const def of defs) {
194
+ if ((def.kind === 'function' || def.kind === 'method') && def.line && !def.complexity) {
195
+ const candidates = resultByLine.get(def.line);
196
+ const funcResult = !candidates
197
+ ? undefined
198
+ : candidates.length === 1
199
+ ? candidates[0]
200
+ : (candidates.find((r) => {
201
+ const n = r.funcNode.childForFieldName('name');
202
+ return n && n.text === def.name;
203
+ }) ?? candidates[0]);
204
+ if (funcResult) {
205
+ const { metrics } = funcResult;
206
+ const loc = computeLOCMetrics(funcResult.funcNode, langId);
207
+ const volume = metrics.halstead ? metrics.halstead.volume : 0;
208
+ const commentRatio = loc.loc > 0 ? loc.commentLines / loc.loc : 0;
209
+ const mi = computeMaintainabilityIndex(volume, metrics.cyclomatic, loc.sloc, commentRatio);
210
+
211
+ def.complexity = {
212
+ cognitive: metrics.cognitive,
213
+ cyclomatic: metrics.cyclomatic,
214
+ maxNesting: metrics.maxNesting,
215
+ halstead: metrics.halstead,
216
+ loc,
217
+ maintainabilityIndex: mi,
218
+ };
219
+ }
220
+ }
221
+ }
222
+ }
333
223
 
334
- timing._unifiedWalkMs = performance.now() - t0walk;
224
+ function storeCfgResults(results, defs) {
225
+ const cfgResults = results.cfg || [];
226
+ const cfgByLine = new Map();
227
+ for (const r of cfgResults) {
228
+ if (r.funcNode) {
229
+ const line = r.funcNode.startPosition.row + 1;
230
+ if (!cfgByLine.has(line)) cfgByLine.set(line, []);
231
+ cfgByLine.get(line).push(r);
232
+ }
233
+ }
234
+ for (const def of defs) {
235
+ if (
236
+ (def.kind === 'function' || def.kind === 'method') &&
237
+ def.line &&
238
+ !def.cfg?.blocks?.length
239
+ ) {
240
+ const candidates = cfgByLine.get(def.line);
241
+ const cfgResult = !candidates
242
+ ? undefined
243
+ : candidates.length === 1
244
+ ? candidates[0]
245
+ : (candidates.find((r) => {
246
+ const n = r.funcNode.childForFieldName('name');
247
+ return n && n.text === def.name;
248
+ }) ?? candidates[0]);
249
+ if (cfgResult) {
250
+ def.cfg = { blocks: cfgResult.blocks, edges: cfgResult.edges };
251
+
252
+ // Override complexity's cyclomatic with CFG-derived value (single source of truth)
253
+ if (def.complexity && cfgResult.cyclomatic != null) {
254
+ def.complexity.cyclomatic = cfgResult.cyclomatic;
255
+ const { loc, halstead } = def.complexity;
256
+ const volume = halstead ? halstead.volume : 0;
257
+ const commentRatio = loc?.loc > 0 ? loc.commentLines / loc.loc : 0;
258
+ def.complexity.maintainabilityIndex = computeMaintainabilityIndex(
259
+ volume,
260
+ cfgResult.cyclomatic,
261
+ loc?.sloc ?? 0,
262
+ commentRatio,
263
+ );
264
+ }
265
+ }
266
+ }
267
+ }
268
+ }
335
269
 
336
- // ── Delegate to buildXxx functions ─────────────────────────────────
337
- // Each function finds pre-computed data from the unified walk above
338
- // (or from the native engine) and only does DB writes + native fallback.
270
+ // ─── Build delegation ───────────────────────────────────────────────────
339
271
 
340
- if (doAst) {
272
+ async function delegateToBuildFunctions(db, fileSymbols, rootDir, opts, engineOpts, timing) {
273
+ if (opts.ast !== false) {
341
274
  const t0 = performance.now();
342
275
  try {
343
276
  const { buildAstNodes } = await import('../features/ast.js');
@@ -348,7 +281,7 @@ export async function runAnalyses(db, fileSymbols, rootDir, opts, engineOpts) {
348
281
  timing.astMs = performance.now() - t0;
349
282
  }
350
283
 
351
- if (doComplexity) {
284
+ if (opts.complexity !== false) {
352
285
  const t0 = performance.now();
353
286
  try {
354
287
  const { buildComplexityMetrics } = await import('../features/complexity.js');
@@ -359,7 +292,7 @@ export async function runAnalyses(db, fileSymbols, rootDir, opts, engineOpts) {
359
292
  timing.complexityMs = performance.now() - t0;
360
293
  }
361
294
 
362
- if (doCfg) {
295
+ if (opts.cfg !== false) {
363
296
  const t0 = performance.now();
364
297
  try {
365
298
  const { buildCFGData } = await import('../features/cfg.js');
@@ -370,7 +303,7 @@ export async function runAnalyses(db, fileSymbols, rootDir, opts, engineOpts) {
370
303
  timing.cfgMs = performance.now() - t0;
371
304
  }
372
305
 
373
- if (doDataflow) {
306
+ if (opts.dataflow !== false) {
374
307
  const t0 = performance.now();
375
308
  try {
376
309
  const { buildDataflowEdges } = await import('../features/dataflow.js');
@@ -380,6 +313,67 @@ export async function runAnalyses(db, fileSymbols, rootDir, opts, engineOpts) {
380
313
  }
381
314
  timing.dataflowMs = performance.now() - t0;
382
315
  }
316
+ }
317
+
318
+ // ─── Public API ──────────────────────────────────────────────────────────
319
+
320
+ /**
321
+ * Run all enabled AST analyses in a coordinated pass.
322
+ *
323
+ * @param {object} db - open better-sqlite3 database (read-write)
324
+ * @param {Map<string, object>} fileSymbols - Map<relPath, { definitions, calls, _tree, _langId, ... }>
325
+ * @param {string} rootDir - absolute project root path
326
+ * @param {object} opts - build options (ast, complexity, cfg, dataflow toggles)
327
+ * @param {object} [engineOpts] - engine options
328
+ * @returns {Promise<{ astMs: number, complexityMs: number, cfgMs: number, dataflowMs: number }>}
329
+ */
330
+ export async function runAnalyses(db, fileSymbols, rootDir, opts, engineOpts) {
331
+ const timing = { astMs: 0, complexityMs: 0, cfgMs: 0, dataflowMs: 0 };
332
+
333
+ const doAst = opts.ast !== false;
334
+ const doComplexity = opts.complexity !== false;
335
+ const doCfg = opts.cfg !== false;
336
+ const doDataflow = opts.dataflow !== false;
337
+
338
+ if (!doAst && !doComplexity && !doCfg && !doDataflow) return timing;
339
+
340
+ const extToLang = buildExtToLangMap();
341
+
342
+ // WASM pre-parse for files that need it
343
+ await ensureWasmTreesIfNeeded(fileSymbols, opts, rootDir);
344
+
345
+ // Unified pre-walk: run all applicable visitors in a single DFS per file
346
+ const t0walk = performance.now();
347
+
348
+ for (const [relPath, symbols] of fileSymbols) {
349
+ if (!symbols._tree) continue;
350
+
351
+ const ext = path.extname(relPath).toLowerCase();
352
+ const langId = symbols._langId || extToLang.get(ext);
353
+ if (!langId) continue;
354
+
355
+ const { visitors, walkerOpts, astVisitor, complexityVisitor, cfgVisitor, dataflowVisitor } =
356
+ setupVisitors(db, relPath, symbols, langId, opts);
357
+
358
+ if (visitors.length === 0) continue;
359
+
360
+ const results = walkWithVisitors(symbols._tree.rootNode, visitors, langId, walkerOpts);
361
+ const defs = symbols.definitions || [];
362
+
363
+ if (astVisitor) {
364
+ const astRows = results['ast-store'] || [];
365
+ if (astRows.length > 0) symbols.astNodes = astRows;
366
+ }
367
+
368
+ if (complexityVisitor) storeComplexityResults(results, defs, langId);
369
+ if (cfgVisitor) storeCfgResults(results, defs);
370
+ if (dataflowVisitor) symbols.dataflow = results.dataflow;
371
+ }
372
+
373
+ timing._unifiedWalkMs = performance.now() - t0walk;
374
+
375
+ // Delegate to buildXxx functions for DB writes + native fallback
376
+ await delegateToBuildFunctions(db, fileSymbols, rootDir, opts, engineOpts, timing);
383
377
 
384
378
  return timing;
385
379
  }
@@ -176,18 +176,6 @@ export function findFunctionNode(rootNode, startLine, _endLine, rules) {
176
176
  return best;
177
177
  }
178
178
 
179
- /**
180
- * Truncate a string to a maximum length, appending an ellipsis if truncated.
181
- *
182
- * @param {string} str - Input string
183
- * @param {number} [max=200] - Maximum length
184
- * @returns {string}
185
- */
186
- export function truncate(str, max = 200) {
187
- if (!str) return '';
188
- return str.length > max ? `${str.slice(0, max)}…` : str;
189
- }
190
-
191
179
  // ─── Extension / Language Mapping ─────────────────────────────────────────
192
180
 
193
181
  /**