@optave/codegraph 2.5.0 → 2.6.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/src/audit.js ADDED
@@ -0,0 +1,423 @@
1
+ /**
2
+ * audit.js — Composite report: explain + impact + health metrics per function.
3
+ *
4
+ * Combines explainData (structure, callers, callees, basic complexity),
5
+ * full function_complexity health metrics, BFS impact analysis, and
6
+ * manifesto threshold breach detection into a single call.
7
+ */
8
+
9
+ import path from 'node:path';
10
+ import { loadConfig } from './config.js';
11
+ import { openReadonlyOrFail } from './db.js';
12
+ import { RULE_DEFS } from './manifesto.js';
13
+ import { explainData, isTestFile, kindIcon } from './queries.js';
14
+
15
+ // ─── Threshold resolution ───────────────────────────────────────────
16
+
17
+ const FUNCTION_RULES = RULE_DEFS.filter((d) => d.level === 'function');
18
+
19
+ function resolveThresholds(customDbPath) {
20
+ try {
21
+ const dbDir = path.dirname(customDbPath);
22
+ const repoRoot = path.resolve(dbDir, '..');
23
+ const cfg = loadConfig(repoRoot);
24
+ const userRules = cfg.manifesto || {};
25
+ const resolved = {};
26
+ for (const def of FUNCTION_RULES) {
27
+ const user = userRules[def.name];
28
+ resolved[def.name] = {
29
+ metric: def.metric,
30
+ warn: user?.warn !== undefined ? user.warn : def.defaults.warn,
31
+ fail: def.reportOnly ? null : user?.fail !== undefined ? user.fail : def.defaults.fail,
32
+ };
33
+ }
34
+ return resolved;
35
+ } catch {
36
+ // Fall back to defaults if config loading fails
37
+ const resolved = {};
38
+ for (const def of FUNCTION_RULES) {
39
+ resolved[def.name] = {
40
+ metric: def.metric,
41
+ warn: def.defaults.warn,
42
+ fail: def.reportOnly ? null : def.defaults.fail,
43
+ };
44
+ }
45
+ return resolved;
46
+ }
47
+ }
48
+
49
+ // Column name in DB → threshold rule name mapping
50
+ const METRIC_TO_RULE = {
51
+ cognitive: 'cognitive',
52
+ cyclomatic: 'cyclomatic',
53
+ max_nesting: 'maxNesting',
54
+ };
55
+
56
+ function checkBreaches(row, thresholds) {
57
+ const breaches = [];
58
+ for (const [col, ruleName] of Object.entries(METRIC_TO_RULE)) {
59
+ const t = thresholds[ruleName];
60
+ if (!t) continue;
61
+ const value = row[col];
62
+ if (value == null) continue;
63
+ if (t.fail != null && value >= t.fail) {
64
+ breaches.push({ metric: ruleName, value, threshold: t.fail, level: 'fail' });
65
+ } else if (t.warn != null && value >= t.warn) {
66
+ breaches.push({ metric: ruleName, value, threshold: t.warn, level: 'warn' });
67
+ }
68
+ }
69
+ return breaches;
70
+ }
71
+
72
+ // ─── BFS impact (inline, same algorithm as fnImpactData) ────────────
73
+
74
+ function computeImpact(db, nodeId, noTests, maxDepth) {
75
+ const visited = new Set([nodeId]);
76
+ const levels = {};
77
+ let frontier = [nodeId];
78
+
79
+ for (let d = 1; d <= maxDepth; d++) {
80
+ const nextFrontier = [];
81
+ for (const fid of frontier) {
82
+ const callers = db
83
+ .prepare(
84
+ `SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line
85
+ FROM edges e JOIN nodes n ON e.source_id = n.id
86
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
87
+ )
88
+ .all(fid);
89
+ for (const c of callers) {
90
+ if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
91
+ visited.add(c.id);
92
+ nextFrontier.push(c.id);
93
+ if (!levels[d]) levels[d] = [];
94
+ levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
95
+ }
96
+ }
97
+ }
98
+ frontier = nextFrontier;
99
+ if (frontier.length === 0) break;
100
+ }
101
+
102
+ return { totalDependents: visited.size - 1, levels };
103
+ }
104
+
105
+ // ─── Phase 4.4 fields (graceful null fallback) ─────────────────────
106
+
107
+ function readPhase44(db, nodeId) {
108
+ try {
109
+ const row = db
110
+ .prepare('SELECT risk_score, complexity_notes, side_effects FROM nodes WHERE id = ?')
111
+ .get(nodeId);
112
+ if (row) {
113
+ return {
114
+ riskScore: row.risk_score ?? null,
115
+ complexityNotes: row.complexity_notes ?? null,
116
+ sideEffects: row.side_effects ?? null,
117
+ };
118
+ }
119
+ } catch {
120
+ /* columns don't exist yet */
121
+ }
122
+ return { riskScore: null, complexityNotes: null, sideEffects: null };
123
+ }
124
+
125
+ // ─── auditData ──────────────────────────────────────────────────────
126
+
127
+ export function auditData(target, customDbPath, opts = {}) {
128
+ const noTests = opts.noTests || false;
129
+ const maxDepth = opts.depth || 3;
130
+ const file = opts.file;
131
+ const kind = opts.kind;
132
+
133
+ // 1. Get structure via explainData
134
+ const explained = explainData(target, customDbPath, { noTests, depth: 0 });
135
+
136
+ // Apply --file and --kind filters for function targets
137
+ let results = explained.results;
138
+ if (explained.kind === 'function') {
139
+ if (file) results = results.filter((r) => r.file.includes(file));
140
+ if (kind) results = results.filter((r) => r.kind === kind);
141
+ }
142
+
143
+ if (results.length === 0) {
144
+ return { target, kind: explained.kind, functions: [] };
145
+ }
146
+
147
+ // 2. Open DB for enrichment
148
+ const db = openReadonlyOrFail(customDbPath);
149
+ const thresholds = resolveThresholds(customDbPath);
150
+
151
+ let functions;
152
+ try {
153
+ if (explained.kind === 'file') {
154
+ // File target: explainData returns file-level info with publicApi + internal
155
+ // We need to enrich each symbol
156
+ functions = [];
157
+ for (const fileResult of results) {
158
+ const allSymbols = [...(fileResult.publicApi || []), ...(fileResult.internal || [])];
159
+ if (kind) {
160
+ const filtered = allSymbols.filter((s) => s.kind === kind);
161
+ for (const sym of filtered) {
162
+ functions.push(enrichSymbol(db, sym, fileResult.file, noTests, maxDepth, thresholds));
163
+ }
164
+ } else {
165
+ for (const sym of allSymbols) {
166
+ functions.push(enrichSymbol(db, sym, fileResult.file, noTests, maxDepth, thresholds));
167
+ }
168
+ }
169
+ }
170
+ } else {
171
+ // Function target: explainData returns per-function results
172
+ functions = results.map((r) => enrichFunction(db, r, noTests, maxDepth, thresholds));
173
+ }
174
+ } finally {
175
+ db.close();
176
+ }
177
+
178
+ return { target, kind: explained.kind, functions };
179
+ }
180
+
181
+ // ─── Enrich a function result from explainData ──────────────────────
182
+
183
+ function enrichFunction(db, r, noTests, maxDepth, thresholds) {
184
+ const nodeRow = db
185
+ .prepare('SELECT id FROM nodes WHERE name = ? AND file = ? AND line = ?')
186
+ .get(r.name, r.file, r.line);
187
+
188
+ const nodeId = nodeRow?.id;
189
+ const health = nodeId ? buildHealth(db, nodeId, thresholds) : defaultHealth();
190
+ const impact = nodeId
191
+ ? computeImpact(db, nodeId, noTests, maxDepth)
192
+ : { totalDependents: 0, levels: {} };
193
+ const phase44 = nodeId
194
+ ? readPhase44(db, nodeId)
195
+ : { riskScore: null, complexityNotes: null, sideEffects: null };
196
+
197
+ return {
198
+ name: r.name,
199
+ kind: r.kind,
200
+ file: r.file,
201
+ line: r.line,
202
+ endLine: r.endLine,
203
+ role: r.role,
204
+ lineCount: r.lineCount,
205
+ summary: r.summary,
206
+ signature: r.signature,
207
+ callees: r.callees,
208
+ callers: r.callers,
209
+ relatedTests: r.relatedTests,
210
+ impact,
211
+ health,
212
+ ...phase44,
213
+ };
214
+ }
215
+
216
+ // ─── Enrich a symbol from file-level explainData ────────────────────
217
+
218
+ function enrichSymbol(db, sym, file, noTests, maxDepth, thresholds) {
219
+ const nodeRow = db
220
+ .prepare('SELECT id, end_line FROM nodes WHERE name = ? AND file = ? AND line = ?')
221
+ .get(sym.name, file, sym.line);
222
+
223
+ const nodeId = nodeRow?.id;
224
+ const endLine = nodeRow?.end_line || null;
225
+ const lineCount = endLine ? endLine - sym.line + 1 : null;
226
+
227
+ // Get callers/callees for this symbol
228
+ let callees = [];
229
+ let callers = [];
230
+ let relatedTests = [];
231
+ if (nodeId) {
232
+ callees = db
233
+ .prepare(
234
+ `SELECT n.name, n.kind, n.file, n.line
235
+ FROM edges e JOIN nodes n ON e.target_id = n.id
236
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
237
+ )
238
+ .all(nodeId)
239
+ .map((c) => ({ name: c.name, kind: c.kind, file: c.file, line: c.line }));
240
+
241
+ callers = db
242
+ .prepare(
243
+ `SELECT n.name, n.kind, n.file, n.line
244
+ FROM edges e JOIN nodes n ON e.source_id = n.id
245
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
246
+ )
247
+ .all(nodeId)
248
+ .map((c) => ({ name: c.name, kind: c.kind, file: c.file, line: c.line }));
249
+ if (noTests) callers = callers.filter((c) => !isTestFile(c.file));
250
+
251
+ const testCallerRows = db
252
+ .prepare(
253
+ `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
254
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
255
+ )
256
+ .all(nodeId);
257
+ relatedTests = testCallerRows.filter((r) => isTestFile(r.file)).map((r) => ({ file: r.file }));
258
+ }
259
+
260
+ const health = nodeId ? buildHealth(db, nodeId, thresholds) : defaultHealth();
261
+ const impact = nodeId
262
+ ? computeImpact(db, nodeId, noTests, maxDepth)
263
+ : { totalDependents: 0, levels: {} };
264
+ const phase44 = nodeId
265
+ ? readPhase44(db, nodeId)
266
+ : { riskScore: null, complexityNotes: null, sideEffects: null };
267
+
268
+ return {
269
+ name: sym.name,
270
+ kind: sym.kind,
271
+ file,
272
+ line: sym.line,
273
+ endLine,
274
+ role: sym.role || null,
275
+ lineCount,
276
+ summary: sym.summary || null,
277
+ signature: sym.signature || null,
278
+ callees,
279
+ callers,
280
+ relatedTests,
281
+ impact,
282
+ health,
283
+ ...phase44,
284
+ };
285
+ }
286
+
287
+ // ─── Build health metrics from function_complexity ──────────────────
288
+
289
+ function buildHealth(db, nodeId, thresholds) {
290
+ try {
291
+ const row = db
292
+ .prepare(
293
+ `SELECT cognitive, cyclomatic, max_nesting, maintainability_index,
294
+ halstead_volume, halstead_difficulty, halstead_effort, halstead_bugs,
295
+ loc, sloc, comment_lines
296
+ FROM function_complexity WHERE node_id = ?`,
297
+ )
298
+ .get(nodeId);
299
+
300
+ if (!row) return defaultHealth();
301
+
302
+ return {
303
+ cognitive: row.cognitive,
304
+ cyclomatic: row.cyclomatic,
305
+ maxNesting: row.max_nesting,
306
+ maintainabilityIndex: row.maintainability_index || 0,
307
+ halstead: {
308
+ volume: row.halstead_volume || 0,
309
+ difficulty: row.halstead_difficulty || 0,
310
+ effort: row.halstead_effort || 0,
311
+ bugs: row.halstead_bugs || 0,
312
+ },
313
+ loc: row.loc || 0,
314
+ sloc: row.sloc || 0,
315
+ commentLines: row.comment_lines || 0,
316
+ thresholdBreaches: checkBreaches(row, thresholds),
317
+ };
318
+ } catch {
319
+ /* table may not exist */
320
+ return defaultHealth();
321
+ }
322
+ }
323
+
324
+ function defaultHealth() {
325
+ return {
326
+ cognitive: null,
327
+ cyclomatic: null,
328
+ maxNesting: null,
329
+ maintainabilityIndex: null,
330
+ halstead: { volume: 0, difficulty: 0, effort: 0, bugs: 0 },
331
+ loc: 0,
332
+ sloc: 0,
333
+ commentLines: 0,
334
+ thresholdBreaches: [],
335
+ };
336
+ }
337
+
338
+ // ─── CLI formatter ──────────────────────────────────────────────────
339
+
340
+ export function audit(target, customDbPath, opts = {}) {
341
+ const data = auditData(target, customDbPath, opts);
342
+
343
+ if (opts.json) {
344
+ console.log(JSON.stringify(data, null, 2));
345
+ return;
346
+ }
347
+
348
+ if (data.functions.length === 0) {
349
+ console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`);
350
+ return;
351
+ }
352
+
353
+ console.log(`\n# Audit: ${target} (${data.kind})`);
354
+ console.log(` ${data.functions.length} function(s) analyzed\n`);
355
+
356
+ for (const fn of data.functions) {
357
+ const lineRange = fn.endLine ? `${fn.line}-${fn.endLine}` : `${fn.line}`;
358
+ const roleTag = fn.role ? ` [${fn.role}]` : '';
359
+ console.log(`## ${kindIcon(fn.kind)} ${fn.name} (${fn.kind})${roleTag}`);
360
+ console.log(` ${fn.file}:${lineRange}${fn.lineCount ? ` (${fn.lineCount} lines)` : ''}`);
361
+ if (fn.summary) console.log(` ${fn.summary}`);
362
+ if (fn.signature) {
363
+ if (fn.signature.params != null) console.log(` Parameters: (${fn.signature.params})`);
364
+ if (fn.signature.returnType) console.log(` Returns: ${fn.signature.returnType}`);
365
+ }
366
+
367
+ // Health metrics
368
+ if (fn.health.cognitive != null) {
369
+ console.log(`\n Health:`);
370
+ console.log(
371
+ ` Cognitive: ${fn.health.cognitive} Cyclomatic: ${fn.health.cyclomatic} Nesting: ${fn.health.maxNesting}`,
372
+ );
373
+ console.log(` MI: ${fn.health.maintainabilityIndex}`);
374
+ if (fn.health.halstead.volume) {
375
+ console.log(
376
+ ` Halstead: vol=${fn.health.halstead.volume} diff=${fn.health.halstead.difficulty} effort=${fn.health.halstead.effort} bugs=${fn.health.halstead.bugs}`,
377
+ );
378
+ }
379
+ if (fn.health.loc) {
380
+ console.log(
381
+ ` LOC: ${fn.health.loc} SLOC: ${fn.health.sloc} Comments: ${fn.health.commentLines}`,
382
+ );
383
+ }
384
+ }
385
+
386
+ // Threshold breaches
387
+ if (fn.health.thresholdBreaches.length > 0) {
388
+ console.log(`\n Threshold Breaches:`);
389
+ for (const b of fn.health.thresholdBreaches) {
390
+ const icon = b.level === 'fail' ? 'FAIL' : 'WARN';
391
+ console.log(` [${icon}] ${b.metric}: ${b.value} >= ${b.threshold}`);
392
+ }
393
+ }
394
+
395
+ // Impact
396
+ console.log(`\n Impact: ${fn.impact.totalDependents} transitive dependent(s)`);
397
+ for (const [level, nodes] of Object.entries(fn.impact.levels)) {
398
+ console.log(` Level ${level}: ${nodes.map((n) => n.name).join(', ')}`);
399
+ }
400
+
401
+ // Call edges
402
+ if (fn.callees.length > 0) {
403
+ console.log(`\n Calls (${fn.callees.length}):`);
404
+ for (const c of fn.callees) {
405
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
406
+ }
407
+ }
408
+ if (fn.callers.length > 0) {
409
+ console.log(`\n Called by (${fn.callers.length}):`);
410
+ for (const c of fn.callers) {
411
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
412
+ }
413
+ }
414
+ if (fn.relatedTests.length > 0) {
415
+ console.log(`\n Tests (${fn.relatedTests.length}):`);
416
+ for (const t of fn.relatedTests) {
417
+ console.log(` ${t.file}`);
418
+ }
419
+ }
420
+
421
+ console.log();
422
+ }
423
+ }
package/src/batch.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Batch query orchestration — run the same query command against multiple targets
3
+ * and return all results in a single JSON payload.
4
+ *
5
+ * Designed for multi-agent swarms that need to dispatch 20+ queries in one call.
6
+ */
7
+
8
+ import { complexityData } from './complexity.js';
9
+ import { flowData } from './flow.js';
10
+ import {
11
+ contextData,
12
+ explainData,
13
+ fileDepsData,
14
+ fnDepsData,
15
+ fnImpactData,
16
+ impactAnalysisData,
17
+ queryNameData,
18
+ whereData,
19
+ } from './queries.js';
20
+
21
+ /**
22
+ * Map of supported batch commands → their data function + first-arg semantics.
23
+ * `sig` describes how the target string is passed to the data function:
24
+ * - 'name' → dataFn(target, dbPath, opts)
25
+ * - 'target' → dataFn(target, dbPath, opts)
26
+ * - 'file' → dataFn(target, dbPath, opts)
27
+ * - 'dbOnly' → dataFn(dbPath, { ...opts, target }) (target goes into opts)
28
+ */
29
+ export const BATCH_COMMANDS = {
30
+ 'fn-impact': { fn: fnImpactData, sig: 'name' },
31
+ context: { fn: contextData, sig: 'name' },
32
+ explain: { fn: explainData, sig: 'target' },
33
+ where: { fn: whereData, sig: 'target' },
34
+ query: { fn: queryNameData, sig: 'name' },
35
+ fn: { fn: fnDepsData, sig: 'name' },
36
+ impact: { fn: impactAnalysisData, sig: 'file' },
37
+ deps: { fn: fileDepsData, sig: 'file' },
38
+ flow: { fn: flowData, sig: 'name' },
39
+ complexity: { fn: complexityData, sig: 'dbOnly' },
40
+ };
41
+
42
+ /**
43
+ * Run a query command against multiple targets, returning all results.
44
+ *
45
+ * @param {string} command - One of the keys in BATCH_COMMANDS
46
+ * @param {string[]} targets - List of target names/paths
47
+ * @param {string} [customDbPath] - Path to graph.db
48
+ * @param {object} [opts] - Shared options passed to every invocation
49
+ * @returns {{ command: string, total: number, succeeded: number, failed: number, results: object[] }}
50
+ */
51
+ export function batchData(command, targets, customDbPath, opts = {}) {
52
+ const entry = BATCH_COMMANDS[command];
53
+ if (!entry) {
54
+ throw new Error(
55
+ `Unknown batch command "${command}". Valid commands: ${Object.keys(BATCH_COMMANDS).join(', ')}`,
56
+ );
57
+ }
58
+
59
+ const results = [];
60
+ let succeeded = 0;
61
+ let failed = 0;
62
+
63
+ for (const target of targets) {
64
+ try {
65
+ let data;
66
+ if (entry.sig === 'dbOnly') {
67
+ // complexityData(dbPath, { ...opts, target })
68
+ data = entry.fn(customDbPath, { ...opts, target });
69
+ } else {
70
+ // All other: dataFn(target, dbPath, opts)
71
+ data = entry.fn(target, customDbPath, opts);
72
+ }
73
+ results.push({ target, ok: true, data });
74
+ succeeded++;
75
+ } catch (err) {
76
+ results.push({ target, ok: false, error: err.message });
77
+ failed++;
78
+ }
79
+ }
80
+
81
+ return { command, total: targets.length, succeeded, failed, results };
82
+ }
83
+
84
+ /**
85
+ * CLI wrapper — calls batchData and prints JSON to stdout.
86
+ */
87
+ export function batch(command, targets, customDbPath, opts = {}) {
88
+ const data = batchData(command, targets, customDbPath, opts);
89
+ console.log(JSON.stringify(data, null, 2));
90
+ }