@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
package/src/check.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { findCycles } from './cycles.js';
|
|
6
|
+
import { findDbPath, openReadonlyOrFail } from './db.js';
|
|
7
|
+
import { matchOwners, parseCodeowners } from './owners.js';
|
|
8
|
+
import { isTestFile } from './queries.js';
|
|
9
|
+
|
|
10
|
+
// ─── Diff Parser ──────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse unified diff output, extracting both new-side (+) and old-side (-) ranges.
|
|
14
|
+
* Old-side ranges are needed for signature detection (DB line numbers = pre-change).
|
|
15
|
+
*
|
|
16
|
+
* @param {string} diffOutput - Raw `git diff --unified=0` output
|
|
17
|
+
* @returns {{ changedRanges: Map<string, {start:number,end:number}[]>, oldRanges: Map<string, {start:number,end:number}[]>, newFiles: Set<string> }}
|
|
18
|
+
*/
|
|
19
|
+
export function parseDiffOutput(diffOutput) {
|
|
20
|
+
const changedRanges = new Map();
|
|
21
|
+
const oldRanges = new Map();
|
|
22
|
+
const newFiles = new Set();
|
|
23
|
+
let currentFile = null;
|
|
24
|
+
let prevIsDevNull = false;
|
|
25
|
+
|
|
26
|
+
for (const line of diffOutput.split('\n')) {
|
|
27
|
+
if (line.startsWith('--- /dev/null')) {
|
|
28
|
+
prevIsDevNull = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (line.startsWith('--- ')) {
|
|
32
|
+
prevIsDevNull = false;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const fileMatch = line.match(/^\+\+\+ b\/(.+)/);
|
|
36
|
+
if (fileMatch) {
|
|
37
|
+
currentFile = fileMatch[1];
|
|
38
|
+
if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []);
|
|
39
|
+
if (!oldRanges.has(currentFile)) oldRanges.set(currentFile, []);
|
|
40
|
+
if (prevIsDevNull) newFiles.add(currentFile);
|
|
41
|
+
prevIsDevNull = false;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
45
|
+
if (hunkMatch && currentFile) {
|
|
46
|
+
const oldStart = parseInt(hunkMatch[1], 10);
|
|
47
|
+
const oldCount = parseInt(hunkMatch[2] || '1', 10);
|
|
48
|
+
if (oldCount > 0) {
|
|
49
|
+
oldRanges.get(currentFile).push({ start: oldStart, end: oldStart + oldCount - 1 });
|
|
50
|
+
}
|
|
51
|
+
const newStart = parseInt(hunkMatch[3], 10);
|
|
52
|
+
const newCount = parseInt(hunkMatch[4] || '1', 10);
|
|
53
|
+
if (newCount > 0) {
|
|
54
|
+
changedRanges.get(currentFile).push({ start: newStart, end: newStart + newCount - 1 });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { changedRanges, oldRanges, newFiles };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Predicates ───────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Predicate 1: Assert no dependency cycles involve changed files.
|
|
65
|
+
*/
|
|
66
|
+
export function checkNoNewCycles(db, changedFiles, noTests) {
|
|
67
|
+
const cycles = findCycles(db, { fileLevel: true, noTests });
|
|
68
|
+
const involved = cycles.filter((cycle) => cycle.some((f) => changedFiles.has(f)));
|
|
69
|
+
return { passed: involved.length === 0, cycles: involved };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Predicate 2: Assert no function exceeds N transitive callers.
|
|
74
|
+
*/
|
|
75
|
+
export function checkMaxBlastRadius(db, changedRanges, threshold, noTests, maxDepth) {
|
|
76
|
+
const violations = [];
|
|
77
|
+
let maxFound = 0;
|
|
78
|
+
|
|
79
|
+
for (const [file, ranges] of changedRanges) {
|
|
80
|
+
if (noTests && isTestFile(file)) continue;
|
|
81
|
+
const defs = db
|
|
82
|
+
.prepare(
|
|
83
|
+
`SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`,
|
|
84
|
+
)
|
|
85
|
+
.all(file);
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < defs.length; i++) {
|
|
88
|
+
const def = defs[i];
|
|
89
|
+
const endLine = def.end_line || (defs[i + 1] ? defs[i + 1].line - 1 : 999999);
|
|
90
|
+
let overlaps = false;
|
|
91
|
+
for (const range of ranges) {
|
|
92
|
+
if (range.start <= endLine && range.end >= def.line) {
|
|
93
|
+
overlaps = true;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!overlaps) continue;
|
|
98
|
+
|
|
99
|
+
// BFS transitive callers
|
|
100
|
+
const visited = new Set([def.id]);
|
|
101
|
+
let frontier = [def.id];
|
|
102
|
+
let totalCallers = 0;
|
|
103
|
+
for (let d = 1; d <= maxDepth; d++) {
|
|
104
|
+
const nextFrontier = [];
|
|
105
|
+
for (const fid of frontier) {
|
|
106
|
+
const callers = db
|
|
107
|
+
.prepare(
|
|
108
|
+
`SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line
|
|
109
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
110
|
+
WHERE e.target_id = ? AND e.kind = 'calls'`,
|
|
111
|
+
)
|
|
112
|
+
.all(fid);
|
|
113
|
+
for (const c of callers) {
|
|
114
|
+
if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
|
|
115
|
+
visited.add(c.id);
|
|
116
|
+
nextFrontier.push(c.id);
|
|
117
|
+
totalCallers++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
frontier = nextFrontier;
|
|
122
|
+
if (frontier.length === 0) break;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (totalCallers > maxFound) maxFound = totalCallers;
|
|
126
|
+
if (totalCallers > threshold) {
|
|
127
|
+
violations.push({
|
|
128
|
+
name: def.name,
|
|
129
|
+
kind: def.kind,
|
|
130
|
+
file: def.file,
|
|
131
|
+
line: def.line,
|
|
132
|
+
transitiveCallers: totalCallers,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { passed: violations.length === 0, maxFound, threshold, violations };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Predicate 3: Assert no function declaration lines were modified.
|
|
143
|
+
* Uses old-side hunk ranges (which correspond to DB line numbers from last build).
|
|
144
|
+
*/
|
|
145
|
+
export function checkNoSignatureChanges(db, oldRanges, noTests) {
|
|
146
|
+
const violations = [];
|
|
147
|
+
|
|
148
|
+
for (const [file, ranges] of oldRanges) {
|
|
149
|
+
if (ranges.length === 0) continue;
|
|
150
|
+
if (noTests && isTestFile(file)) continue;
|
|
151
|
+
|
|
152
|
+
const defs = db
|
|
153
|
+
.prepare(
|
|
154
|
+
`SELECT name, kind, file, line FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`,
|
|
155
|
+
)
|
|
156
|
+
.all(file);
|
|
157
|
+
|
|
158
|
+
for (const def of defs) {
|
|
159
|
+
for (const range of ranges) {
|
|
160
|
+
if (def.line >= range.start && def.line <= range.end) {
|
|
161
|
+
violations.push({
|
|
162
|
+
name: def.name,
|
|
163
|
+
kind: def.kind,
|
|
164
|
+
file: def.file,
|
|
165
|
+
line: def.line,
|
|
166
|
+
});
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { passed: violations.length === 0, violations };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Predicate 4: Assert no cross-owner boundary violations among changed files.
|
|
178
|
+
*/
|
|
179
|
+
export function checkNoBoundaryViolations(db, changedFiles, repoRoot, noTests) {
|
|
180
|
+
const parsed = parseCodeowners(repoRoot);
|
|
181
|
+
if (!parsed) {
|
|
182
|
+
return { passed: true, violations: [], note: 'No CODEOWNERS file found — skipped' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const changedSet = changedFiles instanceof Set ? changedFiles : new Set(changedFiles);
|
|
186
|
+
const edges = db
|
|
187
|
+
.prepare(
|
|
188
|
+
`SELECT e.kind AS edgeKind,
|
|
189
|
+
s.file AS srcFile, t.file AS tgtFile
|
|
190
|
+
FROM edges e
|
|
191
|
+
JOIN nodes s ON e.source_id = s.id
|
|
192
|
+
JOIN nodes t ON e.target_id = t.id
|
|
193
|
+
WHERE e.kind = 'calls'`,
|
|
194
|
+
)
|
|
195
|
+
.all();
|
|
196
|
+
|
|
197
|
+
const violations = [];
|
|
198
|
+
for (const e of edges) {
|
|
199
|
+
if (noTests && (isTestFile(e.srcFile) || isTestFile(e.tgtFile))) continue;
|
|
200
|
+
if (!changedSet.has(e.srcFile) && !changedSet.has(e.tgtFile)) continue;
|
|
201
|
+
|
|
202
|
+
const srcOwners = matchOwners(e.srcFile, parsed.rules).sort().join(',');
|
|
203
|
+
const tgtOwners = matchOwners(e.tgtFile, parsed.rules).sort().join(',');
|
|
204
|
+
if (srcOwners !== tgtOwners) {
|
|
205
|
+
violations.push({
|
|
206
|
+
from: e.srcFile,
|
|
207
|
+
to: e.tgtFile,
|
|
208
|
+
edgeKind: e.edgeKind,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { passed: violations.length === 0, violations };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Main ─────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Run validation predicates against git changes.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} [customDbPath] - Path to graph.db
|
|
222
|
+
* @param {object} opts
|
|
223
|
+
* @param {string} [opts.ref] - Git ref to diff against
|
|
224
|
+
* @param {boolean} [opts.staged] - Analyze staged changes
|
|
225
|
+
* @param {boolean} [opts.cycles] - Enable cycles predicate
|
|
226
|
+
* @param {number} [opts.blastRadius] - Blast radius threshold
|
|
227
|
+
* @param {boolean} [opts.signatures] - Enable signatures predicate
|
|
228
|
+
* @param {boolean} [opts.boundaries] - Enable boundaries predicate
|
|
229
|
+
* @param {number} [opts.depth] - Max BFS depth (default: 3)
|
|
230
|
+
* @param {boolean} [opts.noTests] - Exclude test files
|
|
231
|
+
* @returns {{ predicates: object[], summary: object, passed: boolean }}
|
|
232
|
+
*/
|
|
233
|
+
export function checkData(customDbPath, opts = {}) {
|
|
234
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const dbPath = findDbPath(customDbPath);
|
|
238
|
+
const repoRoot = path.resolve(path.dirname(dbPath), '..');
|
|
239
|
+
const noTests = opts.noTests || false;
|
|
240
|
+
const maxDepth = opts.depth || 3;
|
|
241
|
+
|
|
242
|
+
// Load config defaults for check predicates
|
|
243
|
+
const config = loadConfig(repoRoot);
|
|
244
|
+
const checkConfig = config.check || {};
|
|
245
|
+
|
|
246
|
+
// Resolve which predicates are enabled: CLI flags ?? config ?? built-in defaults
|
|
247
|
+
const enableCycles = opts.cycles ?? checkConfig.cycles ?? true;
|
|
248
|
+
const enableSignatures = opts.signatures ?? checkConfig.signatures ?? true;
|
|
249
|
+
const enableBoundaries = opts.boundaries ?? checkConfig.boundaries ?? true;
|
|
250
|
+
const blastRadiusThreshold = opts.blastRadius ?? checkConfig.blastRadius ?? null;
|
|
251
|
+
|
|
252
|
+
// Verify git repo
|
|
253
|
+
let checkDir = repoRoot;
|
|
254
|
+
let isGitRepo = false;
|
|
255
|
+
while (checkDir) {
|
|
256
|
+
if (fs.existsSync(path.join(checkDir, '.git'))) {
|
|
257
|
+
isGitRepo = true;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
const parent = path.dirname(checkDir);
|
|
261
|
+
if (parent === checkDir) break;
|
|
262
|
+
checkDir = parent;
|
|
263
|
+
}
|
|
264
|
+
if (!isGitRepo) {
|
|
265
|
+
return { error: `Not a git repository: ${repoRoot}` };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Run git diff
|
|
269
|
+
let diffOutput;
|
|
270
|
+
try {
|
|
271
|
+
const args = opts.staged
|
|
272
|
+
? ['diff', '--cached', '--unified=0', '--no-color']
|
|
273
|
+
: ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color'];
|
|
274
|
+
diffOutput = execFileSync('git', args, {
|
|
275
|
+
cwd: repoRoot,
|
|
276
|
+
encoding: 'utf-8',
|
|
277
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
278
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
279
|
+
});
|
|
280
|
+
} catch (e) {
|
|
281
|
+
return { error: `Failed to run git diff: ${e.message}` };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!diffOutput.trim()) {
|
|
285
|
+
return {
|
|
286
|
+
predicates: [],
|
|
287
|
+
summary: { total: 0, passed: 0, failed: 0, changedFiles: 0, newFiles: 0 },
|
|
288
|
+
passed: true,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const { changedRanges, oldRanges, newFiles } = parseDiffOutput(diffOutput);
|
|
293
|
+
if (changedRanges.size === 0) {
|
|
294
|
+
return {
|
|
295
|
+
predicates: [],
|
|
296
|
+
summary: { total: 0, passed: 0, failed: 0, changedFiles: 0, newFiles: 0 },
|
|
297
|
+
passed: true,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const changedFiles = new Set(changedRanges.keys());
|
|
302
|
+
|
|
303
|
+
// Execute enabled predicates
|
|
304
|
+
const predicates = [];
|
|
305
|
+
|
|
306
|
+
if (enableCycles) {
|
|
307
|
+
const result = checkNoNewCycles(db, changedFiles, noTests);
|
|
308
|
+
predicates.push({ name: 'cycles', ...result });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (blastRadiusThreshold != null) {
|
|
312
|
+
const result = checkMaxBlastRadius(
|
|
313
|
+
db,
|
|
314
|
+
changedRanges,
|
|
315
|
+
blastRadiusThreshold,
|
|
316
|
+
noTests,
|
|
317
|
+
maxDepth,
|
|
318
|
+
);
|
|
319
|
+
predicates.push({ name: 'blast-radius', ...result });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (enableSignatures) {
|
|
323
|
+
const result = checkNoSignatureChanges(db, oldRanges, noTests);
|
|
324
|
+
predicates.push({ name: 'signatures', ...result });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (enableBoundaries) {
|
|
328
|
+
const result = checkNoBoundaryViolations(db, changedFiles, repoRoot, noTests);
|
|
329
|
+
predicates.push({ name: 'boundaries', ...result });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const passedCount = predicates.filter((p) => p.passed).length;
|
|
333
|
+
const failedCount = predicates.length - passedCount;
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
predicates,
|
|
337
|
+
summary: {
|
|
338
|
+
total: predicates.length,
|
|
339
|
+
passed: passedCount,
|
|
340
|
+
failed: failedCount,
|
|
341
|
+
changedFiles: changedFiles.size,
|
|
342
|
+
newFiles: newFiles.size,
|
|
343
|
+
},
|
|
344
|
+
passed: failedCount === 0,
|
|
345
|
+
};
|
|
346
|
+
} finally {
|
|
347
|
+
db.close();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── CLI Display ──────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* CLI formatter — prints check results and exits with code 1 on failure.
|
|
355
|
+
*/
|
|
356
|
+
export function check(customDbPath, opts = {}) {
|
|
357
|
+
const data = checkData(customDbPath, opts);
|
|
358
|
+
|
|
359
|
+
if (data.error) {
|
|
360
|
+
console.error(data.error);
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (opts.json) {
|
|
365
|
+
console.log(JSON.stringify(data, null, 2));
|
|
366
|
+
if (!data.passed) process.exit(1);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
console.log('\n# Check Results\n');
|
|
371
|
+
|
|
372
|
+
if (data.predicates.length === 0) {
|
|
373
|
+
console.log(' No changes detected.\n');
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
console.log(
|
|
378
|
+
` Changed files: ${data.summary.changedFiles} New files: ${data.summary.newFiles}\n`,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
for (const pred of data.predicates) {
|
|
382
|
+
const icon = pred.passed ? 'PASS' : 'FAIL';
|
|
383
|
+
console.log(` [${icon}] ${pred.name}`);
|
|
384
|
+
|
|
385
|
+
if (!pred.passed) {
|
|
386
|
+
if (pred.name === 'cycles' && pred.cycles) {
|
|
387
|
+
for (const cycle of pred.cycles.slice(0, 10)) {
|
|
388
|
+
console.log(` ${cycle.join(' -> ')}`);
|
|
389
|
+
}
|
|
390
|
+
if (pred.cycles.length > 10) {
|
|
391
|
+
console.log(` ... and ${pred.cycles.length - 10} more`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (pred.name === 'blast-radius' && pred.violations) {
|
|
395
|
+
for (const v of pred.violations.slice(0, 10)) {
|
|
396
|
+
console.log(
|
|
397
|
+
` ${v.name} (${v.kind}) at ${v.file}:${v.line} — ${v.transitiveCallers} callers (max: ${pred.threshold})`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (pred.violations.length > 10) {
|
|
401
|
+
console.log(` ... and ${pred.violations.length - 10} more`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (pred.name === 'signatures' && pred.violations) {
|
|
405
|
+
for (const v of pred.violations.slice(0, 10)) {
|
|
406
|
+
console.log(` ${v.name} (${v.kind}) at ${v.file}:${v.line}`);
|
|
407
|
+
}
|
|
408
|
+
if (pred.violations.length > 10) {
|
|
409
|
+
console.log(` ... and ${pred.violations.length - 10} more`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (pred.name === 'boundaries' && pred.violations) {
|
|
413
|
+
for (const v of pred.violations.slice(0, 10)) {
|
|
414
|
+
console.log(` ${v.from} -> ${v.to} (${v.edgeKind})`);
|
|
415
|
+
}
|
|
416
|
+
if (pred.violations.length > 10) {
|
|
417
|
+
console.log(` ... and ${pred.violations.length - 10} more`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (pred.note) {
|
|
422
|
+
console.log(` ${pred.note}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const s = data.summary;
|
|
427
|
+
console.log(`\n ${s.total} predicates | ${s.passed} passed | ${s.failed} failed\n`);
|
|
428
|
+
|
|
429
|
+
if (!data.passed) {
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
}
|