@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
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { debug } from './logger.js';
|
|
2
|
+
import { isTestFile } from './queries.js';
|
|
3
|
+
|
|
4
|
+
// ─── Glob-to-Regex ───────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Convert a simple glob pattern to a RegExp.
|
|
8
|
+
* Supports `**` (any path segment), `*` (non-slash), `?` (single non-slash char).
|
|
9
|
+
* @param {string} pattern
|
|
10
|
+
* @returns {RegExp}
|
|
11
|
+
*/
|
|
12
|
+
export function globToRegex(pattern) {
|
|
13
|
+
let re = '';
|
|
14
|
+
let i = 0;
|
|
15
|
+
while (i < pattern.length) {
|
|
16
|
+
const ch = pattern[i];
|
|
17
|
+
if (ch === '*' && pattern[i + 1] === '*') {
|
|
18
|
+
// ** matches any number of path segments
|
|
19
|
+
re += '.*';
|
|
20
|
+
i += 2;
|
|
21
|
+
// Skip trailing slash after **
|
|
22
|
+
if (pattern[i] === '/') i++;
|
|
23
|
+
} else if (ch === '*') {
|
|
24
|
+
// * matches non-slash characters
|
|
25
|
+
re += '[^/]*';
|
|
26
|
+
i++;
|
|
27
|
+
} else if (ch === '?') {
|
|
28
|
+
re += '[^/]';
|
|
29
|
+
i++;
|
|
30
|
+
} else if (/[.+^${}()|[\]\\]/.test(ch)) {
|
|
31
|
+
re += `\\${ch}`;
|
|
32
|
+
i++;
|
|
33
|
+
} else {
|
|
34
|
+
re += ch;
|
|
35
|
+
i++;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return new RegExp(`^${re}$`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Presets ─────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Built-in preset definitions.
|
|
45
|
+
* Each defines layers ordered from innermost (most protected) to outermost.
|
|
46
|
+
* Inner layers cannot import from outer layers.
|
|
47
|
+
*/
|
|
48
|
+
export const PRESETS = {
|
|
49
|
+
hexagonal: {
|
|
50
|
+
layers: ['domain', 'application', 'adapters', 'infrastructure'],
|
|
51
|
+
description: 'Inner layers cannot import outer layers',
|
|
52
|
+
},
|
|
53
|
+
layered: {
|
|
54
|
+
layers: ['data', 'business', 'presentation'],
|
|
55
|
+
description: 'Inward-only dependency direction',
|
|
56
|
+
},
|
|
57
|
+
clean: {
|
|
58
|
+
layers: ['entities', 'usecases', 'interfaces', 'frameworks'],
|
|
59
|
+
description: 'Inward-only dependency direction',
|
|
60
|
+
},
|
|
61
|
+
onion: {
|
|
62
|
+
layers: ['domain-model', 'domain-services', 'application', 'infrastructure'],
|
|
63
|
+
description: 'Inward-only dependency direction',
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ─── Module Resolution ───────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse module definitions into a Map of name → { regex, pattern, layer? }.
|
|
71
|
+
* Supports string shorthand and object form.
|
|
72
|
+
* @param {object} boundaryConfig - The `manifesto.boundaries` config object
|
|
73
|
+
* @returns {Map<string, { regex: RegExp, pattern: string, layer?: string }>}
|
|
74
|
+
*/
|
|
75
|
+
export function resolveModules(boundaryConfig) {
|
|
76
|
+
const modules = new Map();
|
|
77
|
+
const defs = boundaryConfig?.modules;
|
|
78
|
+
if (!defs || typeof defs !== 'object') return modules;
|
|
79
|
+
|
|
80
|
+
for (const [name, value] of Object.entries(defs)) {
|
|
81
|
+
if (typeof value === 'string') {
|
|
82
|
+
modules.set(name, { regex: globToRegex(value), pattern: value });
|
|
83
|
+
} else if (value && typeof value === 'object' && value.match) {
|
|
84
|
+
modules.set(name, {
|
|
85
|
+
regex: globToRegex(value.match),
|
|
86
|
+
pattern: value.match,
|
|
87
|
+
...(value.layer ? { layer: value.layer } : {}),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return modules;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Validation ──────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Validate a boundary configuration object.
|
|
98
|
+
* @param {object} config - The `manifesto.boundaries` config
|
|
99
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
100
|
+
*/
|
|
101
|
+
export function validateBoundaryConfig(config) {
|
|
102
|
+
const errors = [];
|
|
103
|
+
|
|
104
|
+
if (!config || typeof config !== 'object') {
|
|
105
|
+
return { valid: false, errors: ['boundaries config must be an object'] };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Validate modules
|
|
109
|
+
if (
|
|
110
|
+
!config.modules ||
|
|
111
|
+
typeof config.modules !== 'object' ||
|
|
112
|
+
Object.keys(config.modules).length === 0
|
|
113
|
+
) {
|
|
114
|
+
errors.push('boundaries.modules must be a non-empty object');
|
|
115
|
+
} else {
|
|
116
|
+
for (const [name, value] of Object.entries(config.modules)) {
|
|
117
|
+
if (typeof value === 'string') continue;
|
|
118
|
+
if (value && typeof value === 'object' && typeof value.match === 'string') continue;
|
|
119
|
+
errors.push(`boundaries.modules.${name}: must be a glob string or { match: "<glob>" }`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Validate preset
|
|
124
|
+
if (config.preset != null) {
|
|
125
|
+
if (typeof config.preset !== 'string' || !PRESETS[config.preset]) {
|
|
126
|
+
errors.push(
|
|
127
|
+
`boundaries.preset: must be one of ${Object.keys(PRESETS).join(', ')} (got "${config.preset}")`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Validate rules
|
|
133
|
+
if (config.rules) {
|
|
134
|
+
if (!Array.isArray(config.rules)) {
|
|
135
|
+
errors.push('boundaries.rules must be an array');
|
|
136
|
+
} else {
|
|
137
|
+
const moduleNames = config.modules ? new Set(Object.keys(config.modules)) : new Set();
|
|
138
|
+
for (let i = 0; i < config.rules.length; i++) {
|
|
139
|
+
const rule = config.rules[i];
|
|
140
|
+
if (!rule.from) {
|
|
141
|
+
errors.push(`boundaries.rules[${i}]: missing "from" field`);
|
|
142
|
+
} else if (!moduleNames.has(rule.from)) {
|
|
143
|
+
errors.push(`boundaries.rules[${i}]: "from" references unknown module "${rule.from}"`);
|
|
144
|
+
}
|
|
145
|
+
if (rule.notTo && rule.onlyTo) {
|
|
146
|
+
errors.push(`boundaries.rules[${i}]: cannot have both "notTo" and "onlyTo"`);
|
|
147
|
+
}
|
|
148
|
+
if (!rule.notTo && !rule.onlyTo) {
|
|
149
|
+
errors.push(`boundaries.rules[${i}]: must have either "notTo" or "onlyTo"`);
|
|
150
|
+
}
|
|
151
|
+
if (rule.notTo) {
|
|
152
|
+
if (!Array.isArray(rule.notTo)) {
|
|
153
|
+
errors.push(`boundaries.rules[${i}]: "notTo" must be an array`);
|
|
154
|
+
} else {
|
|
155
|
+
for (const target of rule.notTo) {
|
|
156
|
+
if (!moduleNames.has(target)) {
|
|
157
|
+
errors.push(
|
|
158
|
+
`boundaries.rules[${i}]: "notTo" references unknown module "${target}"`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (rule.onlyTo) {
|
|
165
|
+
if (!Array.isArray(rule.onlyTo)) {
|
|
166
|
+
errors.push(`boundaries.rules[${i}]: "onlyTo" must be an array`);
|
|
167
|
+
} else {
|
|
168
|
+
for (const target of rule.onlyTo) {
|
|
169
|
+
if (!moduleNames.has(target)) {
|
|
170
|
+
errors.push(
|
|
171
|
+
`boundaries.rules[${i}]: "onlyTo" references unknown module "${target}"`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Validate preset + layer assignments
|
|
182
|
+
if (config.preset && PRESETS[config.preset] && config.modules) {
|
|
183
|
+
const presetLayers = new Set(PRESETS[config.preset].layers);
|
|
184
|
+
for (const [name, value] of Object.entries(config.modules)) {
|
|
185
|
+
if (typeof value === 'object' && value.layer) {
|
|
186
|
+
if (!presetLayers.has(value.layer)) {
|
|
187
|
+
errors.push(
|
|
188
|
+
`boundaries.modules.${name}: layer "${value.layer}" not in preset "${config.preset}" (valid: ${[...presetLayers].join(', ')})`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { valid: errors.length === 0, errors };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── Preset Rule Generation ─────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Generate notTo rules from preset layer assignments.
|
|
202
|
+
* Inner layers cannot import from outer layers.
|
|
203
|
+
*/
|
|
204
|
+
function generatePresetRules(modules, presetName) {
|
|
205
|
+
const preset = PRESETS[presetName];
|
|
206
|
+
if (!preset) return [];
|
|
207
|
+
|
|
208
|
+
const layers = preset.layers;
|
|
209
|
+
const layerIndex = new Map(layers.map((l, i) => [l, i]));
|
|
210
|
+
|
|
211
|
+
// Group modules by layer
|
|
212
|
+
const modulesByLayer = new Map();
|
|
213
|
+
for (const [name, mod] of modules) {
|
|
214
|
+
if (mod.layer && layerIndex.has(mod.layer)) {
|
|
215
|
+
if (!modulesByLayer.has(mod.layer)) modulesByLayer.set(mod.layer, []);
|
|
216
|
+
modulesByLayer.get(mod.layer).push(name);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const rules = [];
|
|
221
|
+
// For each layer, forbid imports to any outer (higher-index) layer
|
|
222
|
+
for (const [layer, modNames] of modulesByLayer) {
|
|
223
|
+
const idx = layerIndex.get(layer);
|
|
224
|
+
const outerModules = [];
|
|
225
|
+
for (const [otherLayer, otherModNames] of modulesByLayer) {
|
|
226
|
+
if (layerIndex.get(otherLayer) > idx) {
|
|
227
|
+
outerModules.push(...otherModNames);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (outerModules.length > 0) {
|
|
231
|
+
for (const from of modNames) {
|
|
232
|
+
rules.push({ from, notTo: outerModules });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return rules;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Evaluation ──────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Classify a file path into a module name. Returns the first matching module or null.
|
|
244
|
+
*/
|
|
245
|
+
function classifyFile(filePath, modules) {
|
|
246
|
+
for (const [name, mod] of modules) {
|
|
247
|
+
if (mod.regex.test(filePath)) return name;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Evaluate boundary rules against the dependency graph.
|
|
254
|
+
*
|
|
255
|
+
* @param {object} db - Open SQLite database (readonly)
|
|
256
|
+
* @param {object} boundaryConfig - The `manifesto.boundaries` config
|
|
257
|
+
* @param {object} [opts]
|
|
258
|
+
* @param {string[]} [opts.scopeFiles] - Only check edges from these files (diff-impact mode)
|
|
259
|
+
* @param {boolean} [opts.noTests] - Exclude test files
|
|
260
|
+
* @returns {{ violations: object[], violationCount: number }}
|
|
261
|
+
*/
|
|
262
|
+
export function evaluateBoundaries(db, boundaryConfig, opts = {}) {
|
|
263
|
+
if (!boundaryConfig) return { violations: [], violationCount: 0 };
|
|
264
|
+
|
|
265
|
+
const { valid, errors } = validateBoundaryConfig(boundaryConfig);
|
|
266
|
+
if (!valid) {
|
|
267
|
+
debug('boundary config validation failed: %s', errors.join('; '));
|
|
268
|
+
return { violations: [], violationCount: 0 };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const modules = resolveModules(boundaryConfig);
|
|
272
|
+
if (modules.size === 0) return { violations: [], violationCount: 0 };
|
|
273
|
+
|
|
274
|
+
// Merge user rules with preset-generated rules
|
|
275
|
+
let allRules = [];
|
|
276
|
+
if (boundaryConfig.preset) {
|
|
277
|
+
allRules = generatePresetRules(modules, boundaryConfig.preset);
|
|
278
|
+
}
|
|
279
|
+
if (boundaryConfig.rules && Array.isArray(boundaryConfig.rules)) {
|
|
280
|
+
allRules = allRules.concat(boundaryConfig.rules);
|
|
281
|
+
}
|
|
282
|
+
if (allRules.length === 0) return { violations: [], violationCount: 0 };
|
|
283
|
+
|
|
284
|
+
// Query file-level import edges
|
|
285
|
+
let edges;
|
|
286
|
+
try {
|
|
287
|
+
edges = db
|
|
288
|
+
.prepare(
|
|
289
|
+
`SELECT DISTINCT n1.file AS source, n2.file AS target
|
|
290
|
+
FROM edges e
|
|
291
|
+
JOIN nodes n1 ON e.source_id = n1.id
|
|
292
|
+
JOIN nodes n2 ON e.target_id = n2.id
|
|
293
|
+
WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type')`,
|
|
294
|
+
)
|
|
295
|
+
.all();
|
|
296
|
+
} catch (err) {
|
|
297
|
+
debug('boundary edge query failed: %s', err.message);
|
|
298
|
+
return { violations: [], violationCount: 0 };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Filter by scope and tests
|
|
302
|
+
if (opts.noTests) {
|
|
303
|
+
edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
|
|
304
|
+
}
|
|
305
|
+
if (opts.scopeFiles) {
|
|
306
|
+
const scope = new Set(opts.scopeFiles);
|
|
307
|
+
edges = edges.filter((e) => scope.has(e.source));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Check each edge against rules
|
|
311
|
+
const violations = [];
|
|
312
|
+
|
|
313
|
+
for (const edge of edges) {
|
|
314
|
+
const fromModule = classifyFile(edge.source, modules);
|
|
315
|
+
const toModule = classifyFile(edge.target, modules);
|
|
316
|
+
|
|
317
|
+
// Skip edges where source or target is not in any module
|
|
318
|
+
if (!fromModule || !toModule) continue;
|
|
319
|
+
|
|
320
|
+
for (const rule of allRules) {
|
|
321
|
+
if (rule.from !== fromModule) continue;
|
|
322
|
+
|
|
323
|
+
let isViolation = false;
|
|
324
|
+
|
|
325
|
+
if (rule.notTo?.includes(toModule)) {
|
|
326
|
+
isViolation = true;
|
|
327
|
+
} else if (rule.onlyTo && !rule.onlyTo.includes(toModule)) {
|
|
328
|
+
isViolation = true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (isViolation) {
|
|
332
|
+
violations.push({
|
|
333
|
+
rule: 'boundaries',
|
|
334
|
+
name: `${fromModule} -> ${toModule}`,
|
|
335
|
+
file: edge.source,
|
|
336
|
+
targetFile: edge.target,
|
|
337
|
+
message: rule.message || `${fromModule} must not depend on ${toModule}`,
|
|
338
|
+
value: 1,
|
|
339
|
+
threshold: 0,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { violations, violationCount: violations.length };
|
|
346
|
+
}
|
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}`);
|