@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/README.md +119 -49
- package/package.json +8 -7
- package/src/audit.js +423 -0
- package/src/batch.js +90 -0
- package/src/boundaries.js +346 -0
- package/src/builder.js +66 -2
- package/src/check.js +432 -0
- package/src/cli.js +361 -6
- package/src/cochange.js +5 -2
- package/src/communities.js +7 -1
- package/src/complexity.js +116 -9
- package/src/config.js +10 -0
- package/src/embedder.js +350 -38
- package/src/flow.js +4 -4
- package/src/index.js +28 -1
- package/src/manifesto.js +69 -1
- package/src/mcp.js +347 -19
- package/src/owners.js +359 -0
- package/src/paginate.js +35 -0
- package/src/queries.js +233 -19
- package/src/snapshot.js +149 -0
- package/src/structure.js +5 -2
- package/src/triage.js +273 -0
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/snapshot.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
539
|
+
const base = { metric, level, limit, hotspots };
|
|
540
|
+
return paginateResult(base, 'hotspots', { limit: opts.limit, offset: opts.offset });
|
|
538
541
|
}
|
|
539
542
|
|
|
540
543
|
/**
|