@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
@@ -23,9 +23,9 @@ import {
23
23
  hasCfgTables,
24
24
  openReadonlyOrFail,
25
25
  } from '../db/index.js';
26
- import { info } from '../infrastructure/logger.js';
27
- import { isTestFile } from '../infrastructure/test-filter.js';
26
+ import { debug, info } from '../infrastructure/logger.js';
28
27
  import { paginateResult } from '../shared/paginate.js';
28
+ import { findNodes } from './shared/find-nodes.js';
29
29
 
30
30
  // Re-export for backward compatibility
31
31
  export { _makeCfgRules as makeCfgRules, CFG_RULES };
@@ -68,30 +68,15 @@ export function buildFunctionCFG(functionNode, langId) {
68
68
  return { blocks: r.blocks, edges: r.edges, cyclomatic: r.cyclomatic };
69
69
  }
70
70
 
71
- // ─── Build-Time: Compute CFG for Changed Files ─────────────────────────
71
+ // ─── Build-Time Helpers ─────────────────────────────────────────────────
72
72
 
73
- /**
74
- * Build CFG data for all function/method definitions and persist to DB.
75
- *
76
- * @param {object} db - open better-sqlite3 database (read-write)
77
- * @param {Map<string, object>} fileSymbols - Map<relPath, { definitions, _tree, _langId }>
78
- * @param {string} rootDir - absolute project root path
79
- * @param {object} [_engineOpts] - engine options (unused; always uses WASM for AST)
80
- */
81
- export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
82
- // Lazily init WASM parsers if needed
83
- let parsers = null;
73
+ async function initCfgParsers(fileSymbols) {
84
74
  let needsFallback = false;
85
75
 
86
- // Always build ext→langId map so native-only builds (where _langId is unset)
87
- // can still derive the language from the file extension.
88
- const extToLang = buildExtToLangMap();
89
-
90
76
  for (const [relPath, symbols] of fileSymbols) {
91
77
  if (!symbols._tree) {
92
78
  const ext = path.extname(relPath).toLowerCase();
93
79
  if (CFG_EXTENSIONS.has(ext)) {
94
- // Check if all function/method defs already have native CFG data
95
80
  const hasNativeCfg = symbols.definitions
96
81
  .filter((d) => (d.kind === 'function' || d.kind === 'method') && d.line)
97
82
  .every((d) => d.cfg === null || d.cfg?.blocks?.length);
@@ -103,18 +88,131 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
103
88
  }
104
89
  }
105
90
 
91
+ let parsers = null;
92
+ let getParserFn = null;
93
+
106
94
  if (needsFallback) {
107
95
  const { createParsers } = await import('../domain/parser.js');
108
96
  parsers = await createParsers();
109
- }
110
-
111
- let getParserFn = null;
112
- if (parsers) {
113
97
  const mod = await import('../domain/parser.js');
114
98
  getParserFn = mod.getParser;
115
99
  }
116
100
 
117
- // findFunctionNode imported from ./ast-analysis/shared.js at module level
101
+ return { parsers, getParserFn };
102
+ }
103
+
104
+ function getTreeAndLang(symbols, relPath, rootDir, extToLang, parsers, getParserFn) {
105
+ const ext = path.extname(relPath).toLowerCase();
106
+ let tree = symbols._tree;
107
+ let langId = symbols._langId;
108
+
109
+ const allNative = symbols.definitions
110
+ .filter((d) => (d.kind === 'function' || d.kind === 'method') && d.line)
111
+ .every((d) => d.cfg === null || d.cfg?.blocks?.length);
112
+
113
+ if (!tree && !allNative) {
114
+ if (!getParserFn) return null;
115
+ langId = extToLang.get(ext);
116
+ if (!langId || !CFG_RULES.has(langId)) return null;
117
+
118
+ const absPath = path.join(rootDir, relPath);
119
+ let code;
120
+ try {
121
+ code = fs.readFileSync(absPath, 'utf-8');
122
+ } catch (e) {
123
+ debug(`cfg: cannot read ${relPath}: ${e.message}`);
124
+ return null;
125
+ }
126
+
127
+ const parser = getParserFn(parsers, absPath);
128
+ if (!parser) return null;
129
+
130
+ try {
131
+ tree = parser.parse(code);
132
+ } catch (e) {
133
+ debug(`cfg: parse failed for ${relPath}: ${e.message}`);
134
+ return null;
135
+ }
136
+ }
137
+
138
+ if (!langId) {
139
+ langId = extToLang.get(ext);
140
+ if (!langId) return null;
141
+ }
142
+
143
+ return { tree, langId };
144
+ }
145
+
146
+ function buildVisitorCfgMap(tree, cfgRules, symbols, langId) {
147
+ const needsVisitor =
148
+ tree &&
149
+ symbols.definitions.some(
150
+ (d) =>
151
+ (d.kind === 'function' || d.kind === 'method') &&
152
+ d.line &&
153
+ d.cfg !== null &&
154
+ !d.cfg?.blocks?.length,
155
+ );
156
+ if (!needsVisitor) return null;
157
+
158
+ const visitor = createCfgVisitor(cfgRules);
159
+ const walkerOpts = {
160
+ functionNodeTypes: new Set(cfgRules.functionNodes),
161
+ nestingNodeTypes: new Set(),
162
+ getFunctionName: (node) => {
163
+ const nameNode = node.childForFieldName('name');
164
+ return nameNode ? nameNode.text : null;
165
+ },
166
+ };
167
+ const walkResults = walkWithVisitors(tree.rootNode, [visitor], langId, walkerOpts);
168
+ const cfgResults = walkResults.cfg || [];
169
+ const visitorCfgByLine = new Map();
170
+ for (const r of cfgResults) {
171
+ if (r.funcNode) {
172
+ const line = r.funcNode.startPosition.row + 1;
173
+ if (!visitorCfgByLine.has(line)) visitorCfgByLine.set(line, []);
174
+ visitorCfgByLine.get(line).push(r);
175
+ }
176
+ }
177
+ return visitorCfgByLine;
178
+ }
179
+
180
+ function persistCfg(cfg, nodeId, insertBlock, insertEdge) {
181
+ const blockDbIds = new Map();
182
+ for (const block of cfg.blocks) {
183
+ const result = insertBlock.run(
184
+ nodeId,
185
+ block.index,
186
+ block.type,
187
+ block.startLine,
188
+ block.endLine,
189
+ block.label,
190
+ );
191
+ blockDbIds.set(block.index, result.lastInsertRowid);
192
+ }
193
+
194
+ for (const edge of cfg.edges) {
195
+ const sourceDbId = blockDbIds.get(edge.sourceIndex);
196
+ const targetDbId = blockDbIds.get(edge.targetIndex);
197
+ if (sourceDbId && targetDbId) {
198
+ insertEdge.run(nodeId, sourceDbId, targetDbId, edge.kind);
199
+ }
200
+ }
201
+ }
202
+
203
+ // ─── Build-Time: Compute CFG for Changed Files ─────────────────────────
204
+
205
+ /**
206
+ * Build CFG data for all function/method definitions and persist to DB.
207
+ *
208
+ * @param {object} db - open better-sqlite3 database (read-write)
209
+ * @param {Map<string, object>} fileSymbols - Map<relPath, { definitions, _tree, _langId }>
210
+ * @param {string} rootDir - absolute project root path
211
+ * @param {object} [_engineOpts] - engine options (unused; always uses WASM for AST)
212
+ */
213
+ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
214
+ const extToLang = buildExtToLangMap();
215
+ const { parsers, getParserFn } = await initCfgParsers(fileSymbols);
118
216
 
119
217
  const insertBlock = db.prepare(
120
218
  `INSERT INTO cfg_blocks (function_node_id, block_index, block_type, start_line, end_line, label)
@@ -131,79 +229,14 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
131
229
  const ext = path.extname(relPath).toLowerCase();
132
230
  if (!CFG_EXTENSIONS.has(ext)) continue;
133
231
 
134
- let tree = symbols._tree;
135
- let langId = symbols._langId;
136
-
137
- // Check if all defs already have native CFG — skip WASM parse if so
138
- const allNative = symbols.definitions
139
- .filter((d) => (d.kind === 'function' || d.kind === 'method') && d.line)
140
- .every((d) => d.cfg === null || d.cfg?.blocks?.length);
141
-
142
- // WASM fallback if no cached tree and not all native
143
- if (!tree && !allNative) {
144
- if (!getParserFn) continue;
145
- langId = extToLang.get(ext);
146
- if (!langId || !CFG_RULES.has(langId)) continue;
147
-
148
- const absPath = path.join(rootDir, relPath);
149
- let code;
150
- try {
151
- code = fs.readFileSync(absPath, 'utf-8');
152
- } catch {
153
- continue;
154
- }
155
-
156
- const parser = getParserFn(parsers, absPath);
157
- if (!parser) continue;
158
-
159
- try {
160
- tree = parser.parse(code);
161
- } catch {
162
- continue;
163
- }
164
- }
165
-
166
- if (!langId) {
167
- langId = extToLang.get(ext);
168
- if (!langId) continue;
169
- }
232
+ const treeLang = getTreeAndLang(symbols, relPath, rootDir, extToLang, parsers, getParserFn);
233
+ if (!treeLang) continue;
234
+ const { tree, langId } = treeLang;
170
235
 
171
236
  const cfgRules = CFG_RULES.get(langId);
172
237
  if (!cfgRules) continue;
173
238
 
174
- // WASM fallback: run file-level visitor walk to compute CFG for all functions
175
- // that don't already have pre-computed data (from native engine or unified walk)
176
- let visitorCfgByLine = null;
177
- const needsVisitor =
178
- tree &&
179
- symbols.definitions.some(
180
- (d) =>
181
- (d.kind === 'function' || d.kind === 'method') &&
182
- d.line &&
183
- d.cfg !== null &&
184
- !d.cfg?.blocks?.length,
185
- );
186
- if (needsVisitor) {
187
- const visitor = createCfgVisitor(cfgRules);
188
- const walkerOpts = {
189
- functionNodeTypes: new Set(cfgRules.functionNodes),
190
- nestingNodeTypes: new Set(),
191
- getFunctionName: (node) => {
192
- const nameNode = node.childForFieldName('name');
193
- return nameNode ? nameNode.text : null;
194
- },
195
- };
196
- const walkResults = walkWithVisitors(tree.rootNode, [visitor], langId, walkerOpts);
197
- const cfgResults = walkResults.cfg || [];
198
- visitorCfgByLine = new Map();
199
- for (const r of cfgResults) {
200
- if (r.funcNode) {
201
- const line = r.funcNode.startPosition.row + 1;
202
- if (!visitorCfgByLine.has(line)) visitorCfgByLine.set(line, []);
203
- visitorCfgByLine.get(line).push(r);
204
- }
205
- }
206
- }
239
+ const visitorCfgByLine = buildVisitorCfgMap(tree, cfgRules, symbols, langId);
207
240
 
208
241
  for (const def of symbols.definitions) {
209
242
  if (def.kind !== 'function' && def.kind !== 'method') continue;
@@ -212,7 +245,6 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
212
245
  const nodeId = getFunctionNodeId(db, def.name, relPath, def.line);
213
246
  if (!nodeId) continue;
214
247
 
215
- // Use pre-computed CFG (native engine or unified walk), then visitor fallback
216
248
  let cfg = null;
217
249
  if (def.cfg?.blocks?.length) {
218
250
  cfg = def.cfg;
@@ -231,36 +263,10 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
231
263
 
232
264
  if (!cfg || cfg.blocks.length === 0) continue;
233
265
 
234
- // Clear old CFG data for this function
235
266
  deleteCfgForNode(db, nodeId);
236
-
237
- // Insert blocks and build index→dbId mapping
238
- const blockDbIds = new Map();
239
- for (const block of cfg.blocks) {
240
- const result = insertBlock.run(
241
- nodeId,
242
- block.index,
243
- block.type,
244
- block.startLine,
245
- block.endLine,
246
- block.label,
247
- );
248
- blockDbIds.set(block.index, result.lastInsertRowid);
249
- }
250
-
251
- // Insert edges
252
- for (const edge of cfg.edges) {
253
- const sourceDbId = blockDbIds.get(edge.sourceIndex);
254
- const targetDbId = blockDbIds.get(edge.targetIndex);
255
- if (sourceDbId && targetDbId) {
256
- insertEdge.run(nodeId, sourceDbId, targetDbId, edge.kind);
257
- }
258
- }
259
-
267
+ persistCfg(cfg, nodeId, insertBlock, insertEdge);
260
268
  analyzed++;
261
269
  }
262
-
263
- // Don't release _tree here — complexity/dataflow may still need it
264
270
  }
265
271
  });
266
272
 
@@ -273,27 +279,7 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
273
279
 
274
280
  // ─── Query-Time Functions ───────────────────────────────────────────────
275
281
 
276
- function findNodes(db, name, opts = {}) {
277
- const kinds = opts.kind ? [opts.kind] : ['function', 'method'];
278
- const placeholders = kinds.map(() => '?').join(', ');
279
- const params = [`%${name}%`, ...kinds];
280
-
281
- let fileCondition = '';
282
- if (opts.file) {
283
- fileCondition = ' AND n.file LIKE ?';
284
- params.push(`%${opts.file}%`);
285
- }
286
-
287
- const rows = db
288
- .prepare(
289
- `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
290
- FROM nodes n
291
- WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}`,
292
- )
293
- .all(...params);
294
-
295
- return opts.noTests ? rows.filter((n) => !isTestFile(n.file)) : rows;
296
- }
282
+ const CFG_DEFAULT_KINDS = ['function', 'method'];
297
283
 
298
284
  /**
299
285
  * Load CFG data for a function from the database.
@@ -317,7 +303,12 @@ export function cfgData(name, customDbPath, opts = {}) {
317
303
  };
318
304
  }
319
305
 
320
- const nodes = findNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
306
+ const nodes = findNodes(
307
+ db,
308
+ name,
309
+ { noTests, file: opts.file, kind: opts.kind },
310
+ CFG_DEFAULT_KINDS,
311
+ );
321
312
  if (nodes.length === 0) {
322
313
  return { name, results: [] };
323
314
  }
@@ -11,48 +11,18 @@ function getDirectory(filePath) {
11
11
  return dir === '.' ? '(root)' : dir;
12
12
  }
13
13
 
14
- // ─── Core Analysis ────────────────────────────────────────────────────
14
+ // ─── Community Building ──────────────────────────────────────────────
15
15
 
16
16
  /**
17
- * Run Louvain community detection and return structured data.
18
- *
19
- * @param {string} [customDbPath] - Path to graph.db
20
- * @param {object} [opts]
21
- * @param {boolean} [opts.functions] - Function-level instead of file-level
22
- * @param {number} [opts.resolution] - Louvain resolution (default 1.0)
23
- * @param {boolean} [opts.noTests] - Exclude test files
24
- * @param {boolean} [opts.drift] - Drift-only mode (omit community member lists)
25
- * @param {boolean} [opts.json] - JSON output (used by CLI wrapper only)
26
- * @returns {{ communities: object[], modularity: number, drift: object, summary: object }}
17
+ * Group graph nodes by Louvain community assignment and build structured objects.
18
+ * @param {object} graph - The dependency graph
19
+ * @param {Map<string, number>} assignments - Node key → community ID
20
+ * @param {object} opts
21
+ * @param {boolean} [opts.drift] - If true, omit member lists
22
+ * @returns {{ communities: object[], communityDirs: Map<number, Set<string>> }}
27
23
  */
28
- export function communitiesData(customDbPath, opts = {}) {
29
- const { repo, close } = openRepo(customDbPath, opts);
30
- let graph;
31
- try {
32
- graph = buildDependencyGraph(repo, {
33
- fileLevel: !opts.functions,
34
- noTests: opts.noTests,
35
- });
36
- } finally {
37
- close();
38
- }
39
-
40
- // Handle empty or trivial graphs
41
- if (graph.nodeCount === 0 || graph.edgeCount === 0) {
42
- return {
43
- communities: [],
44
- modularity: 0,
45
- drift: { splitCandidates: [], mergeCandidates: [] },
46
- summary: { communityCount: 0, modularity: 0, nodeCount: graph.nodeCount, driftScore: 0 },
47
- };
48
- }
49
-
50
- // Run Louvain
51
- const resolution = opts.resolution ?? 1.0;
52
- const { assignments, modularity } = louvainCommunities(graph, { resolution });
53
-
54
- // Group nodes by community
55
- const communityMap = new Map(); // community id → node keys[]
24
+ function buildCommunityObjects(graph, assignments, opts) {
25
+ const communityMap = new Map();
56
26
  for (const [key] of graph.nodes()) {
57
27
  const cid = assignments.get(key);
58
28
  if (cid == null) continue;
@@ -60,9 +30,8 @@ export function communitiesData(customDbPath, opts = {}) {
60
30
  communityMap.get(cid).push(key);
61
31
  }
62
32
 
63
- // Build community objects
64
33
  const communities = [];
65
- const communityDirs = new Map(); // community id → Set<dir>
34
+ const communityDirs = new Map();
66
35
 
67
36
  for (const [cid, members] of communityMap) {
68
37
  const dirCounts = {};
@@ -88,19 +57,27 @@ export function communitiesData(customDbPath, opts = {}) {
88
57
  });
89
58
  }
90
59
 
91
- // Sort by size descending
92
60
  communities.sort((a, b) => b.size - a.size);
61
+ return { communities, communityDirs };
62
+ }
93
63
 
94
- // ─── Drift Analysis ─────────────────────────────────────────────
64
+ // ─── Drift Analysis ──────────────────────────────────────────────────
95
65
 
96
- // Split candidates: directories with members in 2+ communities
97
- const dirToCommunities = new Map(); // dir Set<community id>
66
+ /**
67
+ * Compute split/merge candidates and drift score from community directory data.
68
+ * @param {object[]} communities - Community objects with `directories`
69
+ * @param {Map<number, Set<string>>} communityDirs - Community ID → directory set
70
+ * @returns {{ splitCandidates: object[], mergeCandidates: object[], driftScore: number }}
71
+ */
72
+ function analyzeDrift(communities, communityDirs) {
73
+ const dirToCommunities = new Map();
98
74
  for (const [cid, dirs] of communityDirs) {
99
75
  for (const dir of dirs) {
100
76
  if (!dirToCommunities.has(dir)) dirToCommunities.set(dir, new Set());
101
77
  dirToCommunities.get(dir).add(cid);
102
78
  }
103
79
  }
80
+
104
81
  const splitCandidates = [];
105
82
  for (const [dir, cids] of dirToCommunities) {
106
83
  if (cids.size >= 2) {
@@ -109,7 +86,6 @@ export function communitiesData(customDbPath, opts = {}) {
109
86
  }
110
87
  splitCandidates.sort((a, b) => b.communityCount - a.communityCount);
111
88
 
112
- // Merge candidates: communities spanning 2+ directories
113
89
  const mergeCandidates = [];
114
90
  for (const c of communities) {
115
91
  const dirCount = Object.keys(c.directories).length;
@@ -124,17 +100,56 @@ export function communitiesData(customDbPath, opts = {}) {
124
100
  }
125
101
  mergeCandidates.sort((a, b) => b.directoryCount - a.directoryCount);
126
102
 
127
- // Drift score: 0-100 based on how much directory structure diverges from communities
128
103
  const totalDirs = dirToCommunities.size;
129
- const splitDirs = splitCandidates.length;
130
- const splitRatio = totalDirs > 0 ? splitDirs / totalDirs : 0;
131
-
104
+ const splitRatio = totalDirs > 0 ? splitCandidates.length / totalDirs : 0;
132
105
  const totalComms = communities.length;
133
- const mergeComms = mergeCandidates.length;
134
- const mergeRatio = totalComms > 0 ? mergeComms / totalComms : 0;
135
-
106
+ const mergeRatio = totalComms > 0 ? mergeCandidates.length / totalComms : 0;
136
107
  const driftScore = Math.round(((splitRatio + mergeRatio) / 2) * 100);
137
108
 
109
+ return { splitCandidates, mergeCandidates, driftScore };
110
+ }
111
+
112
+ // ─── Core Analysis ────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Run Louvain community detection and return structured data.
116
+ *
117
+ * @param {string} [customDbPath] - Path to graph.db
118
+ * @param {object} [opts]
119
+ * @param {boolean} [opts.functions] - Function-level instead of file-level
120
+ * @param {number} [opts.resolution] - Louvain resolution (default 1.0)
121
+ * @param {boolean} [opts.noTests] - Exclude test files
122
+ * @param {boolean} [opts.drift] - Drift-only mode (omit community member lists)
123
+ * @param {boolean} [opts.json] - JSON output (used by CLI wrapper only)
124
+ * @returns {{ communities: object[], modularity: number, drift: object, summary: object }}
125
+ */
126
+ export function communitiesData(customDbPath, opts = {}) {
127
+ const { repo, close } = openRepo(customDbPath, opts);
128
+ let graph;
129
+ try {
130
+ graph = buildDependencyGraph(repo, {
131
+ fileLevel: !opts.functions,
132
+ noTests: opts.noTests,
133
+ });
134
+ } finally {
135
+ close();
136
+ }
137
+
138
+ if (graph.nodeCount === 0 || graph.edgeCount === 0) {
139
+ return {
140
+ communities: [],
141
+ modularity: 0,
142
+ drift: { splitCandidates: [], mergeCandidates: [] },
143
+ summary: { communityCount: 0, modularity: 0, nodeCount: graph.nodeCount, driftScore: 0 },
144
+ };
145
+ }
146
+
147
+ const resolution = opts.resolution ?? 1.0;
148
+ const { assignments, modularity } = louvainCommunities(graph, { resolution });
149
+
150
+ const { communities, communityDirs } = buildCommunityObjects(graph, assignments, opts);
151
+ const { splitCandidates, mergeCandidates, driftScore } = analyzeDrift(communities, communityDirs);
152
+
138
153
  const base = {
139
154
  communities: opts.drift ? [] : communities,
140
155
  modularity: +modularity.toFixed(4),