@optave/codegraph 2.3.1-dev.1aeea34 → 2.5.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 +66 -10
- package/package.json +15 -6
- package/src/builder.js +183 -22
- package/src/cli.js +251 -5
- package/src/cochange.js +8 -8
- package/src/communities.js +303 -0
- package/src/complexity.js +2056 -0
- package/src/config.js +20 -1
- package/src/db.js +111 -1
- package/src/embedder.js +49 -12
- package/src/export.js +25 -1
- package/src/flow.js +361 -0
- package/src/index.js +32 -2
- package/src/manifesto.js +442 -0
- package/src/mcp.js +244 -5
- package/src/paginate.js +70 -0
- package/src/parser.js +21 -5
- package/src/queries.js +396 -7
- package/src/structure.js +88 -24
- package/src/update-check.js +160 -0
- package/src/watcher.js +2 -2
package/src/manifesto.js
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { loadConfig } from './config.js';
|
|
2
|
+
import { findCycles } from './cycles.js';
|
|
3
|
+
import { openReadonlyOrFail } from './db.js';
|
|
4
|
+
import { debug } from './logger.js';
|
|
5
|
+
|
|
6
|
+
// ─── Rule Definitions ─────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* All supported manifesto rules.
|
|
10
|
+
* level: 'function' | 'file' | 'graph'
|
|
11
|
+
* metric: DB column or special key
|
|
12
|
+
* defaults: { warn, fail } — null means disabled
|
|
13
|
+
*/
|
|
14
|
+
export const RULE_DEFS = [
|
|
15
|
+
{
|
|
16
|
+
name: 'cognitive',
|
|
17
|
+
level: 'function',
|
|
18
|
+
metric: 'cognitive',
|
|
19
|
+
defaults: { warn: 15, fail: null },
|
|
20
|
+
reportOnly: true,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'cyclomatic',
|
|
24
|
+
level: 'function',
|
|
25
|
+
metric: 'cyclomatic',
|
|
26
|
+
defaults: { warn: 10, fail: null },
|
|
27
|
+
reportOnly: true,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'maxNesting',
|
|
31
|
+
level: 'function',
|
|
32
|
+
metric: 'max_nesting',
|
|
33
|
+
defaults: { warn: 4, fail: null },
|
|
34
|
+
reportOnly: true,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'importCount',
|
|
38
|
+
level: 'file',
|
|
39
|
+
metric: 'import_count',
|
|
40
|
+
defaults: { warn: null, fail: null },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'exportCount',
|
|
44
|
+
level: 'file',
|
|
45
|
+
metric: 'export_count',
|
|
46
|
+
defaults: { warn: null, fail: null },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'lineCount',
|
|
50
|
+
level: 'file',
|
|
51
|
+
metric: 'line_count',
|
|
52
|
+
defaults: { warn: null, fail: null },
|
|
53
|
+
},
|
|
54
|
+
{ name: 'fanIn', level: 'file', metric: 'fan_in', defaults: { warn: null, fail: null } },
|
|
55
|
+
{ name: 'fanOut', level: 'file', metric: 'fan_out', defaults: { warn: null, fail: null } },
|
|
56
|
+
{ name: 'noCycles', level: 'graph', metric: 'noCycles', defaults: { warn: null, fail: null } },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
const NO_TEST_SQL = `
|
|
62
|
+
AND n.file NOT LIKE '%.test.%'
|
|
63
|
+
AND n.file NOT LIKE '%.spec.%'
|
|
64
|
+
AND n.file NOT LIKE '%__test__%'
|
|
65
|
+
AND n.file NOT LIKE '%__tests__%'
|
|
66
|
+
AND n.file NOT LIKE '%.stories.%'`;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Deep-merge user config with RULE_DEFS defaults per rule.
|
|
70
|
+
* mergeConfig in config.js is shallow for nested objects, so we do per-rule merging here.
|
|
71
|
+
*/
|
|
72
|
+
function resolveRules(userRules) {
|
|
73
|
+
const resolved = {};
|
|
74
|
+
for (const def of RULE_DEFS) {
|
|
75
|
+
const user = userRules?.[def.name];
|
|
76
|
+
resolved[def.name] = {
|
|
77
|
+
warn: user?.warn !== undefined ? user.warn : def.defaults.warn,
|
|
78
|
+
fail: def.reportOnly ? null : user?.fail !== undefined ? user.fail : def.defaults.fail,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if a rule is enabled (has at least one non-null threshold).
|
|
86
|
+
*/
|
|
87
|
+
function isEnabled(thresholds) {
|
|
88
|
+
return thresholds.warn != null || thresholds.fail != null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check a numeric value against warn/fail thresholds, push violations.
|
|
93
|
+
*/
|
|
94
|
+
function checkThreshold(rule, thresholds, value, meta, violations) {
|
|
95
|
+
if (thresholds.fail != null && value >= thresholds.fail) {
|
|
96
|
+
violations.push({
|
|
97
|
+
rule,
|
|
98
|
+
level: 'fail',
|
|
99
|
+
value,
|
|
100
|
+
threshold: thresholds.fail,
|
|
101
|
+
...meta,
|
|
102
|
+
});
|
|
103
|
+
return 'fail';
|
|
104
|
+
}
|
|
105
|
+
if (thresholds.warn != null && value >= thresholds.warn) {
|
|
106
|
+
violations.push({
|
|
107
|
+
rule,
|
|
108
|
+
level: 'warn',
|
|
109
|
+
value,
|
|
110
|
+
threshold: thresholds.warn,
|
|
111
|
+
...meta,
|
|
112
|
+
});
|
|
113
|
+
return 'warn';
|
|
114
|
+
}
|
|
115
|
+
return 'pass';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Evaluators ───────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
function evaluateFunctionRules(db, rules, opts, violations, ruleResults) {
|
|
121
|
+
const functionDefs = RULE_DEFS.filter((d) => d.level === 'function');
|
|
122
|
+
const activeDefs = functionDefs.filter((d) => isEnabled(rules[d.name]));
|
|
123
|
+
if (activeDefs.length === 0) {
|
|
124
|
+
for (const def of functionDefs) {
|
|
125
|
+
ruleResults.push({
|
|
126
|
+
name: def.name,
|
|
127
|
+
level: def.level,
|
|
128
|
+
status: 'pass',
|
|
129
|
+
thresholds: rules[def.name],
|
|
130
|
+
violationCount: 0,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let where = "WHERE n.kind IN ('function','method')";
|
|
137
|
+
const params = [];
|
|
138
|
+
if (opts.noTests) where += NO_TEST_SQL;
|
|
139
|
+
if (opts.file) {
|
|
140
|
+
where += ' AND n.file LIKE ?';
|
|
141
|
+
params.push(`%${opts.file}%`);
|
|
142
|
+
}
|
|
143
|
+
if (opts.kind) {
|
|
144
|
+
where += ' AND n.kind = ?';
|
|
145
|
+
params.push(opts.kind);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let rows;
|
|
149
|
+
try {
|
|
150
|
+
rows = db
|
|
151
|
+
.prepare(
|
|
152
|
+
`SELECT n.name, n.kind, n.file, n.line,
|
|
153
|
+
fc.cognitive, fc.cyclomatic, fc.max_nesting
|
|
154
|
+
FROM function_complexity fc
|
|
155
|
+
JOIN nodes n ON fc.node_id = n.id
|
|
156
|
+
${where}`,
|
|
157
|
+
)
|
|
158
|
+
.all(...params);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
debug('manifesto function query failed: %s', err.message);
|
|
161
|
+
rows = [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Track worst status per rule
|
|
165
|
+
const worst = {};
|
|
166
|
+
const counts = {};
|
|
167
|
+
for (const def of functionDefs) {
|
|
168
|
+
worst[def.name] = 'pass';
|
|
169
|
+
counts[def.name] = 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const row of rows) {
|
|
173
|
+
for (const def of activeDefs) {
|
|
174
|
+
const value = row[def.metric];
|
|
175
|
+
if (value == null) continue;
|
|
176
|
+
const meta = { name: row.name, file: row.file, line: row.line };
|
|
177
|
+
const status = checkThreshold(def.name, rules[def.name], value, meta, violations);
|
|
178
|
+
if (status !== 'pass') {
|
|
179
|
+
counts[def.name]++;
|
|
180
|
+
if (status === 'fail') worst[def.name] = 'fail';
|
|
181
|
+
else if (worst[def.name] !== 'fail') worst[def.name] = 'warn';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const def of functionDefs) {
|
|
187
|
+
ruleResults.push({
|
|
188
|
+
name: def.name,
|
|
189
|
+
level: def.level,
|
|
190
|
+
status: worst[def.name],
|
|
191
|
+
thresholds: rules[def.name],
|
|
192
|
+
violationCount: counts[def.name],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function evaluateFileRules(db, rules, opts, violations, ruleResults) {
|
|
198
|
+
const fileDefs = RULE_DEFS.filter((d) => d.level === 'file');
|
|
199
|
+
const activeDefs = fileDefs.filter((d) => isEnabled(rules[d.name]));
|
|
200
|
+
if (activeDefs.length === 0) {
|
|
201
|
+
for (const def of fileDefs) {
|
|
202
|
+
ruleResults.push({
|
|
203
|
+
name: def.name,
|
|
204
|
+
level: def.level,
|
|
205
|
+
status: 'pass',
|
|
206
|
+
thresholds: rules[def.name],
|
|
207
|
+
violationCount: 0,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let where = "WHERE n.kind = 'file'";
|
|
214
|
+
const params = [];
|
|
215
|
+
if (opts.noTests) where += NO_TEST_SQL;
|
|
216
|
+
if (opts.file) {
|
|
217
|
+
where += ' AND n.file LIKE ?';
|
|
218
|
+
params.push(`%${opts.file}%`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let rows;
|
|
222
|
+
try {
|
|
223
|
+
rows = db
|
|
224
|
+
.prepare(
|
|
225
|
+
`SELECT n.name, n.file, n.line,
|
|
226
|
+
nm.import_count, nm.export_count, nm.line_count,
|
|
227
|
+
nm.fan_in, nm.fan_out
|
|
228
|
+
FROM node_metrics nm
|
|
229
|
+
JOIN nodes n ON nm.node_id = n.id
|
|
230
|
+
${where}`,
|
|
231
|
+
)
|
|
232
|
+
.all(...params);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
debug('manifesto file query failed: %s', err.message);
|
|
235
|
+
rows = [];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const worst = {};
|
|
239
|
+
const counts = {};
|
|
240
|
+
for (const def of fileDefs) {
|
|
241
|
+
worst[def.name] = 'pass';
|
|
242
|
+
counts[def.name] = 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const row of rows) {
|
|
246
|
+
for (const def of activeDefs) {
|
|
247
|
+
const value = row[def.metric];
|
|
248
|
+
if (value == null) continue;
|
|
249
|
+
const meta = { name: row.name, file: row.file, line: row.line };
|
|
250
|
+
const status = checkThreshold(def.name, rules[def.name], value, meta, violations);
|
|
251
|
+
if (status !== 'pass') {
|
|
252
|
+
counts[def.name]++;
|
|
253
|
+
if (status === 'fail') worst[def.name] = 'fail';
|
|
254
|
+
else if (worst[def.name] !== 'fail') worst[def.name] = 'warn';
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const def of fileDefs) {
|
|
260
|
+
ruleResults.push({
|
|
261
|
+
name: def.name,
|
|
262
|
+
level: def.level,
|
|
263
|
+
status: worst[def.name],
|
|
264
|
+
thresholds: rules[def.name],
|
|
265
|
+
violationCount: counts[def.name],
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function evaluateGraphRules(db, rules, opts, violations, ruleResults) {
|
|
271
|
+
const thresholds = rules.noCycles;
|
|
272
|
+
if (!isEnabled(thresholds)) {
|
|
273
|
+
ruleResults.push({
|
|
274
|
+
name: 'noCycles',
|
|
275
|
+
level: 'graph',
|
|
276
|
+
status: 'pass',
|
|
277
|
+
thresholds,
|
|
278
|
+
violationCount: 0,
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const cycles = findCycles(db, { fileLevel: true, noTests: opts.noTests || false });
|
|
284
|
+
const hasCycles = cycles.length > 0;
|
|
285
|
+
|
|
286
|
+
if (!hasCycles) {
|
|
287
|
+
ruleResults.push({
|
|
288
|
+
name: 'noCycles',
|
|
289
|
+
level: 'graph',
|
|
290
|
+
status: 'pass',
|
|
291
|
+
thresholds,
|
|
292
|
+
violationCount: 0,
|
|
293
|
+
});
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Determine level: fail takes precedence over warn
|
|
298
|
+
const level = thresholds.fail != null ? 'fail' : 'warn';
|
|
299
|
+
|
|
300
|
+
for (const cycle of cycles) {
|
|
301
|
+
violations.push({
|
|
302
|
+
rule: 'noCycles',
|
|
303
|
+
level,
|
|
304
|
+
name: `cycle(${cycle.length} files)`,
|
|
305
|
+
file: cycle.join(' → '),
|
|
306
|
+
line: null,
|
|
307
|
+
value: cycle.length,
|
|
308
|
+
threshold: 0,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
ruleResults.push({
|
|
313
|
+
name: 'noCycles',
|
|
314
|
+
level: 'graph',
|
|
315
|
+
status: level,
|
|
316
|
+
thresholds,
|
|
317
|
+
violationCount: cycles.length,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── Public API ───────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Evaluate all manifesto rules and return structured results.
|
|
325
|
+
*
|
|
326
|
+
* @param {string} [customDbPath] - Path to graph.db
|
|
327
|
+
* @param {object} [opts] - Options
|
|
328
|
+
* @param {boolean} [opts.noTests] - Exclude test files
|
|
329
|
+
* @param {string} [opts.file] - Filter by file (partial match)
|
|
330
|
+
* @param {string} [opts.kind] - Filter by symbol kind
|
|
331
|
+
* @returns {{ rules: object[], violations: object[], summary: object, passed: boolean }}
|
|
332
|
+
*/
|
|
333
|
+
export function manifestoData(customDbPath, opts = {}) {
|
|
334
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const config = loadConfig(process.cwd());
|
|
338
|
+
const rules = resolveRules(config.manifesto?.rules);
|
|
339
|
+
|
|
340
|
+
const violations = [];
|
|
341
|
+
const ruleResults = [];
|
|
342
|
+
|
|
343
|
+
evaluateFunctionRules(db, rules, opts, violations, ruleResults);
|
|
344
|
+
evaluateFileRules(db, rules, opts, violations, ruleResults);
|
|
345
|
+
evaluateGraphRules(db, rules, opts, violations, ruleResults);
|
|
346
|
+
|
|
347
|
+
const failViolations = violations.filter((v) => v.level === 'fail');
|
|
348
|
+
|
|
349
|
+
const summary = {
|
|
350
|
+
total: ruleResults.length,
|
|
351
|
+
passed: ruleResults.filter((r) => r.status === 'pass').length,
|
|
352
|
+
warned: ruleResults.filter((r) => r.status === 'warn').length,
|
|
353
|
+
failed: ruleResults.filter((r) => r.status === 'fail').length,
|
|
354
|
+
violationCount: violations.length,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
rules: ruleResults,
|
|
359
|
+
violations,
|
|
360
|
+
summary,
|
|
361
|
+
passed: failViolations.length === 0,
|
|
362
|
+
};
|
|
363
|
+
} finally {
|
|
364
|
+
db.close();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* CLI formatter — prints manifesto results and exits with code 1 on failure.
|
|
370
|
+
*/
|
|
371
|
+
export function manifesto(customDbPath, opts = {}) {
|
|
372
|
+
const data = manifestoData(customDbPath, opts);
|
|
373
|
+
|
|
374
|
+
if (opts.json) {
|
|
375
|
+
console.log(JSON.stringify(data, null, 2));
|
|
376
|
+
if (!data.passed) process.exit(1);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
console.log('\n# Manifesto Rules\n');
|
|
381
|
+
|
|
382
|
+
// Rules table
|
|
383
|
+
console.log(
|
|
384
|
+
` ${'Rule'.padEnd(20)} ${'Level'.padEnd(10)} ${'Status'.padEnd(8)} ${'Warn'.padStart(6)} ${'Fail'.padStart(6)} ${'Violations'.padStart(11)}`,
|
|
385
|
+
);
|
|
386
|
+
console.log(
|
|
387
|
+
` ${'─'.repeat(20)} ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(6)} ${'─'.repeat(11)}`,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
for (const rule of data.rules) {
|
|
391
|
+
const warn = rule.thresholds.warn != null ? String(rule.thresholds.warn) : '—';
|
|
392
|
+
const fail = rule.thresholds.fail != null ? String(rule.thresholds.fail) : '—';
|
|
393
|
+
const statusIcon = rule.status === 'pass' ? 'pass' : rule.status === 'warn' ? 'WARN' : 'FAIL';
|
|
394
|
+
console.log(
|
|
395
|
+
` ${rule.name.padEnd(20)} ${rule.level.padEnd(10)} ${statusIcon.padEnd(8)} ${warn.padStart(6)} ${fail.padStart(6)} ${String(rule.violationCount).padStart(11)}`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Summary
|
|
400
|
+
const s = data.summary;
|
|
401
|
+
console.log(
|
|
402
|
+
`\n ${s.total} rules | ${s.passed} passed | ${s.warned} warned | ${s.failed} failed | ${s.violationCount} violations`,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// Violations detail
|
|
406
|
+
if (data.violations.length > 0) {
|
|
407
|
+
const failViolations = data.violations.filter((v) => v.level === 'fail');
|
|
408
|
+
const warnViolations = data.violations.filter((v) => v.level === 'warn');
|
|
409
|
+
|
|
410
|
+
if (failViolations.length > 0) {
|
|
411
|
+
console.log(`\n## Failures (${failViolations.length})\n`);
|
|
412
|
+
for (const v of failViolations.slice(0, 20)) {
|
|
413
|
+
const loc = v.line ? `${v.file}:${v.line}` : v.file;
|
|
414
|
+
console.log(
|
|
415
|
+
` [FAIL] ${v.rule}: ${v.name} (${v.value}) at ${loc} — threshold ${v.threshold}`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
if (failViolations.length > 20) {
|
|
419
|
+
console.log(` ... and ${failViolations.length - 20} more`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (warnViolations.length > 0) {
|
|
424
|
+
console.log(`\n## Warnings (${warnViolations.length})\n`);
|
|
425
|
+
for (const v of warnViolations.slice(0, 20)) {
|
|
426
|
+
const loc = v.line ? `${v.file}:${v.line}` : v.file;
|
|
427
|
+
console.log(
|
|
428
|
+
` [WARN] ${v.rule}: ${v.name} (${v.value}) at ${loc} — threshold ${v.threshold}`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
if (warnViolations.length > 20) {
|
|
432
|
+
console.log(` ... and ${warnViolations.length - 20} more`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
console.log();
|
|
438
|
+
|
|
439
|
+
if (!data.passed) {
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
}
|