@optave/codegraph 2.5.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.
- package/README.md +7 -5
- package/package.json +5 -5
- package/src/branch-compare.js +568 -0
- package/src/cli.js +14 -3
- package/src/registry.js +6 -3
package/README.md
CHANGED
|
@@ -423,12 +423,14 @@ Self-measured on every release via CI ([build benchmarks](generated/BUILD-BENCHM
|
|
|
423
423
|
|
|
424
424
|
| Metric | Latest |
|
|
425
425
|
|---|---|
|
|
426
|
-
| Build speed | **
|
|
426
|
+
| Build speed (native) | **2 ms/file** |
|
|
427
|
+
| Build speed (WASM) | **8.4 ms/file** |
|
|
427
428
|
| Query time | **2ms** |
|
|
428
|
-
| No-op rebuild | **
|
|
429
|
-
| 1-file rebuild | **
|
|
430
|
-
| Query: fn-deps | **
|
|
431
|
-
|
|
|
429
|
+
| No-op rebuild (native) | **4ms** |
|
|
430
|
+
| 1-file rebuild (native) | **97ms** |
|
|
431
|
+
| Query: fn-deps | **2.1ms** |
|
|
432
|
+
| Query: path | **1.2ms** |
|
|
433
|
+
| ~50,000 files (est.) | **~100.0s build** |
|
|
432
434
|
|
|
433
435
|
Metrics are normalized per file for cross-version comparability. Times above are for a full initial build — incremental rebuilds only re-parse changed files.
|
|
434
436
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@optave/codegraph",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "Local code graph CLI — parse codebases with tree-sitter, build dependency graphs, query them",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -71,10 +71,10 @@
|
|
|
71
71
|
},
|
|
72
72
|
"optionalDependencies": {
|
|
73
73
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
74
|
-
"@optave/codegraph-darwin-arm64": "2.5.
|
|
75
|
-
"@optave/codegraph-darwin-x64": "2.5.
|
|
76
|
-
"@optave/codegraph-linux-x64-gnu": "2.5.
|
|
77
|
-
"@optave/codegraph-win32-x64-msvc": "2.5.
|
|
74
|
+
"@optave/codegraph-darwin-arm64": "2.5.1",
|
|
75
|
+
"@optave/codegraph-darwin-x64": "2.5.1",
|
|
76
|
+
"@optave/codegraph-linux-x64-gnu": "2.5.1",
|
|
77
|
+
"@optave/codegraph-win32-x64-msvc": "2.5.1"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@biomejs/biome": "^2.4.4",
|
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branch structural diff – compare code structure between two git refs.
|
|
3
|
+
*
|
|
4
|
+
* Builds separate codegraph databases for each ref using git worktrees,
|
|
5
|
+
* then diffs at the symbol level to show added/removed/changed symbols
|
|
6
|
+
* and transitive caller impact.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import Database from 'better-sqlite3';
|
|
14
|
+
import { buildGraph } from './builder.js';
|
|
15
|
+
import { isTestFile, kindIcon } from './queries.js';
|
|
16
|
+
|
|
17
|
+
// ─── Git Helpers ────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function validateGitRef(repoRoot, ref) {
|
|
20
|
+
try {
|
|
21
|
+
const sha = execFileSync('git', ['rev-parse', '--verify', ref], {
|
|
22
|
+
cwd: repoRoot,
|
|
23
|
+
encoding: 'utf-8',
|
|
24
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
25
|
+
}).trim();
|
|
26
|
+
return sha;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getChangedFilesBetweenRefs(repoRoot, base, target) {
|
|
33
|
+
const output = execFileSync('git', ['diff', '--name-only', `${base}..${target}`], {
|
|
34
|
+
cwd: repoRoot,
|
|
35
|
+
encoding: 'utf-8',
|
|
36
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
37
|
+
}).trim();
|
|
38
|
+
if (!output) return [];
|
|
39
|
+
return output.split('\n').filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createWorktree(repoRoot, ref, dir) {
|
|
43
|
+
execFileSync('git', ['worktree', 'add', '--detach', dir, ref], {
|
|
44
|
+
cwd: repoRoot,
|
|
45
|
+
encoding: 'utf-8',
|
|
46
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function removeWorktree(repoRoot, dir) {
|
|
51
|
+
try {
|
|
52
|
+
execFileSync('git', ['worktree', 'remove', '--force', dir], {
|
|
53
|
+
cwd: repoRoot,
|
|
54
|
+
encoding: 'utf-8',
|
|
55
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
56
|
+
});
|
|
57
|
+
} catch {
|
|
58
|
+
// Fallback: remove directory and prune
|
|
59
|
+
try {
|
|
60
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
61
|
+
} catch {
|
|
62
|
+
/* best-effort */
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
execFileSync('git', ['worktree', 'prune'], {
|
|
66
|
+
cwd: repoRoot,
|
|
67
|
+
encoding: 'utf-8',
|
|
68
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
/* best-effort */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Symbol Loading ─────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
function makeSymbolKey(kind, file, name) {
|
|
79
|
+
return `${kind}::${file}::${name}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function loadSymbolsFromDb(dbPath, changedFiles, noTests) {
|
|
83
|
+
const db = new Database(dbPath, { readonly: true });
|
|
84
|
+
const symbols = new Map();
|
|
85
|
+
|
|
86
|
+
if (changedFiles.length === 0) {
|
|
87
|
+
db.close();
|
|
88
|
+
return symbols;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Query nodes in changed files
|
|
92
|
+
const placeholders = changedFiles.map(() => '?').join(', ');
|
|
93
|
+
const rows = db
|
|
94
|
+
.prepare(
|
|
95
|
+
`SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
|
|
96
|
+
FROM nodes n
|
|
97
|
+
WHERE n.file IN (${placeholders})
|
|
98
|
+
AND n.kind NOT IN ('file', 'directory')
|
|
99
|
+
ORDER BY n.file, n.line`,
|
|
100
|
+
)
|
|
101
|
+
.all(...changedFiles);
|
|
102
|
+
|
|
103
|
+
// Compute fan_in and fan_out for each node
|
|
104
|
+
const fanInStmt = db.prepare(
|
|
105
|
+
`SELECT COUNT(*) AS cnt FROM edges WHERE target_id = ? AND kind = 'calls'`,
|
|
106
|
+
);
|
|
107
|
+
const fanOutStmt = db.prepare(
|
|
108
|
+
`SELECT COUNT(*) AS cnt FROM edges WHERE source_id = ? AND kind = 'calls'`,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
for (const row of rows) {
|
|
112
|
+
if (noTests && isTestFile(row.file)) continue;
|
|
113
|
+
|
|
114
|
+
const lineCount = row.end_line ? row.end_line - row.line + 1 : 0;
|
|
115
|
+
const fanIn = fanInStmt.get(row.id).cnt;
|
|
116
|
+
const fanOut = fanOutStmt.get(row.id).cnt;
|
|
117
|
+
const key = makeSymbolKey(row.kind, row.file, row.name);
|
|
118
|
+
|
|
119
|
+
symbols.set(key, {
|
|
120
|
+
id: row.id,
|
|
121
|
+
name: row.name,
|
|
122
|
+
kind: row.kind,
|
|
123
|
+
file: row.file,
|
|
124
|
+
line: row.line,
|
|
125
|
+
lineCount,
|
|
126
|
+
fanIn,
|
|
127
|
+
fanOut,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
db.close();
|
|
132
|
+
return symbols;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Caller BFS ─────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
function loadCallersFromDb(dbPath, nodeIds, maxDepth, noTests) {
|
|
138
|
+
if (nodeIds.length === 0) return [];
|
|
139
|
+
|
|
140
|
+
const db = new Database(dbPath, { readonly: true });
|
|
141
|
+
const allCallers = new Set();
|
|
142
|
+
|
|
143
|
+
for (const startId of nodeIds) {
|
|
144
|
+
const visited = new Set([startId]);
|
|
145
|
+
let frontier = [startId];
|
|
146
|
+
|
|
147
|
+
for (let d = 1; d <= maxDepth; d++) {
|
|
148
|
+
const nextFrontier = [];
|
|
149
|
+
for (const fid of frontier) {
|
|
150
|
+
const callers = db
|
|
151
|
+
.prepare(
|
|
152
|
+
`SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line
|
|
153
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
154
|
+
WHERE e.target_id = ? AND e.kind = 'calls'`,
|
|
155
|
+
)
|
|
156
|
+
.all(fid);
|
|
157
|
+
|
|
158
|
+
for (const c of callers) {
|
|
159
|
+
if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
|
|
160
|
+
visited.add(c.id);
|
|
161
|
+
nextFrontier.push(c.id);
|
|
162
|
+
allCallers.add(
|
|
163
|
+
JSON.stringify({ name: c.name, kind: c.kind, file: c.file, line: c.line }),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
frontier = nextFrontier;
|
|
169
|
+
if (frontier.length === 0) break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
db.close();
|
|
174
|
+
return [...allCallers].map((s) => JSON.parse(s));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Symbol Comparison ──────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
function compareSymbols(baseSymbols, targetSymbols) {
|
|
180
|
+
const added = [];
|
|
181
|
+
const removed = [];
|
|
182
|
+
const changed = [];
|
|
183
|
+
|
|
184
|
+
// Added: in target but not base
|
|
185
|
+
for (const [key, sym] of targetSymbols) {
|
|
186
|
+
if (!baseSymbols.has(key)) {
|
|
187
|
+
added.push(sym);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Removed: in base but not target
|
|
192
|
+
for (const [key, sym] of baseSymbols) {
|
|
193
|
+
if (!targetSymbols.has(key)) {
|
|
194
|
+
removed.push(sym);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Changed: in both but with different metrics
|
|
199
|
+
for (const [key, baseSym] of baseSymbols) {
|
|
200
|
+
const targetSym = targetSymbols.get(key);
|
|
201
|
+
if (!targetSym) continue;
|
|
202
|
+
|
|
203
|
+
const lineCountDelta = targetSym.lineCount - baseSym.lineCount;
|
|
204
|
+
const fanInDelta = targetSym.fanIn - baseSym.fanIn;
|
|
205
|
+
const fanOutDelta = targetSym.fanOut - baseSym.fanOut;
|
|
206
|
+
|
|
207
|
+
if (lineCountDelta !== 0 || fanInDelta !== 0 || fanOutDelta !== 0) {
|
|
208
|
+
changed.push({
|
|
209
|
+
name: baseSym.name,
|
|
210
|
+
kind: baseSym.kind,
|
|
211
|
+
file: baseSym.file,
|
|
212
|
+
base: {
|
|
213
|
+
line: baseSym.line,
|
|
214
|
+
lineCount: baseSym.lineCount,
|
|
215
|
+
fanIn: baseSym.fanIn,
|
|
216
|
+
fanOut: baseSym.fanOut,
|
|
217
|
+
},
|
|
218
|
+
target: {
|
|
219
|
+
line: targetSym.line,
|
|
220
|
+
lineCount: targetSym.lineCount,
|
|
221
|
+
fanIn: targetSym.fanIn,
|
|
222
|
+
fanOut: targetSym.fanOut,
|
|
223
|
+
},
|
|
224
|
+
changes: {
|
|
225
|
+
lineCount: lineCountDelta,
|
|
226
|
+
fanIn: fanInDelta,
|
|
227
|
+
fanOut: fanOutDelta,
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { added, removed, changed };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── Main Data Function ─────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
export async function branchCompareData(baseRef, targetRef, opts = {}) {
|
|
239
|
+
const repoRoot = opts.repoRoot || process.cwd();
|
|
240
|
+
const maxDepth = opts.depth || 3;
|
|
241
|
+
const noTests = opts.noTests || false;
|
|
242
|
+
const engine = opts.engine || 'wasm';
|
|
243
|
+
|
|
244
|
+
// Check if this is a git repo
|
|
245
|
+
try {
|
|
246
|
+
execFileSync('git', ['rev-parse', '--git-dir'], {
|
|
247
|
+
cwd: repoRoot,
|
|
248
|
+
encoding: 'utf-8',
|
|
249
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
250
|
+
});
|
|
251
|
+
} catch {
|
|
252
|
+
return { error: 'Not a git repository' };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Validate refs
|
|
256
|
+
const baseSha = validateGitRef(repoRoot, baseRef);
|
|
257
|
+
if (!baseSha) return { error: `Invalid git ref: "${baseRef}"` };
|
|
258
|
+
|
|
259
|
+
const targetSha = validateGitRef(repoRoot, targetRef);
|
|
260
|
+
if (!targetSha) return { error: `Invalid git ref: "${targetRef}"` };
|
|
261
|
+
|
|
262
|
+
// Get changed files
|
|
263
|
+
const changedFiles = getChangedFilesBetweenRefs(repoRoot, baseSha, targetSha);
|
|
264
|
+
|
|
265
|
+
if (changedFiles.length === 0) {
|
|
266
|
+
return {
|
|
267
|
+
baseRef,
|
|
268
|
+
targetRef,
|
|
269
|
+
baseSha,
|
|
270
|
+
targetSha,
|
|
271
|
+
changedFiles: [],
|
|
272
|
+
added: [],
|
|
273
|
+
removed: [],
|
|
274
|
+
changed: [],
|
|
275
|
+
summary: {
|
|
276
|
+
added: 0,
|
|
277
|
+
removed: 0,
|
|
278
|
+
changed: 0,
|
|
279
|
+
totalImpacted: 0,
|
|
280
|
+
filesAffected: 0,
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Create temp dir for worktrees
|
|
286
|
+
const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-bc-'));
|
|
287
|
+
const baseDir = path.join(tmpBase, 'base');
|
|
288
|
+
const targetDir = path.join(tmpBase, 'target');
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
// Create worktrees
|
|
292
|
+
createWorktree(repoRoot, baseSha, baseDir);
|
|
293
|
+
createWorktree(repoRoot, targetSha, targetDir);
|
|
294
|
+
|
|
295
|
+
// Build graphs
|
|
296
|
+
await buildGraph(baseDir, { engine, skipRegistry: true });
|
|
297
|
+
await buildGraph(targetDir, { engine, skipRegistry: true });
|
|
298
|
+
|
|
299
|
+
const baseDbPath = path.join(baseDir, '.codegraph', 'graph.db');
|
|
300
|
+
const targetDbPath = path.join(targetDir, '.codegraph', 'graph.db');
|
|
301
|
+
|
|
302
|
+
// Normalize file paths for comparison (relative to worktree root)
|
|
303
|
+
const normalizedFiles = changedFiles.map((f) => f.replace(/\\/g, '/'));
|
|
304
|
+
|
|
305
|
+
// Load symbols from both DBs
|
|
306
|
+
const baseSymbols = loadSymbolsFromDb(baseDbPath, normalizedFiles, noTests);
|
|
307
|
+
const targetSymbols = loadSymbolsFromDb(targetDbPath, normalizedFiles, noTests);
|
|
308
|
+
|
|
309
|
+
// Compare
|
|
310
|
+
const { added, removed, changed } = compareSymbols(baseSymbols, targetSymbols);
|
|
311
|
+
|
|
312
|
+
// BFS for transitive callers of removed/changed symbols in base graph
|
|
313
|
+
const removedIds = removed.map((s) => s.id).filter(Boolean);
|
|
314
|
+
const changedIds = changed
|
|
315
|
+
.map((s) => {
|
|
316
|
+
const baseSym = baseSymbols.get(makeSymbolKey(s.kind, s.file, s.name));
|
|
317
|
+
return baseSym?.id;
|
|
318
|
+
})
|
|
319
|
+
.filter(Boolean);
|
|
320
|
+
|
|
321
|
+
const removedImpact = loadCallersFromDb(baseDbPath, removedIds, maxDepth, noTests);
|
|
322
|
+
const changedImpact = loadCallersFromDb(baseDbPath, changedIds, maxDepth, noTests);
|
|
323
|
+
|
|
324
|
+
// Attach impact to removed/changed
|
|
325
|
+
for (const sym of removed) {
|
|
326
|
+
const symCallers = loadCallersFromDb(baseDbPath, sym.id ? [sym.id] : [], maxDepth, noTests);
|
|
327
|
+
sym.impact = symCallers;
|
|
328
|
+
}
|
|
329
|
+
for (const sym of changed) {
|
|
330
|
+
const baseSym = baseSymbols.get(makeSymbolKey(sym.kind, sym.file, sym.name));
|
|
331
|
+
const symCallers = loadCallersFromDb(
|
|
332
|
+
baseDbPath,
|
|
333
|
+
baseSym?.id ? [baseSym.id] : [],
|
|
334
|
+
maxDepth,
|
|
335
|
+
noTests,
|
|
336
|
+
);
|
|
337
|
+
sym.impact = symCallers;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Summary
|
|
341
|
+
const allImpacted = new Set();
|
|
342
|
+
for (const c of removedImpact) allImpacted.add(`${c.file}:${c.name}`);
|
|
343
|
+
for (const c of changedImpact) allImpacted.add(`${c.file}:${c.name}`);
|
|
344
|
+
|
|
345
|
+
const impactedFiles = new Set();
|
|
346
|
+
for (const key of allImpacted) impactedFiles.add(key.split(':')[0]);
|
|
347
|
+
|
|
348
|
+
// Remove id fields from output (internal only)
|
|
349
|
+
const cleanAdded = added.map(({ id, ...rest }) => rest);
|
|
350
|
+
const cleanRemoved = removed.map(({ id, ...rest }) => rest);
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
baseRef,
|
|
354
|
+
targetRef,
|
|
355
|
+
baseSha,
|
|
356
|
+
targetSha,
|
|
357
|
+
changedFiles: normalizedFiles,
|
|
358
|
+
added: cleanAdded,
|
|
359
|
+
removed: cleanRemoved,
|
|
360
|
+
changed,
|
|
361
|
+
summary: {
|
|
362
|
+
added: added.length,
|
|
363
|
+
removed: removed.length,
|
|
364
|
+
changed: changed.length,
|
|
365
|
+
totalImpacted: allImpacted.size,
|
|
366
|
+
filesAffected: impactedFiles.size,
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
} catch (err) {
|
|
370
|
+
return { error: err.message };
|
|
371
|
+
} finally {
|
|
372
|
+
// Clean up worktrees
|
|
373
|
+
removeWorktree(repoRoot, baseDir);
|
|
374
|
+
removeWorktree(repoRoot, targetDir);
|
|
375
|
+
try {
|
|
376
|
+
fs.rmSync(tmpBase, { recursive: true, force: true });
|
|
377
|
+
} catch {
|
|
378
|
+
/* best-effort */
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── Mermaid Output ─────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
export function branchCompareMermaid(data) {
|
|
386
|
+
if (data.error) return data.error;
|
|
387
|
+
if (data.added.length === 0 && data.removed.length === 0 && data.changed.length === 0) {
|
|
388
|
+
return 'flowchart TB\n none["No structural differences detected"]';
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const lines = ['flowchart TB'];
|
|
392
|
+
let nodeCounter = 0;
|
|
393
|
+
const nodeIdMap = new Map();
|
|
394
|
+
|
|
395
|
+
function nodeId(key) {
|
|
396
|
+
if (!nodeIdMap.has(key)) {
|
|
397
|
+
nodeIdMap.set(key, `n${nodeCounter++}`);
|
|
398
|
+
}
|
|
399
|
+
return nodeIdMap.get(key);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Added subgraph (green)
|
|
403
|
+
if (data.added.length > 0) {
|
|
404
|
+
lines.push(' subgraph sg_added["Added"]');
|
|
405
|
+
for (const sym of data.added) {
|
|
406
|
+
const key = `added::${sym.kind}::${sym.file}::${sym.name}`;
|
|
407
|
+
const nid = nodeId(key, sym.name);
|
|
408
|
+
lines.push(` ${nid}["[${kindIcon(sym.kind)}] ${sym.name}"]`);
|
|
409
|
+
}
|
|
410
|
+
lines.push(' end');
|
|
411
|
+
lines.push(' style sg_added fill:#e8f5e9,stroke:#4caf50');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Removed subgraph (red)
|
|
415
|
+
if (data.removed.length > 0) {
|
|
416
|
+
lines.push(' subgraph sg_removed["Removed"]');
|
|
417
|
+
for (const sym of data.removed) {
|
|
418
|
+
const key = `removed::${sym.kind}::${sym.file}::${sym.name}`;
|
|
419
|
+
const nid = nodeId(key, sym.name);
|
|
420
|
+
lines.push(` ${nid}["[${kindIcon(sym.kind)}] ${sym.name}"]`);
|
|
421
|
+
}
|
|
422
|
+
lines.push(' end');
|
|
423
|
+
lines.push(' style sg_removed fill:#ffebee,stroke:#f44336');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Changed subgraph (orange)
|
|
427
|
+
if (data.changed.length > 0) {
|
|
428
|
+
lines.push(' subgraph sg_changed["Changed"]');
|
|
429
|
+
for (const sym of data.changed) {
|
|
430
|
+
const key = `changed::${sym.kind}::${sym.file}::${sym.name}`;
|
|
431
|
+
const nid = nodeId(key, sym.name);
|
|
432
|
+
lines.push(` ${nid}["[${kindIcon(sym.kind)}] ${sym.name}"]`);
|
|
433
|
+
}
|
|
434
|
+
lines.push(' end');
|
|
435
|
+
lines.push(' style sg_changed fill:#fff3e0,stroke:#ff9800');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Impacted callers subgraph (purple)
|
|
439
|
+
const allImpacted = new Map();
|
|
440
|
+
for (const sym of [...data.removed, ...data.changed]) {
|
|
441
|
+
if (!sym.impact) continue;
|
|
442
|
+
for (const c of sym.impact) {
|
|
443
|
+
const key = `impact::${c.kind}::${c.file}::${c.name}`;
|
|
444
|
+
if (!allImpacted.has(key)) allImpacted.set(key, c);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (allImpacted.size > 0) {
|
|
449
|
+
lines.push(' subgraph sg_impact["Impacted Callers"]');
|
|
450
|
+
for (const [key, c] of allImpacted) {
|
|
451
|
+
const nid = nodeId(key, c.name);
|
|
452
|
+
lines.push(` ${nid}["[${kindIcon(c.kind)}] ${c.name}"]`);
|
|
453
|
+
}
|
|
454
|
+
lines.push(' end');
|
|
455
|
+
lines.push(' style sg_impact fill:#f3e5f5,stroke:#9c27b0');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Edges: removed/changed -> impacted callers
|
|
459
|
+
for (const sym of [...data.removed, ...data.changed]) {
|
|
460
|
+
if (!sym.impact) continue;
|
|
461
|
+
const prefix = data.removed.includes(sym) ? 'removed' : 'changed';
|
|
462
|
+
const symKey = `${prefix}::${sym.kind}::${sym.file}::${sym.name}`;
|
|
463
|
+
for (const c of sym.impact) {
|
|
464
|
+
const callerKey = `impact::${c.kind}::${c.file}::${c.name}`;
|
|
465
|
+
if (nodeIdMap.has(symKey) && nodeIdMap.has(callerKey)) {
|
|
466
|
+
lines.push(` ${nodeIdMap.get(symKey)} -.-> ${nodeIdMap.get(callerKey)}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return lines.join('\n');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ─── Text Formatting ────────────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
function formatText(data) {
|
|
477
|
+
if (data.error) return `Error: ${data.error}`;
|
|
478
|
+
|
|
479
|
+
const lines = [];
|
|
480
|
+
const shortBase = data.baseSha.slice(0, 7);
|
|
481
|
+
const shortTarget = data.targetSha.slice(0, 7);
|
|
482
|
+
|
|
483
|
+
lines.push(`branch-compare: ${data.baseRef}..${data.targetRef}`);
|
|
484
|
+
lines.push(` Base: ${data.baseRef} (${shortBase})`);
|
|
485
|
+
lines.push(` Target: ${data.targetRef} (${shortTarget})`);
|
|
486
|
+
lines.push(` Files changed: ${data.changedFiles.length}`);
|
|
487
|
+
|
|
488
|
+
if (data.added.length > 0) {
|
|
489
|
+
lines.push('');
|
|
490
|
+
lines.push(` + Added (${data.added.length} symbol${data.added.length !== 1 ? 's' : ''}):`);
|
|
491
|
+
for (const sym of data.added) {
|
|
492
|
+
lines.push(` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.line}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (data.removed.length > 0) {
|
|
497
|
+
lines.push('');
|
|
498
|
+
lines.push(
|
|
499
|
+
` - Removed (${data.removed.length} symbol${data.removed.length !== 1 ? 's' : ''}):`,
|
|
500
|
+
);
|
|
501
|
+
for (const sym of data.removed) {
|
|
502
|
+
lines.push(` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.line}`);
|
|
503
|
+
if (sym.impact && sym.impact.length > 0) {
|
|
504
|
+
lines.push(
|
|
505
|
+
` ^ ${sym.impact.length} transitive caller${sym.impact.length !== 1 ? 's' : ''} affected`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (data.changed.length > 0) {
|
|
512
|
+
lines.push('');
|
|
513
|
+
lines.push(
|
|
514
|
+
` ~ Changed (${data.changed.length} symbol${data.changed.length !== 1 ? 's' : ''}):`,
|
|
515
|
+
);
|
|
516
|
+
for (const sym of data.changed) {
|
|
517
|
+
const parts = [];
|
|
518
|
+
if (sym.changes.lineCount !== 0) {
|
|
519
|
+
parts.push(`lines: ${sym.base.lineCount} -> ${sym.target.lineCount}`);
|
|
520
|
+
}
|
|
521
|
+
if (sym.changes.fanIn !== 0) {
|
|
522
|
+
parts.push(`fan_in: ${sym.base.fanIn} -> ${sym.target.fanIn}`);
|
|
523
|
+
}
|
|
524
|
+
if (sym.changes.fanOut !== 0) {
|
|
525
|
+
parts.push(`fan_out: ${sym.base.fanOut} -> ${sym.target.fanOut}`);
|
|
526
|
+
}
|
|
527
|
+
const detail = parts.length > 0 ? ` (${parts.join(', ')})` : '';
|
|
528
|
+
lines.push(
|
|
529
|
+
` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.base.line}${detail}`,
|
|
530
|
+
);
|
|
531
|
+
if (sym.impact && sym.impact.length > 0) {
|
|
532
|
+
lines.push(
|
|
533
|
+
` ^ ${sym.impact.length} transitive caller${sym.impact.length !== 1 ? 's' : ''} affected`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const s = data.summary;
|
|
540
|
+
lines.push('');
|
|
541
|
+
lines.push(
|
|
542
|
+
` Summary: +${s.added} added, -${s.removed} removed, ~${s.changed} changed` +
|
|
543
|
+
` -> ${s.totalImpacted} caller${s.totalImpacted !== 1 ? 's' : ''} impacted` +
|
|
544
|
+
(s.filesAffected > 0
|
|
545
|
+
? ` across ${s.filesAffected} file${s.filesAffected !== 1 ? 's' : ''}`
|
|
546
|
+
: ''),
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
return lines.join('\n');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ─── CLI Display Function ───────────────────────────────────────────────
|
|
553
|
+
|
|
554
|
+
export async function branchCompare(baseRef, targetRef, opts = {}) {
|
|
555
|
+
const data = await branchCompareData(baseRef, targetRef, opts);
|
|
556
|
+
|
|
557
|
+
if (opts.json || opts.format === 'json') {
|
|
558
|
+
console.log(JSON.stringify(data, null, 2));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (opts.format === 'mermaid') {
|
|
563
|
+
console.log(branchCompareMermaid(data));
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
console.log(formatText(data));
|
|
568
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -468,6 +468,7 @@ registry
|
|
|
468
468
|
.description('Remove stale registry entries (missing directories or idle beyond TTL)')
|
|
469
469
|
.option('--ttl <days>', 'Days of inactivity before pruning (default: 30)', '30')
|
|
470
470
|
.option('--exclude <names>', 'Comma-separated repo names to preserve from pruning')
|
|
471
|
+
.option('--dry-run', 'Show what would be pruned without removing anything')
|
|
471
472
|
.action((opts) => {
|
|
472
473
|
const excludeNames = opts.exclude
|
|
473
474
|
? opts.exclude
|
|
@@ -475,15 +476,25 @@ registry
|
|
|
475
476
|
.map((s) => s.trim())
|
|
476
477
|
.filter((s) => s.length > 0)
|
|
477
478
|
: [];
|
|
478
|
-
const
|
|
479
|
+
const dryRun = !!opts.dryRun;
|
|
480
|
+
const pruned = pruneRegistry(undefined, parseInt(opts.ttl, 10), excludeNames, dryRun);
|
|
479
481
|
if (pruned.length === 0) {
|
|
480
482
|
console.log('No stale entries found.');
|
|
481
483
|
} else {
|
|
484
|
+
const prefix = dryRun ? 'Would prune' : 'Pruned';
|
|
482
485
|
for (const entry of pruned) {
|
|
483
486
|
const tag = entry.reason === 'expired' ? 'expired' : 'missing';
|
|
484
|
-
console.log(
|
|
487
|
+
console.log(`${prefix} "${entry.name}" (${entry.path}) [${tag}]`);
|
|
488
|
+
}
|
|
489
|
+
if (dryRun) {
|
|
490
|
+
console.log(
|
|
491
|
+
`\nDry run: ${pruned.length} ${pruned.length === 1 ? 'entry' : 'entries'} would be removed.`,
|
|
492
|
+
);
|
|
493
|
+
} else {
|
|
494
|
+
console.log(
|
|
495
|
+
`\nRemoved ${pruned.length} stale ${pruned.length === 1 ? 'entry' : 'entries'}.`,
|
|
496
|
+
);
|
|
485
497
|
}
|
|
486
|
-
console.log(`\nRemoved ${pruned.length} stale ${pruned.length === 1 ? 'entry' : 'entries'}.`);
|
|
487
498
|
}
|
|
488
499
|
});
|
|
489
500
|
|
package/src/registry.js
CHANGED
|
@@ -135,11 +135,14 @@ export function resolveRepoDbPath(name, registryPath = REGISTRY_PATH) {
|
|
|
135
135
|
* Remove registry entries whose repo directory no longer exists on disk,
|
|
136
136
|
* or that haven't been accessed within `ttlDays` days.
|
|
137
137
|
* Returns an array of `{ name, path, reason }` for each pruned entry.
|
|
138
|
+
*
|
|
139
|
+
* When `dryRun` is true, entries are identified but not removed from disk.
|
|
138
140
|
*/
|
|
139
141
|
export function pruneRegistry(
|
|
140
142
|
registryPath = REGISTRY_PATH,
|
|
141
143
|
ttlDays = DEFAULT_TTL_DAYS,
|
|
142
144
|
excludeNames = [],
|
|
145
|
+
dryRun = false,
|
|
143
146
|
) {
|
|
144
147
|
const registry = loadRegistry(registryPath);
|
|
145
148
|
const pruned = [];
|
|
@@ -152,17 +155,17 @@ export function pruneRegistry(
|
|
|
152
155
|
if (excludeSet.has(name)) continue;
|
|
153
156
|
if (!fs.existsSync(entry.path)) {
|
|
154
157
|
pruned.push({ name, path: entry.path, reason: 'missing' });
|
|
155
|
-
delete registry.repos[name];
|
|
158
|
+
if (!dryRun) delete registry.repos[name];
|
|
156
159
|
continue;
|
|
157
160
|
}
|
|
158
161
|
const lastAccess = Date.parse(entry.lastAccessedAt || entry.addedAt);
|
|
159
162
|
if (lastAccess < cutoff) {
|
|
160
163
|
pruned.push({ name, path: entry.path, reason: 'expired' });
|
|
161
|
-
delete registry.repos[name];
|
|
164
|
+
if (!dryRun) delete registry.repos[name];
|
|
162
165
|
}
|
|
163
166
|
}
|
|
164
167
|
|
|
165
|
-
if (pruned.length > 0) {
|
|
168
|
+
if (!dryRun && pruned.length > 0) {
|
|
166
169
|
saveRegistry(registry, registryPath);
|
|
167
170
|
}
|
|
168
171
|
|