@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.
@@ -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
+ }