@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.
@@ -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}`);