@optave/codegraph 2.4.0 → 2.5.1

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.
@@ -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
+ }