@optave/codegraph 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -47
- 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/branch-compare.js +568 -0
- package/src/builder.js +66 -2
- package/src/check.js +432 -0
- package/src/cli.js +375 -9
- 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/registry.js +6 -3
- package/src/snapshot.js +149 -0
- package/src/structure.js +5 -2
- package/src/triage.js +273 -0
|
@@ -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/builder.js
CHANGED
|
@@ -435,7 +435,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
435
435
|
|
|
436
436
|
if (isFullBuild) {
|
|
437
437
|
const deletions =
|
|
438
|
-
'PRAGMA foreign_keys = OFF; DELETE FROM node_metrics; DELETE FROM edges; DELETE FROM nodes; PRAGMA foreign_keys = ON;';
|
|
438
|
+
'PRAGMA foreign_keys = OFF; DELETE FROM node_metrics; DELETE FROM edges; DELETE FROM function_complexity; DELETE FROM nodes; PRAGMA foreign_keys = ON;';
|
|
439
439
|
db.exec(
|
|
440
440
|
hasEmbeddings
|
|
441
441
|
? `${deletions.replace('PRAGMA foreign_keys = ON;', '')} DELETE FROM embeddings; PRAGMA foreign_keys = ON;`
|
|
@@ -460,7 +460,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
460
460
|
SELECT DISTINCT n_src.file FROM edges e
|
|
461
461
|
JOIN nodes n_src ON e.source_id = n_src.id
|
|
462
462
|
JOIN nodes n_tgt ON e.target_id = n_tgt.id
|
|
463
|
-
WHERE n_tgt.file = ? AND n_src.file != n_tgt.file
|
|
463
|
+
WHERE n_tgt.file = ? AND n_src.file != n_tgt.file AND n_src.kind != 'directory'
|
|
464
464
|
`);
|
|
465
465
|
for (const relPath of changedRelPaths) {
|
|
466
466
|
for (const row of findReverseDeps.all(relPath)) {
|
|
@@ -687,6 +687,46 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
687
687
|
}
|
|
688
688
|
}
|
|
689
689
|
|
|
690
|
+
// For incremental builds, load unchanged barrel files into reexportMap
|
|
691
|
+
// so barrel-resolved import/call edges aren't dropped for reverse-dep files.
|
|
692
|
+
// These files are loaded only for resolution — they must NOT be iterated
|
|
693
|
+
// in the edge-building loop (their existing edges are still in the DB).
|
|
694
|
+
const barrelOnlyFiles = new Set();
|
|
695
|
+
if (!isFullBuild) {
|
|
696
|
+
const barrelCandidates = db
|
|
697
|
+
.prepare(
|
|
698
|
+
`SELECT DISTINCT n1.file FROM edges e
|
|
699
|
+
JOIN nodes n1 ON e.source_id = n1.id
|
|
700
|
+
WHERE e.kind = 'reexports' AND n1.kind = 'file'`,
|
|
701
|
+
)
|
|
702
|
+
.all();
|
|
703
|
+
for (const { file: relPath } of barrelCandidates) {
|
|
704
|
+
if (fileSymbols.has(relPath)) continue;
|
|
705
|
+
const absPath = path.join(rootDir, relPath);
|
|
706
|
+
try {
|
|
707
|
+
const symbols = await parseFilesAuto([absPath], rootDir, engineOpts);
|
|
708
|
+
const fileSym = symbols.get(relPath);
|
|
709
|
+
if (fileSym) {
|
|
710
|
+
fileSymbols.set(relPath, fileSym);
|
|
711
|
+
barrelOnlyFiles.add(relPath);
|
|
712
|
+
const reexports = fileSym.imports.filter((imp) => imp.reexport);
|
|
713
|
+
if (reexports.length > 0) {
|
|
714
|
+
reexportMap.set(
|
|
715
|
+
relPath,
|
|
716
|
+
reexports.map((imp) => ({
|
|
717
|
+
source: getResolved(absPath, imp.source),
|
|
718
|
+
names: imp.names,
|
|
719
|
+
wildcardReexport: imp.wildcardReexport || false,
|
|
720
|
+
})),
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
} catch {
|
|
725
|
+
/* skip if unreadable */
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
690
730
|
function isBarrelFile(relPath) {
|
|
691
731
|
const symbols = fileSymbols.get(relPath);
|
|
692
732
|
if (!symbols) return false;
|
|
@@ -752,6 +792,8 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
752
792
|
let edgeCount = 0;
|
|
753
793
|
const buildEdges = db.transaction(() => {
|
|
754
794
|
for (const [relPath, symbols] of fileSymbols) {
|
|
795
|
+
// Skip barrel-only files — loaded for resolution, edges already in DB
|
|
796
|
+
if (barrelOnlyFiles.has(relPath)) continue;
|
|
755
797
|
const fileNodeRow = getNodeId.get(relPath, 'file', relPath, 0);
|
|
756
798
|
if (!fileNodeRow) continue;
|
|
757
799
|
const fileNodeId = fileNodeRow.id;
|
|
@@ -1046,6 +1088,26 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1046
1088
|
info(`Graph built: ${nodeCount} nodes, ${edgeCount} edges`);
|
|
1047
1089
|
info(`Stored in ${dbPath}`);
|
|
1048
1090
|
|
|
1091
|
+
// Verify incremental build didn't diverge significantly from previous counts
|
|
1092
|
+
if (!isFullBuild) {
|
|
1093
|
+
const prevNodes = getBuildMeta(db, 'node_count');
|
|
1094
|
+
const prevEdges = getBuildMeta(db, 'edge_count');
|
|
1095
|
+
if (prevNodes && prevEdges) {
|
|
1096
|
+
const prevN = Number(prevNodes);
|
|
1097
|
+
const prevE = Number(prevEdges);
|
|
1098
|
+
if (prevN > 0) {
|
|
1099
|
+
const nodeDrift = Math.abs(nodeCount - prevN) / prevN;
|
|
1100
|
+
const edgeDrift = prevE > 0 ? Math.abs(edgeCount - prevE) / prevE : 0;
|
|
1101
|
+
const driftThreshold = config.build?.driftThreshold ?? 0.2;
|
|
1102
|
+
if (nodeDrift > driftThreshold || edgeDrift > driftThreshold) {
|
|
1103
|
+
warn(
|
|
1104
|
+
`Incremental build diverged significantly from previous counts (nodes: ${prevN}→${nodeCount} [${(nodeDrift * 100).toFixed(1)}%], edges: ${prevE}→${edgeCount} [${(edgeDrift * 100).toFixed(1)}%], threshold: ${(driftThreshold * 100).toFixed(0)}%). Consider rebuilding with --no-incremental.`,
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1049
1111
|
// Warn about orphaned embeddings that no longer match any node
|
|
1050
1112
|
if (hasEmbeddings) {
|
|
1051
1113
|
try {
|
|
@@ -1069,6 +1131,8 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1069
1131
|
engine_version: engineVersion || '',
|
|
1070
1132
|
codegraph_version: CODEGRAPH_VERSION,
|
|
1071
1133
|
built_at: new Date().toISOString(),
|
|
1134
|
+
node_count: nodeCount,
|
|
1135
|
+
edge_count: edgeCount,
|
|
1072
1136
|
});
|
|
1073
1137
|
} catch (err) {
|
|
1074
1138
|
warn(`Failed to write build metadata: ${err.message}`);
|