@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.
- package/README.md +3 -2
- package/package.json +7 -7
- package/src/ast-analysis/engine.js +252 -258
- package/src/ast-analysis/shared.js +0 -12
- package/src/ast-analysis/visitors/cfg-visitor.js +635 -649
- package/src/ast-analysis/visitors/complexity-visitor.js +135 -139
- package/src/ast-analysis/visitors/dataflow-visitor.js +230 -224
- package/src/cli/commands/ast.js +2 -1
- package/src/cli/commands/audit.js +2 -1
- package/src/cli/commands/batch.js +2 -1
- package/src/cli/commands/brief.js +12 -0
- package/src/cli/commands/cfg.js +2 -1
- package/src/cli/commands/check.js +20 -23
- package/src/cli/commands/children.js +6 -1
- package/src/cli/commands/complexity.js +2 -1
- package/src/cli/commands/context.js +6 -1
- package/src/cli/commands/dataflow.js +2 -1
- package/src/cli/commands/deps.js +8 -3
- package/src/cli/commands/flow.js +2 -1
- package/src/cli/commands/fn-impact.js +6 -1
- package/src/cli/commands/owners.js +4 -2
- package/src/cli/commands/query.js +6 -1
- package/src/cli/commands/roles.js +2 -1
- package/src/cli/commands/search.js +8 -2
- package/src/cli/commands/sequence.js +2 -1
- package/src/cli/commands/triage.js +38 -27
- package/src/db/connection.js +18 -12
- package/src/db/migrations.js +41 -64
- package/src/db/query-builder.js +60 -4
- package/src/db/repository/in-memory-repository.js +27 -16
- package/src/db/repository/nodes.js +8 -10
- package/src/domain/analysis/brief.js +155 -0
- package/src/domain/analysis/context.js +174 -190
- package/src/domain/analysis/dependencies.js +200 -146
- package/src/domain/analysis/exports.js +3 -2
- package/src/domain/analysis/impact.js +267 -152
- package/src/domain/analysis/module-map.js +247 -221
- package/src/domain/analysis/roles.js +8 -5
- package/src/domain/analysis/symbol-lookup.js +7 -5
- package/src/domain/graph/builder/helpers.js +1 -1
- package/src/domain/graph/builder/incremental.js +116 -90
- package/src/domain/graph/builder/pipeline.js +106 -80
- package/src/domain/graph/builder/stages/build-edges.js +318 -239
- package/src/domain/graph/builder/stages/detect-changes.js +198 -177
- package/src/domain/graph/builder/stages/insert-nodes.js +147 -139
- package/src/domain/graph/watcher.js +2 -2
- package/src/domain/parser.js +20 -11
- package/src/domain/queries.js +1 -0
- package/src/domain/search/search/filters.js +9 -5
- package/src/domain/search/search/keyword.js +12 -5
- package/src/domain/search/search/prepare.js +13 -5
- package/src/extractors/csharp.js +224 -207
- package/src/extractors/go.js +176 -172
- package/src/extractors/hcl.js +94 -78
- package/src/extractors/java.js +213 -207
- package/src/extractors/javascript.js +274 -304
- package/src/extractors/php.js +234 -221
- package/src/extractors/python.js +252 -250
- package/src/extractors/ruby.js +192 -185
- package/src/extractors/rust.js +182 -167
- package/src/features/ast.js +5 -3
- package/src/features/audit.js +4 -2
- package/src/features/boundaries.js +98 -83
- package/src/features/cfg.js +134 -143
- package/src/features/communities.js +68 -53
- package/src/features/complexity.js +143 -132
- package/src/features/dataflow.js +146 -149
- package/src/features/export.js +3 -3
- package/src/features/graph-enrichment.js +2 -2
- package/src/features/manifesto.js +9 -6
- package/src/features/owners.js +4 -3
- package/src/features/sequence.js +152 -141
- package/src/features/shared/find-nodes.js +31 -0
- package/src/features/structure.js +130 -99
- package/src/features/triage.js +83 -68
- package/src/graph/classifiers/risk.js +3 -2
- package/src/graph/classifiers/roles.js +6 -3
- package/src/index.js +1 -0
- package/src/mcp/server.js +65 -56
- package/src/mcp/tool-registry.js +13 -0
- package/src/mcp/tools/brief.js +8 -0
- package/src/mcp/tools/index.js +2 -0
- package/src/presentation/brief.js +51 -0
- package/src/presentation/queries-cli/exports.js +21 -14
- package/src/presentation/queries-cli/impact.js +55 -39
- package/src/presentation/queries-cli/inspect.js +184 -189
- package/src/presentation/queries-cli/overview.js +57 -58
- package/src/presentation/queries-cli/path.js +36 -29
- package/src/presentation/table.js +0 -8
- package/src/shared/generators.js +7 -3
- package/src/shared/kinds.js +1 -1
package/src/features/cfg.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
// ───
|
|
14
|
+
// ─── Community Building ──────────────────────────────────────────────
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* @param {string}
|
|
20
|
-
* @param {object}
|
|
21
|
-
* @param {boolean} [opts.
|
|
22
|
-
* @
|
|
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
|
-
|
|
29
|
-
const
|
|
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();
|
|
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
|
-
|
|
64
|
+
// ─── Drift Analysis ──────────────────────────────────────────────────
|
|
95
65
|
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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
|
|
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),
|