@optave/codegraph 2.5.1 → 3.0.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.
@@ -0,0 +1,149 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import Database from 'better-sqlite3';
4
+ import { findDbPath } from './db.js';
5
+ import { debug } from './logger.js';
6
+
7
+ const NAME_RE = /^[a-zA-Z0-9_-]+$/;
8
+
9
+ /**
10
+ * Validate a snapshot name (alphanumeric, hyphens, underscores only).
11
+ * Throws on invalid input.
12
+ */
13
+ export function validateSnapshotName(name) {
14
+ if (!name || !NAME_RE.test(name)) {
15
+ throw new Error(
16
+ `Invalid snapshot name "${name}". Use only letters, digits, hyphens, and underscores.`,
17
+ );
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Return the snapshots directory for a given DB path.
23
+ */
24
+ export function snapshotsDir(dbPath) {
25
+ return path.join(path.dirname(dbPath), 'snapshots');
26
+ }
27
+
28
+ /**
29
+ * Save a snapshot of the current graph database.
30
+ * Uses VACUUM INTO for an atomic, WAL-free copy.
31
+ *
32
+ * @param {string} name - Snapshot name
33
+ * @param {object} [options]
34
+ * @param {string} [options.dbPath] - Explicit path to graph.db
35
+ * @param {boolean} [options.force] - Overwrite existing snapshot
36
+ * @returns {{ name: string, path: string, size: number }}
37
+ */
38
+ export function snapshotSave(name, options = {}) {
39
+ validateSnapshotName(name);
40
+ const dbPath = options.dbPath || findDbPath();
41
+ if (!fs.existsSync(dbPath)) {
42
+ throw new Error(`Database not found: ${dbPath}`);
43
+ }
44
+
45
+ const dir = snapshotsDir(dbPath);
46
+ const dest = path.join(dir, `${name}.db`);
47
+
48
+ if (fs.existsSync(dest)) {
49
+ if (!options.force) {
50
+ throw new Error(`Snapshot "${name}" already exists. Use --force to overwrite.`);
51
+ }
52
+ fs.unlinkSync(dest);
53
+ debug(`Deleted existing snapshot: ${dest}`);
54
+ }
55
+
56
+ fs.mkdirSync(dir, { recursive: true });
57
+
58
+ const db = new Database(dbPath, { readonly: true });
59
+ try {
60
+ db.exec(`VACUUM INTO '${dest.replace(/'/g, "''")}'`);
61
+ } finally {
62
+ db.close();
63
+ }
64
+
65
+ const stat = fs.statSync(dest);
66
+ debug(`Snapshot saved: ${dest} (${stat.size} bytes)`);
67
+ return { name, path: dest, size: stat.size };
68
+ }
69
+
70
+ /**
71
+ * Restore a snapshot over the current graph database.
72
+ * Removes WAL/SHM sidecar files before overwriting.
73
+ *
74
+ * @param {string} name - Snapshot name
75
+ * @param {object} [options]
76
+ * @param {string} [options.dbPath] - Explicit path to graph.db
77
+ */
78
+ export function snapshotRestore(name, options = {}) {
79
+ validateSnapshotName(name);
80
+ const dbPath = options.dbPath || findDbPath();
81
+ const dir = snapshotsDir(dbPath);
82
+ const src = path.join(dir, `${name}.db`);
83
+
84
+ if (!fs.existsSync(src)) {
85
+ throw new Error(`Snapshot "${name}" not found at ${src}`);
86
+ }
87
+
88
+ // Remove WAL/SHM sidecar files for a clean restore
89
+ for (const suffix of ['-wal', '-shm']) {
90
+ const sidecar = dbPath + suffix;
91
+ if (fs.existsSync(sidecar)) {
92
+ fs.unlinkSync(sidecar);
93
+ debug(`Removed sidecar: ${sidecar}`);
94
+ }
95
+ }
96
+
97
+ fs.copyFileSync(src, dbPath);
98
+ debug(`Restored snapshot "${name}" → ${dbPath}`);
99
+ }
100
+
101
+ /**
102
+ * List all saved snapshots.
103
+ *
104
+ * @param {object} [options]
105
+ * @param {string} [options.dbPath] - Explicit path to graph.db
106
+ * @returns {Array<{ name: string, path: string, size: number, createdAt: Date }>}
107
+ */
108
+ export function snapshotList(options = {}) {
109
+ const dbPath = options.dbPath || findDbPath();
110
+ const dir = snapshotsDir(dbPath);
111
+
112
+ if (!fs.existsSync(dir)) return [];
113
+
114
+ return fs
115
+ .readdirSync(dir)
116
+ .filter((f) => f.endsWith('.db'))
117
+ .map((f) => {
118
+ const filePath = path.join(dir, f);
119
+ const stat = fs.statSync(filePath);
120
+ return {
121
+ name: f.replace(/\.db$/, ''),
122
+ path: filePath,
123
+ size: stat.size,
124
+ createdAt: stat.birthtime,
125
+ };
126
+ })
127
+ .sort((a, b) => b.createdAt - a.createdAt);
128
+ }
129
+
130
+ /**
131
+ * Delete a named snapshot.
132
+ *
133
+ * @param {string} name - Snapshot name
134
+ * @param {object} [options]
135
+ * @param {string} [options.dbPath] - Explicit path to graph.db
136
+ */
137
+ export function snapshotDelete(name, options = {}) {
138
+ validateSnapshotName(name);
139
+ const dbPath = options.dbPath || findDbPath();
140
+ const dir = snapshotsDir(dbPath);
141
+ const target = path.join(dir, `${name}.db`);
142
+
143
+ if (!fs.existsSync(target)) {
144
+ throw new Error(`Snapshot "${name}" not found at ${target}`);
145
+ }
146
+
147
+ fs.unlinkSync(target);
148
+ debug(`Deleted snapshot: ${target}`);
149
+ }
package/src/structure.js CHANGED
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { normalizePath } from './constants.js';
3
3
  import { openReadonlyOrFail } from './db.js';
4
4
  import { debug } from './logger.js';
5
+ import { paginateResult } from './paginate.js';
5
6
  import { isTestFile } from './queries.js';
6
7
 
7
8
  // ─── Build-time: insert directory nodes, contains edges, and metrics ────
@@ -33,8 +34,11 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
33
34
  `);
34
35
 
35
36
  // Clean previous directory nodes/edges (idempotent rebuild)
37
+ // Scope contains-edge delete to directory-sourced edges only,
38
+ // preserving symbol-level contains edges (file→def, class→method, etc.)
36
39
  db.exec(`
37
- DELETE FROM edges WHERE kind = 'contains';
40
+ DELETE FROM edges WHERE kind = 'contains'
41
+ AND source_id IN (SELECT id FROM nodes WHERE kind = 'directory');
38
42
  DELETE FROM node_metrics;
39
43
  DELETE FROM nodes WHERE kind = 'directory';
40
44
  `);
@@ -463,7 +467,8 @@ export function structureData(customDbPath, opts = {}) {
463
467
  }
464
468
  }
465
469
 
466
- return { directories: result, count: result.length };
470
+ const base = { directories: result, count: result.length };
471
+ return paginateResult(base, 'directories', { limit: opts.limit, offset: opts.offset });
467
472
  }
468
473
 
469
474
  /**
@@ -534,7 +539,8 @@ export function hotspotsData(customDbPath, opts = {}) {
534
539
  }));
535
540
 
536
541
  db.close();
537
- return { metric, level, limit, hotspots };
542
+ const base = { metric, level, limit, hotspots };
543
+ return paginateResult(base, 'hotspots', { limit: opts.limit, offset: opts.offset });
538
544
  }
539
545
 
540
546
  /**
package/src/triage.js ADDED
@@ -0,0 +1,273 @@
1
+ import { openReadonlyOrFail } from './db.js';
2
+ import { warn } from './logger.js';
3
+ import { paginateResult, printNdjson } from './paginate.js';
4
+ import { isTestFile } from './queries.js';
5
+
6
+ // ─── Constants ────────────────────────────────────────────────────────
7
+
8
+ const DEFAULT_WEIGHTS = {
9
+ fanIn: 0.25,
10
+ complexity: 0.3,
11
+ churn: 0.2,
12
+ role: 0.15,
13
+ mi: 0.1,
14
+ };
15
+
16
+ const ROLE_WEIGHTS = {
17
+ core: 1.0,
18
+ utility: 0.9,
19
+ entry: 0.8,
20
+ adapter: 0.5,
21
+ leaf: 0.2,
22
+ dead: 0.1,
23
+ };
24
+
25
+ const DEFAULT_ROLE_WEIGHT = 0.5;
26
+
27
+ // ─── Helpers ──────────────────────────────────────────────────────────
28
+
29
+ /** Min-max normalize an array of numbers. All-equal → all zeros. */
30
+ function minMaxNormalize(values) {
31
+ const min = Math.min(...values);
32
+ const max = Math.max(...values);
33
+ if (max === min) return values.map(() => 0);
34
+ const range = max - min;
35
+ return values.map((v) => (v - min) / range);
36
+ }
37
+
38
+ // ─── Data Function ────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Compute composite risk scores for all symbols.
42
+ *
43
+ * @param {string} [customDbPath] - Path to graph.db
44
+ * @param {object} [opts]
45
+ * @returns {{ items: object[], summary: object, _pagination?: object }}
46
+ */
47
+ export function triageData(customDbPath, opts = {}) {
48
+ const db = openReadonlyOrFail(customDbPath);
49
+ const noTests = opts.noTests || false;
50
+ const fileFilter = opts.file || null;
51
+ const kindFilter = opts.kind || null;
52
+ const roleFilter = opts.role || null;
53
+ const minScore = opts.minScore != null ? Number(opts.minScore) : null;
54
+ const sort = opts.sort || 'risk';
55
+ const weights = { ...DEFAULT_WEIGHTS, ...(opts.weights || {}) };
56
+
57
+ // Build WHERE clause
58
+ let where = "WHERE n.kind IN ('function','method','class')";
59
+ const params = [];
60
+
61
+ if (noTests) {
62
+ where += ` 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
+ if (fileFilter) {
69
+ where += ' AND n.file LIKE ?';
70
+ params.push(`%${fileFilter}%`);
71
+ }
72
+ if (kindFilter) {
73
+ where += ' AND n.kind = ?';
74
+ params.push(kindFilter);
75
+ }
76
+ if (roleFilter) {
77
+ where += ' AND n.role = ?';
78
+ params.push(roleFilter);
79
+ }
80
+
81
+ let rows;
82
+ try {
83
+ rows = db
84
+ .prepare(
85
+ `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line, n.role,
86
+ COALESCE(fi.cnt, 0) AS fan_in,
87
+ COALESCE(fc.cognitive, 0) AS cognitive,
88
+ COALESCE(fc.maintainability_index, 0) AS mi,
89
+ COALESCE(fc.cyclomatic, 0) AS cyclomatic,
90
+ COALESCE(fc.max_nesting, 0) AS max_nesting,
91
+ COALESCE(fcc.commit_count, 0) AS churn
92
+ FROM nodes n
93
+ LEFT JOIN (SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind='calls' GROUP BY target_id) fi
94
+ ON n.id = fi.target_id
95
+ LEFT JOIN function_complexity fc ON fc.node_id = n.id
96
+ LEFT JOIN file_commit_counts fcc ON n.file = fcc.file
97
+ ${where}
98
+ ORDER BY n.file, n.line`,
99
+ )
100
+ .all(...params);
101
+ } catch (err) {
102
+ warn(`triage query failed: ${err.message}`);
103
+ db.close();
104
+ return {
105
+ items: [],
106
+ summary: { total: 0, analyzed: 0, avgScore: 0, maxScore: 0, weights, signalCoverage: {} },
107
+ };
108
+ }
109
+
110
+ // Post-filter test files (belt-and-suspenders)
111
+ const filtered = noTests ? rows.filter((r) => !isTestFile(r.file)) : rows;
112
+
113
+ if (filtered.length === 0) {
114
+ db.close();
115
+ return {
116
+ items: [],
117
+ summary: { total: 0, analyzed: 0, avgScore: 0, maxScore: 0, weights, signalCoverage: {} },
118
+ };
119
+ }
120
+
121
+ // Extract raw signal arrays
122
+ const fanIns = filtered.map((r) => r.fan_in);
123
+ const cognitives = filtered.map((r) => r.cognitive);
124
+ const churns = filtered.map((r) => r.churn);
125
+ const mis = filtered.map((r) => r.mi);
126
+
127
+ // Min-max normalize
128
+ const normFanIns = minMaxNormalize(fanIns);
129
+ const normCognitives = minMaxNormalize(cognitives);
130
+ const normChurns = minMaxNormalize(churns);
131
+ // MI: higher is better, so invert: 1 - norm(mi)
132
+ const normMIsRaw = minMaxNormalize(mis);
133
+ const normMIs = normMIsRaw.map((v) => round4(1 - v));
134
+
135
+ // Compute risk scores
136
+ const items = filtered.map((r, i) => {
137
+ const roleWeight = ROLE_WEIGHTS[r.role] ?? DEFAULT_ROLE_WEIGHT;
138
+ const riskScore =
139
+ weights.fanIn * normFanIns[i] +
140
+ weights.complexity * normCognitives[i] +
141
+ weights.churn * normChurns[i] +
142
+ weights.role * roleWeight +
143
+ weights.mi * normMIs[i];
144
+
145
+ return {
146
+ name: r.name,
147
+ kind: r.kind,
148
+ file: r.file,
149
+ line: r.line,
150
+ role: r.role || null,
151
+ fanIn: r.fan_in,
152
+ cognitive: r.cognitive,
153
+ churn: r.churn,
154
+ maintainabilityIndex: r.mi,
155
+ normFanIn: round4(normFanIns[i]),
156
+ normComplexity: round4(normCognitives[i]),
157
+ normChurn: round4(normChurns[i]),
158
+ normMI: round4(normMIs[i]),
159
+ roleWeight,
160
+ riskScore: round4(riskScore),
161
+ };
162
+ });
163
+
164
+ // Apply minScore filter
165
+ const scored = minScore != null ? items.filter((it) => it.riskScore >= minScore) : items;
166
+
167
+ // Sort
168
+ const sortFns = {
169
+ risk: (a, b) => b.riskScore - a.riskScore,
170
+ complexity: (a, b) => b.cognitive - a.cognitive,
171
+ churn: (a, b) => b.churn - a.churn,
172
+ 'fan-in': (a, b) => b.fanIn - a.fanIn,
173
+ mi: (a, b) => a.maintainabilityIndex - b.maintainabilityIndex,
174
+ };
175
+ scored.sort(sortFns[sort] || sortFns.risk);
176
+
177
+ // Signal coverage: % of items with non-zero signal
178
+ const signalCoverage = {
179
+ complexity: round4(filtered.filter((r) => r.cognitive > 0).length / filtered.length),
180
+ churn: round4(filtered.filter((r) => r.churn > 0).length / filtered.length),
181
+ fanIn: round4(filtered.filter((r) => r.fan_in > 0).length / filtered.length),
182
+ mi: round4(filtered.filter((r) => r.mi > 0).length / filtered.length),
183
+ };
184
+
185
+ const scores = scored.map((it) => it.riskScore);
186
+ const avgScore =
187
+ scores.length > 0 ? round4(scores.reduce((a, b) => a + b, 0) / scores.length) : 0;
188
+ const maxScore = scores.length > 0 ? round4(Math.max(...scores)) : 0;
189
+
190
+ const result = {
191
+ items: scored,
192
+ summary: {
193
+ total: filtered.length,
194
+ analyzed: scored.length,
195
+ avgScore,
196
+ maxScore,
197
+ weights,
198
+ signalCoverage,
199
+ },
200
+ };
201
+
202
+ db.close();
203
+
204
+ return paginateResult(result, 'items', {
205
+ limit: opts.limit,
206
+ offset: opts.offset,
207
+ });
208
+ }
209
+
210
+ // ─── CLI Formatter ────────────────────────────────────────────────────
211
+
212
+ /**
213
+ * Print triage results to console.
214
+ *
215
+ * @param {string} [customDbPath]
216
+ * @param {object} [opts]
217
+ */
218
+ export function triage(customDbPath, opts = {}) {
219
+ const data = triageData(customDbPath, opts);
220
+
221
+ if (opts.ndjson) {
222
+ printNdjson(data, 'items');
223
+ return;
224
+ }
225
+ if (opts.json) {
226
+ console.log(JSON.stringify(data, null, 2));
227
+ return;
228
+ }
229
+
230
+ if (data.items.length === 0) {
231
+ if (data.summary.total === 0) {
232
+ console.log('\nNo symbols found. Run "codegraph build" first.\n');
233
+ } else {
234
+ console.log('\nNo symbols match the given filters.\n');
235
+ }
236
+ return;
237
+ }
238
+
239
+ console.log('\n# Risk Audit Queue\n');
240
+
241
+ console.log(
242
+ ` ${'Symbol'.padEnd(35)} ${'File'.padEnd(28)} ${'Role'.padEnd(8)} ${'Score'.padStart(6)} ${'Fan-In'.padStart(7)} ${'Cog'.padStart(4)} ${'Churn'.padStart(6)} ${'MI'.padStart(5)}`,
243
+ );
244
+ console.log(
245
+ ` ${'─'.repeat(35)} ${'─'.repeat(28)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(7)} ${'─'.repeat(4)} ${'─'.repeat(6)} ${'─'.repeat(5)}`,
246
+ );
247
+
248
+ for (const it of data.items) {
249
+ const name = it.name.length > 33 ? `${it.name.slice(0, 32)}…` : it.name;
250
+ const file = it.file.length > 26 ? `…${it.file.slice(-25)}` : it.file;
251
+ const role = (it.role || '-').padEnd(8);
252
+ const score = it.riskScore.toFixed(2).padStart(6);
253
+ const fanIn = String(it.fanIn).padStart(7);
254
+ const cog = String(it.cognitive).padStart(4);
255
+ const churn = String(it.churn).padStart(6);
256
+ const mi = it.maintainabilityIndex > 0 ? String(it.maintainabilityIndex).padStart(5) : ' -';
257
+ console.log(
258
+ ` ${name.padEnd(35)} ${file.padEnd(28)} ${role} ${score} ${fanIn} ${cog} ${churn} ${mi}`,
259
+ );
260
+ }
261
+
262
+ const s = data.summary;
263
+ console.log(
264
+ `\n ${s.analyzed} symbols scored (of ${s.total} total) | avg: ${s.avgScore.toFixed(2)} | max: ${s.maxScore.toFixed(2)} | sort: ${opts.sort || 'risk'}`,
265
+ );
266
+ console.log();
267
+ }
268
+
269
+ // ─── Utilities ────────────────────────────────────────────────────────
270
+
271
+ function round4(n) {
272
+ return Math.round(n * 10000) / 10000;
273
+ }