@optave/codegraph 2.5.1 → 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/queries.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { evaluateBoundaries } from './boundaries.js';
4
5
  import { coChangeForFiles } from './cochange.js';
6
+ import { loadConfig } from './config.js';
5
7
  import { findCycles } from './cycles.js';
6
8
  import { findDbPath, openReadonlyOrFail } from './db.js';
7
9
  import { debug } from './logger.js';
8
- import { paginateResult } from './paginate.js';
10
+ import { ownersForFiles } from './owners.js';
11
+ import { paginateResult, printNdjson } from './paginate.js';
9
12
  import { LANGUAGE_REGISTRY } from './parser.js';
10
13
 
11
14
  /**
@@ -391,7 +394,8 @@ export function fileDepsData(file, customDbPath, opts = {}) {
391
394
  });
392
395
 
393
396
  db.close();
394
- return { file, results };
397
+ const base = { file, results };
398
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
395
399
  }
396
400
 
397
401
  export function fnDepsData(name, customDbPath, opts = {}) {
@@ -511,7 +515,8 @@ export function fnDepsData(name, customDbPath, opts = {}) {
511
515
  });
512
516
 
513
517
  db.close();
514
- return { name, results };
518
+ const base = { name, results };
519
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
515
520
  }
516
521
 
517
522
  export function fnImpactData(name, customDbPath, opts = {}) {
@@ -525,7 +530,7 @@ export function fnImpactData(name, customDbPath, opts = {}) {
525
530
  return { name, results: [] };
526
531
  }
527
532
 
528
- const results = nodes.slice(0, 3).map((node) => {
533
+ const results = nodes.map((node) => {
529
534
  const visited = new Set([node.id]);
530
535
  const levels = {};
531
536
  let frontier = [node.id];
@@ -564,7 +569,8 @@ export function fnImpactData(name, customDbPath, opts = {}) {
564
569
  });
565
570
 
566
571
  db.close();
567
- return { name, results };
572
+ const base = { name, results };
573
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
568
574
  }
569
575
 
570
576
  export function pathData(from, to, customDbPath, opts = {}) {
@@ -998,20 +1004,60 @@ export function diffImpactData(customDbPath, opts = {}) {
998
1004
  /* co_changes table doesn't exist — skip silently */
999
1005
  }
1000
1006
 
1007
+ // Look up CODEOWNERS for changed + affected files
1008
+ let ownership = null;
1009
+ try {
1010
+ const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])];
1011
+ const ownerResult = ownersForFiles(allFilePaths, repoRoot);
1012
+ if (ownerResult.affectedOwners.length > 0) {
1013
+ ownership = {
1014
+ owners: Object.fromEntries(ownerResult.owners),
1015
+ affectedOwners: ownerResult.affectedOwners,
1016
+ suggestedReviewers: ownerResult.suggestedReviewers,
1017
+ };
1018
+ }
1019
+ } catch {
1020
+ /* CODEOWNERS missing or unreadable — skip silently */
1021
+ }
1022
+
1023
+ // Check boundary violations scoped to changed files
1024
+ let boundaryViolations = [];
1025
+ let boundaryViolationCount = 0;
1026
+ try {
1027
+ const config = loadConfig(repoRoot);
1028
+ const boundaryConfig = config.manifesto?.boundaries;
1029
+ if (boundaryConfig) {
1030
+ const result = evaluateBoundaries(db, boundaryConfig, {
1031
+ scopeFiles: [...changedRanges.keys()],
1032
+ noTests,
1033
+ });
1034
+ boundaryViolations = result.violations;
1035
+ boundaryViolationCount = result.violationCount;
1036
+ }
1037
+ } catch {
1038
+ /* boundary check failed — skip silently */
1039
+ }
1040
+
1001
1041
  db.close();
1002
- return {
1042
+ const base = {
1003
1043
  changedFiles: changedRanges.size,
1004
1044
  newFiles: [...newFiles],
1005
1045
  affectedFunctions: functionResults,
1006
1046
  affectedFiles: [...affectedFiles],
1007
1047
  historicallyCoupled,
1048
+ ownership,
1049
+ boundaryViolations,
1050
+ boundaryViolationCount,
1008
1051
  summary: {
1009
1052
  functionsChanged: affectedFunctions.length,
1010
1053
  callersAffected: allAffected.size,
1011
1054
  filesAffected: affectedFiles.size,
1012
1055
  historicallyCoupledCount: historicallyCoupled.length,
1056
+ ownersAffected: ownership ? ownership.affectedOwners.length : 0,
1057
+ boundaryViolationCount,
1013
1058
  },
1014
1059
  };
1060
+ return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset });
1015
1061
  }
1016
1062
 
1017
1063
  export function diffImpactMermaid(customDbPath, opts = {}) {
@@ -1159,6 +1205,131 @@ export function listFunctionsData(customDbPath, opts = {}) {
1159
1205
  return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset });
1160
1206
  }
1161
1207
 
1208
+ /**
1209
+ * Generator: stream functions one-by-one using .iterate() for memory efficiency.
1210
+ * @param {string} [customDbPath]
1211
+ * @param {object} [opts]
1212
+ * @param {boolean} [opts.noTests]
1213
+ * @param {string} [opts.file]
1214
+ * @param {string} [opts.pattern]
1215
+ * @yields {{ name: string, kind: string, file: string, line: number, role: string|null }}
1216
+ */
1217
+ export function* iterListFunctions(customDbPath, opts = {}) {
1218
+ const db = openReadonlyOrFail(customDbPath);
1219
+ try {
1220
+ const noTests = opts.noTests || false;
1221
+ const kinds = ['function', 'method', 'class'];
1222
+ const placeholders = kinds.map(() => '?').join(', ');
1223
+
1224
+ const conditions = [`kind IN (${placeholders})`];
1225
+ const params = [...kinds];
1226
+
1227
+ if (opts.file) {
1228
+ conditions.push('file LIKE ?');
1229
+ params.push(`%${opts.file}%`);
1230
+ }
1231
+ if (opts.pattern) {
1232
+ conditions.push('name LIKE ?');
1233
+ params.push(`%${opts.pattern}%`);
1234
+ }
1235
+
1236
+ const stmt = db.prepare(
1237
+ `SELECT name, kind, file, line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`,
1238
+ );
1239
+ for (const row of stmt.iterate(...params)) {
1240
+ if (noTests && isTestFile(row.file)) continue;
1241
+ yield { name: row.name, kind: row.kind, file: row.file, line: row.line, role: row.role };
1242
+ }
1243
+ } finally {
1244
+ db.close();
1245
+ }
1246
+ }
1247
+
1248
+ /**
1249
+ * Generator: stream role-classified symbols one-by-one.
1250
+ * @param {string} [customDbPath]
1251
+ * @param {object} [opts]
1252
+ * @param {boolean} [opts.noTests]
1253
+ * @param {string} [opts.role]
1254
+ * @param {string} [opts.file]
1255
+ * @yields {{ name: string, kind: string, file: string, line: number, role: string }}
1256
+ */
1257
+ export function* iterRoles(customDbPath, opts = {}) {
1258
+ const db = openReadonlyOrFail(customDbPath);
1259
+ try {
1260
+ const noTests = opts.noTests || false;
1261
+ const conditions = ['role IS NOT NULL'];
1262
+ const params = [];
1263
+
1264
+ if (opts.role) {
1265
+ conditions.push('role = ?');
1266
+ params.push(opts.role);
1267
+ }
1268
+ if (opts.file) {
1269
+ conditions.push('file LIKE ?');
1270
+ params.push(`%${opts.file}%`);
1271
+ }
1272
+
1273
+ const stmt = db.prepare(
1274
+ `SELECT name, kind, file, line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`,
1275
+ );
1276
+ for (const row of stmt.iterate(...params)) {
1277
+ if (noTests && isTestFile(row.file)) continue;
1278
+ yield { name: row.name, kind: row.kind, file: row.file, line: row.line, role: row.role };
1279
+ }
1280
+ } finally {
1281
+ db.close();
1282
+ }
1283
+ }
1284
+
1285
+ /**
1286
+ * Generator: stream symbol lookup results one-by-one.
1287
+ * @param {string} target - Symbol name to search for (partial match)
1288
+ * @param {string} [customDbPath]
1289
+ * @param {object} [opts]
1290
+ * @param {boolean} [opts.noTests]
1291
+ * @yields {{ name: string, kind: string, file: string, line: number, role: string|null, exported: boolean, uses: object[] }}
1292
+ */
1293
+ export function* iterWhere(target, customDbPath, opts = {}) {
1294
+ const db = openReadonlyOrFail(customDbPath);
1295
+ try {
1296
+ const noTests = opts.noTests || false;
1297
+ const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', ');
1298
+ const stmt = db.prepare(
1299
+ `SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`,
1300
+ );
1301
+ const crossFileCallersStmt = db.prepare(
1302
+ `SELECT COUNT(*) as cnt FROM edges e JOIN nodes n ON e.source_id = n.id
1303
+ WHERE e.target_id = ? AND e.kind = 'calls' AND n.file != ?`,
1304
+ );
1305
+ const usesStmt = db.prepare(
1306
+ `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id
1307
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1308
+ );
1309
+ for (const node of stmt.iterate(`%${target}%`, ...ALL_SYMBOL_KINDS)) {
1310
+ if (noTests && isTestFile(node.file)) continue;
1311
+
1312
+ const crossFileCallers = crossFileCallersStmt.get(node.id, node.file);
1313
+ const exported = crossFileCallers.cnt > 0;
1314
+
1315
+ let uses = usesStmt.all(node.id);
1316
+ if (noTests) uses = uses.filter((u) => !isTestFile(u.file));
1317
+
1318
+ yield {
1319
+ name: node.name,
1320
+ kind: node.kind,
1321
+ file: node.file,
1322
+ line: node.line,
1323
+ role: node.role || null,
1324
+ exported,
1325
+ uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })),
1326
+ };
1327
+ }
1328
+ } finally {
1329
+ db.close();
1330
+ }
1331
+ }
1332
+
1162
1333
  export function statsData(customDbPath, opts = {}) {
1163
1334
  const db = openReadonlyOrFail(customDbPath);
1164
1335
  const noTests = opts.noTests || false;
@@ -1553,8 +1724,7 @@ export function queryName(name, customDbPath, opts = {}) {
1553
1724
  offset: opts.offset,
1554
1725
  });
1555
1726
  if (opts.ndjson) {
1556
- if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination }));
1557
- for (const r of data.results) console.log(JSON.stringify(r));
1727
+ printNdjson(data, 'results');
1558
1728
  return;
1559
1729
  }
1560
1730
  if (opts.json) {
@@ -1586,7 +1756,11 @@ export function queryName(name, customDbPath, opts = {}) {
1586
1756
  }
1587
1757
 
1588
1758
  export function impactAnalysis(file, customDbPath, opts = {}) {
1589
- const data = impactAnalysisData(file, customDbPath, { noTests: opts.noTests });
1759
+ const data = impactAnalysisData(file, customDbPath, opts);
1760
+ if (opts.ndjson) {
1761
+ printNdjson(data, 'sources');
1762
+ return;
1763
+ }
1590
1764
  if (opts.json) {
1591
1765
  console.log(JSON.stringify(data, null, 2));
1592
1766
  return;
@@ -1645,7 +1819,11 @@ export function moduleMap(customDbPath, limit = 20, opts = {}) {
1645
1819
  }
1646
1820
 
1647
1821
  export function fileDeps(file, customDbPath, opts = {}) {
1648
- const data = fileDepsData(file, customDbPath, { noTests: opts.noTests });
1822
+ const data = fileDepsData(file, customDbPath, opts);
1823
+ if (opts.ndjson) {
1824
+ printNdjson(data, 'results');
1825
+ return;
1826
+ }
1649
1827
  if (opts.json) {
1650
1828
  console.log(JSON.stringify(data, null, 2));
1651
1829
  return;
@@ -1676,6 +1854,10 @@ export function fileDeps(file, customDbPath, opts = {}) {
1676
1854
 
1677
1855
  export function fnDeps(name, customDbPath, opts = {}) {
1678
1856
  const data = fnDepsData(name, customDbPath, opts);
1857
+ if (opts.ndjson) {
1858
+ printNdjson(data, 'results');
1859
+ return;
1860
+ }
1679
1861
  if (opts.json) {
1680
1862
  console.log(JSON.stringify(data, null, 2));
1681
1863
  return;
@@ -1838,14 +2020,13 @@ export function contextData(name, customDbPath, opts = {}) {
1838
2020
  const dbPath = findDbPath(customDbPath);
1839
2021
  const repoRoot = path.resolve(path.dirname(dbPath), '..');
1840
2022
 
1841
- let nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
2023
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
1842
2024
  if (nodes.length === 0) {
1843
2025
  db.close();
1844
2026
  return { name, results: [] };
1845
2027
  }
1846
2028
 
1847
- // Limit to first 5 results
1848
- nodes = nodes.slice(0, 5);
2029
+ // No hardcoded slice pagination handles bounding via limit/offset
1849
2030
 
1850
2031
  // File-lines cache to avoid re-reading the same file
1851
2032
  const fileCache = new Map();
@@ -2050,11 +2231,16 @@ export function contextData(name, customDbPath, opts = {}) {
2050
2231
  });
2051
2232
 
2052
2233
  db.close();
2053
- return { name, results };
2234
+ const base = { name, results };
2235
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2054
2236
  }
2055
2237
 
2056
2238
  export function context(name, customDbPath, opts = {}) {
2057
2239
  const data = contextData(name, customDbPath, opts);
2240
+ if (opts.ndjson) {
2241
+ printNdjson(data, 'results');
2242
+ return;
2243
+ }
2058
2244
  if (opts.json) {
2059
2245
  console.log(JSON.stringify(data, null, 2));
2060
2246
  return;
@@ -2410,11 +2596,16 @@ export function explainData(target, customDbPath, opts = {}) {
2410
2596
  }
2411
2597
 
2412
2598
  db.close();
2413
- return { target, kind, results };
2599
+ const base = { target, kind, results };
2600
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2414
2601
  }
2415
2602
 
2416
2603
  export function explain(target, customDbPath, opts = {}) {
2417
2604
  const data = explainData(target, customDbPath, opts);
2605
+ if (opts.ndjson) {
2606
+ printNdjson(data, 'results');
2607
+ return;
2608
+ }
2418
2609
  if (opts.json) {
2419
2610
  console.log(JSON.stringify(data, null, 2));
2420
2611
  return;
@@ -2645,8 +2836,7 @@ export function whereData(target, customDbPath, opts = {}) {
2645
2836
  export function where(target, customDbPath, opts = {}) {
2646
2837
  const data = whereData(target, customDbPath, opts);
2647
2838
  if (opts.ndjson) {
2648
- if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination }));
2649
- for (const r of data.results) console.log(JSON.stringify(r));
2839
+ printNdjson(data, 'results');
2650
2840
  return;
2651
2841
  }
2652
2842
  if (opts.json) {
@@ -2737,8 +2927,7 @@ export function rolesData(customDbPath, opts = {}) {
2737
2927
  export function roles(customDbPath, opts = {}) {
2738
2928
  const data = rolesData(customDbPath, opts);
2739
2929
  if (opts.ndjson) {
2740
- if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination }));
2741
- for (const s of data.symbols) console.log(JSON.stringify(s));
2930
+ printNdjson(data, 'symbols');
2742
2931
  return;
2743
2932
  }
2744
2933
  if (opts.json) {
@@ -2779,6 +2968,10 @@ export function roles(customDbPath, opts = {}) {
2779
2968
 
2780
2969
  export function fnImpact(name, customDbPath, opts = {}) {
2781
2970
  const data = fnImpactData(name, customDbPath, opts);
2971
+ if (opts.ndjson) {
2972
+ printNdjson(data, 'results');
2973
+ return;
2974
+ }
2782
2975
  if (opts.json) {
2783
2976
  console.log(JSON.stringify(data, null, 2));
2784
2977
  return;
@@ -2811,6 +3004,10 @@ export function diffImpact(customDbPath, opts = {}) {
2811
3004
  return;
2812
3005
  }
2813
3006
  const data = diffImpactData(customDbPath, opts);
3007
+ if (opts.ndjson) {
3008
+ printNdjson(data, 'affectedFunctions');
3009
+ return;
3010
+ }
2814
3011
  if (opts.json || opts.format === 'json') {
2815
3012
  console.log(JSON.stringify(data, null, 2));
2816
3013
  return;
@@ -2845,11 +3042,28 @@ export function diffImpact(customDbPath, opts = {}) {
2845
3042
  );
2846
3043
  }
2847
3044
  }
3045
+ if (data.ownership) {
3046
+ console.log(`\n Affected owners: ${data.ownership.affectedOwners.join(', ')}`);
3047
+ console.log(` Suggested reviewers: ${data.ownership.suggestedReviewers.join(', ')}`);
3048
+ }
3049
+ if (data.boundaryViolations && data.boundaryViolations.length > 0) {
3050
+ console.log(`\n Boundary violations (${data.boundaryViolationCount}):\n`);
3051
+ for (const v of data.boundaryViolations) {
3052
+ console.log(` [${v.name}] ${v.file} -> ${v.targetFile}`);
3053
+ if (v.message) console.log(` ${v.message}`);
3054
+ }
3055
+ }
2848
3056
  if (data.summary) {
2849
3057
  let summaryLine = `\n Summary: ${data.summary.functionsChanged} functions changed -> ${data.summary.callersAffected} callers affected across ${data.summary.filesAffected} files`;
2850
3058
  if (data.summary.historicallyCoupledCount > 0) {
2851
3059
  summaryLine += `, ${data.summary.historicallyCoupledCount} historically coupled`;
2852
3060
  }
3061
+ if (data.summary.ownersAffected > 0) {
3062
+ summaryLine += `, ${data.summary.ownersAffected} owners affected`;
3063
+ }
3064
+ if (data.summary.boundaryViolationCount > 0) {
3065
+ summaryLine += `, ${data.summary.boundaryViolationCount} boundary violations`;
3066
+ }
2853
3067
  console.log(`${summaryLine}\n`);
2854
3068
  }
2855
3069
  }
@@ -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 ────
@@ -463,7 +464,8 @@ export function structureData(customDbPath, opts = {}) {
463
464
  }
464
465
  }
465
466
 
466
- return { directories: result, count: result.length };
467
+ const base = { directories: result, count: result.length };
468
+ return paginateResult(base, 'directories', { limit: opts.limit, offset: opts.offset });
467
469
  }
468
470
 
469
471
  /**
@@ -534,7 +536,8 @@ export function hotspotsData(customDbPath, opts = {}) {
534
536
  }));
535
537
 
536
538
  db.close();
537
- return { metric, level, limit, hotspots };
539
+ const base = { metric, level, limit, hotspots };
540
+ return paginateResult(base, 'hotspots', { limit: opts.limit, offset: opts.offset });
538
541
  }
539
542
 
540
543
  /**