@optave/codegraph 3.1.5 → 3.2.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.
Files changed (91) hide show
  1. package/README.md +3 -2
  2. package/package.json +7 -7
  3. package/src/ast-analysis/engine.js +252 -258
  4. package/src/ast-analysis/shared.js +0 -12
  5. package/src/ast-analysis/visitors/cfg-visitor.js +635 -649
  6. package/src/ast-analysis/visitors/complexity-visitor.js +135 -139
  7. package/src/ast-analysis/visitors/dataflow-visitor.js +230 -224
  8. package/src/cli/commands/ast.js +2 -1
  9. package/src/cli/commands/audit.js +2 -1
  10. package/src/cli/commands/batch.js +2 -1
  11. package/src/cli/commands/brief.js +12 -0
  12. package/src/cli/commands/cfg.js +2 -1
  13. package/src/cli/commands/check.js +20 -23
  14. package/src/cli/commands/children.js +6 -1
  15. package/src/cli/commands/complexity.js +2 -1
  16. package/src/cli/commands/context.js +6 -1
  17. package/src/cli/commands/dataflow.js +2 -1
  18. package/src/cli/commands/deps.js +8 -3
  19. package/src/cli/commands/flow.js +2 -1
  20. package/src/cli/commands/fn-impact.js +6 -1
  21. package/src/cli/commands/owners.js +4 -2
  22. package/src/cli/commands/query.js +6 -1
  23. package/src/cli/commands/roles.js +2 -1
  24. package/src/cli/commands/search.js +8 -2
  25. package/src/cli/commands/sequence.js +2 -1
  26. package/src/cli/commands/triage.js +38 -27
  27. package/src/db/connection.js +18 -12
  28. package/src/db/migrations.js +41 -64
  29. package/src/db/query-builder.js +60 -4
  30. package/src/db/repository/in-memory-repository.js +27 -16
  31. package/src/db/repository/nodes.js +8 -10
  32. package/src/domain/analysis/brief.js +155 -0
  33. package/src/domain/analysis/context.js +174 -190
  34. package/src/domain/analysis/dependencies.js +200 -146
  35. package/src/domain/analysis/exports.js +3 -2
  36. package/src/domain/analysis/impact.js +267 -152
  37. package/src/domain/analysis/module-map.js +247 -221
  38. package/src/domain/analysis/roles.js +8 -5
  39. package/src/domain/analysis/symbol-lookup.js +7 -5
  40. package/src/domain/graph/builder/helpers.js +1 -1
  41. package/src/domain/graph/builder/incremental.js +116 -90
  42. package/src/domain/graph/builder/pipeline.js +106 -80
  43. package/src/domain/graph/builder/stages/build-edges.js +318 -239
  44. package/src/domain/graph/builder/stages/detect-changes.js +198 -177
  45. package/src/domain/graph/builder/stages/insert-nodes.js +147 -139
  46. package/src/domain/graph/watcher.js +2 -2
  47. package/src/domain/parser.js +20 -11
  48. package/src/domain/queries.js +1 -0
  49. package/src/domain/search/search/filters.js +9 -5
  50. package/src/domain/search/search/keyword.js +12 -5
  51. package/src/domain/search/search/prepare.js +13 -5
  52. package/src/extractors/csharp.js +224 -207
  53. package/src/extractors/go.js +176 -172
  54. package/src/extractors/hcl.js +94 -78
  55. package/src/extractors/java.js +213 -207
  56. package/src/extractors/javascript.js +274 -304
  57. package/src/extractors/php.js +234 -221
  58. package/src/extractors/python.js +252 -250
  59. package/src/extractors/ruby.js +192 -185
  60. package/src/extractors/rust.js +182 -167
  61. package/src/features/ast.js +5 -3
  62. package/src/features/audit.js +4 -2
  63. package/src/features/boundaries.js +98 -83
  64. package/src/features/cfg.js +134 -143
  65. package/src/features/communities.js +68 -53
  66. package/src/features/complexity.js +143 -132
  67. package/src/features/dataflow.js +146 -149
  68. package/src/features/export.js +3 -3
  69. package/src/features/graph-enrichment.js +2 -2
  70. package/src/features/manifesto.js +9 -6
  71. package/src/features/owners.js +4 -3
  72. package/src/features/sequence.js +152 -141
  73. package/src/features/shared/find-nodes.js +31 -0
  74. package/src/features/structure.js +130 -99
  75. package/src/features/triage.js +83 -68
  76. package/src/graph/classifiers/risk.js +3 -2
  77. package/src/graph/classifiers/roles.js +6 -3
  78. package/src/index.js +1 -0
  79. package/src/mcp/server.js +65 -56
  80. package/src/mcp/tool-registry.js +13 -0
  81. package/src/mcp/tools/brief.js +8 -0
  82. package/src/mcp/tools/index.js +2 -0
  83. package/src/presentation/brief.js +51 -0
  84. package/src/presentation/queries-cli/exports.js +21 -14
  85. package/src/presentation/queries-cli/impact.js +55 -39
  86. package/src/presentation/queries-cli/inspect.js +184 -189
  87. package/src/presentation/queries-cli/overview.js +57 -58
  88. package/src/presentation/queries-cli/path.js +36 -29
  89. package/src/presentation/table.js +0 -8
  90. package/src/shared/generators.js +7 -3
  91. package/src/shared/kinds.js +1 -1
@@ -4,191 +4,206 @@ import { findChild, nodeEndLine, rustVisibility } from './helpers.js';
4
4
  * Extract symbols from Rust files.
5
5
  */
6
6
  export function extractRustSymbols(tree, _filePath) {
7
- const definitions = [];
8
- const calls = [];
9
- const imports = [];
10
- const classes = [];
11
- const exports = [];
7
+ const ctx = {
8
+ definitions: [],
9
+ calls: [],
10
+ imports: [],
11
+ classes: [],
12
+ exports: [],
13
+ };
12
14
 
13
- function findCurrentImpl(node) {
14
- let current = node.parent;
15
- while (current) {
16
- if (current.type === 'impl_item') {
17
- const typeNode = current.childForFieldName('type');
18
- return typeNode ? typeNode.text : null;
19
- }
20
- current = current.parent;
21
- }
22
- return null;
15
+ walkRustNode(tree.rootNode, ctx);
16
+ return ctx;
17
+ }
18
+
19
+ function walkRustNode(node, ctx) {
20
+ switch (node.type) {
21
+ case 'function_item':
22
+ handleRustFuncItem(node, ctx);
23
+ break;
24
+ case 'struct_item':
25
+ handleRustStructItem(node, ctx);
26
+ break;
27
+ case 'enum_item':
28
+ handleRustEnumItem(node, ctx);
29
+ break;
30
+ case 'const_item':
31
+ handleRustConstItem(node, ctx);
32
+ break;
33
+ case 'trait_item':
34
+ handleRustTraitItem(node, ctx);
35
+ break;
36
+ case 'impl_item':
37
+ handleRustImplItem(node, ctx);
38
+ break;
39
+ case 'use_declaration':
40
+ handleRustUseDecl(node, ctx);
41
+ break;
42
+ case 'call_expression':
43
+ handleRustCallExpr(node, ctx);
44
+ break;
45
+ case 'macro_invocation':
46
+ handleRustMacroInvocation(node, ctx);
47
+ break;
23
48
  }
24
49
 
25
- function walkRustNode(node) {
26
- switch (node.type) {
27
- case 'function_item': {
28
- const nameNode = node.childForFieldName('name');
29
- if (nameNode) {
30
- const implType = findCurrentImpl(node);
31
- const fullName = implType ? `${implType}.${nameNode.text}` : nameNode.text;
32
- const kind = implType ? 'method' : 'function';
33
- const params = extractRustParameters(node.childForFieldName('parameters'));
34
- definitions.push({
35
- name: fullName,
36
- kind,
37
- line: node.startPosition.row + 1,
38
- endLine: nodeEndLine(node),
39
- children: params.length > 0 ? params : undefined,
40
- visibility: rustVisibility(node),
41
- });
42
- }
43
- break;
44
- }
50
+ for (let i = 0; i < node.childCount; i++) walkRustNode(node.child(i), ctx);
51
+ }
45
52
 
46
- case 'struct_item': {
47
- const nameNode = node.childForFieldName('name');
48
- if (nameNode) {
49
- const fields = extractStructFields(node);
50
- definitions.push({
51
- name: nameNode.text,
52
- kind: 'struct',
53
- line: node.startPosition.row + 1,
54
- endLine: nodeEndLine(node),
55
- children: fields.length > 0 ? fields : undefined,
56
- visibility: rustVisibility(node),
57
- });
58
- }
59
- break;
60
- }
53
+ // ── Walk-path per-node-type handlers ────────────────────────────────────────
61
54
 
62
- case 'enum_item': {
63
- const nameNode = node.childForFieldName('name');
64
- if (nameNode) {
65
- const variants = extractEnumVariants(node);
66
- definitions.push({
67
- name: nameNode.text,
68
- kind: 'enum',
69
- line: node.startPosition.row + 1,
70
- endLine: nodeEndLine(node),
71
- children: variants.length > 0 ? variants : undefined,
72
- });
73
- }
74
- break;
75
- }
55
+ function handleRustFuncItem(node, ctx) {
56
+ // Skip default-impl functions already emitted by handleRustTraitItem
57
+ if (node.parent?.parent?.type === 'trait_item') return;
58
+ const nameNode = node.childForFieldName('name');
59
+ if (!nameNode) return;
60
+ const implType = findCurrentImpl(node);
61
+ const fullName = implType ? `${implType}.${nameNode.text}` : nameNode.text;
62
+ const kind = implType ? 'method' : 'function';
63
+ const params = extractRustParameters(node.childForFieldName('parameters'));
64
+ ctx.definitions.push({
65
+ name: fullName,
66
+ kind,
67
+ line: node.startPosition.row + 1,
68
+ endLine: nodeEndLine(node),
69
+ children: params.length > 0 ? params : undefined,
70
+ visibility: rustVisibility(node),
71
+ });
72
+ }
76
73
 
77
- case 'const_item': {
78
- const nameNode = node.childForFieldName('name');
79
- if (nameNode) {
80
- definitions.push({
81
- name: nameNode.text,
82
- kind: 'constant',
83
- line: node.startPosition.row + 1,
84
- endLine: nodeEndLine(node),
85
- });
86
- }
87
- break;
88
- }
74
+ function handleRustStructItem(node, ctx) {
75
+ const nameNode = node.childForFieldName('name');
76
+ if (!nameNode) return;
77
+ const fields = extractStructFields(node);
78
+ ctx.definitions.push({
79
+ name: nameNode.text,
80
+ kind: 'struct',
81
+ line: node.startPosition.row + 1,
82
+ endLine: nodeEndLine(node),
83
+ children: fields.length > 0 ? fields : undefined,
84
+ visibility: rustVisibility(node),
85
+ });
86
+ }
89
87
 
90
- case 'trait_item': {
91
- const nameNode = node.childForFieldName('name');
92
- if (nameNode) {
93
- definitions.push({
94
- name: nameNode.text,
95
- kind: 'trait',
96
- line: node.startPosition.row + 1,
97
- endLine: nodeEndLine(node),
98
- });
99
- const body = node.childForFieldName('body');
100
- if (body) {
101
- for (let i = 0; i < body.childCount; i++) {
102
- const child = body.child(i);
103
- if (
104
- child &&
105
- (child.type === 'function_signature_item' || child.type === 'function_item')
106
- ) {
107
- const methName = child.childForFieldName('name');
108
- if (methName) {
109
- definitions.push({
110
- name: `${nameNode.text}.${methName.text}`,
111
- kind: 'method',
112
- line: child.startPosition.row + 1,
113
- endLine: child.endPosition.row + 1,
114
- });
115
- }
116
- }
117
- }
118
- }
119
- }
120
- break;
121
- }
88
+ function handleRustEnumItem(node, ctx) {
89
+ const nameNode = node.childForFieldName('name');
90
+ if (!nameNode) return;
91
+ const variants = extractEnumVariants(node);
92
+ ctx.definitions.push({
93
+ name: nameNode.text,
94
+ kind: 'enum',
95
+ line: node.startPosition.row + 1,
96
+ endLine: nodeEndLine(node),
97
+ children: variants.length > 0 ? variants : undefined,
98
+ });
99
+ }
100
+
101
+ function handleRustConstItem(node, ctx) {
102
+ const nameNode = node.childForFieldName('name');
103
+ if (!nameNode) return;
104
+ ctx.definitions.push({
105
+ name: nameNode.text,
106
+ kind: 'constant',
107
+ line: node.startPosition.row + 1,
108
+ endLine: nodeEndLine(node),
109
+ });
110
+ }
122
111
 
123
- case 'impl_item': {
124
- const typeNode = node.childForFieldName('type');
125
- const traitNode = node.childForFieldName('trait');
126
- if (typeNode && traitNode) {
127
- classes.push({
128
- name: typeNode.text,
129
- implements: traitNode.text,
130
- line: node.startPosition.row + 1,
112
+ function handleRustTraitItem(node, ctx) {
113
+ const nameNode = node.childForFieldName('name');
114
+ if (!nameNode) return;
115
+ ctx.definitions.push({
116
+ name: nameNode.text,
117
+ kind: 'trait',
118
+ line: node.startPosition.row + 1,
119
+ endLine: nodeEndLine(node),
120
+ });
121
+ const body = node.childForFieldName('body');
122
+ if (body) {
123
+ for (let i = 0; i < body.childCount; i++) {
124
+ const child = body.child(i);
125
+ if (child && (child.type === 'function_signature_item' || child.type === 'function_item')) {
126
+ const methName = child.childForFieldName('name');
127
+ if (methName) {
128
+ ctx.definitions.push({
129
+ name: `${nameNode.text}.${methName.text}`,
130
+ kind: 'method',
131
+ line: child.startPosition.row + 1,
132
+ endLine: child.endPosition.row + 1,
131
133
  });
132
134
  }
133
- break;
134
135
  }
136
+ }
137
+ }
138
+ }
135
139
 
136
- case 'use_declaration': {
137
- const argNode = node.child(1);
138
- if (argNode) {
139
- const usePaths = extractRustUsePath(argNode);
140
- for (const imp of usePaths) {
141
- imports.push({
142
- source: imp.source,
143
- names: imp.names,
144
- line: node.startPosition.row + 1,
145
- rustUse: true,
146
- });
147
- }
148
- }
149
- break;
150
- }
140
+ function handleRustImplItem(node, ctx) {
141
+ const typeNode = node.childForFieldName('type');
142
+ const traitNode = node.childForFieldName('trait');
143
+ if (typeNode && traitNode) {
144
+ ctx.classes.push({
145
+ name: typeNode.text,
146
+ implements: traitNode.text,
147
+ line: node.startPosition.row + 1,
148
+ });
149
+ }
150
+ }
151
151
 
152
- case 'call_expression': {
153
- const fn = node.childForFieldName('function');
154
- if (fn) {
155
- if (fn.type === 'identifier') {
156
- calls.push({ name: fn.text, line: node.startPosition.row + 1 });
157
- } else if (fn.type === 'field_expression') {
158
- const field = fn.childForFieldName('field');
159
- if (field) {
160
- const value = fn.childForFieldName('value');
161
- const call = { name: field.text, line: node.startPosition.row + 1 };
162
- if (value) call.receiver = value.text;
163
- calls.push(call);
164
- }
165
- } else if (fn.type === 'scoped_identifier') {
166
- const name = fn.childForFieldName('name');
167
- if (name) {
168
- const path = fn.childForFieldName('path');
169
- const call = { name: name.text, line: node.startPosition.row + 1 };
170
- if (path) call.receiver = path.text;
171
- calls.push(call);
172
- }
173
- }
174
- }
175
- break;
176
- }
152
+ function handleRustUseDecl(node, ctx) {
153
+ const argNode = node.child(1);
154
+ if (!argNode) return;
155
+ const usePaths = extractRustUsePath(argNode);
156
+ for (const imp of usePaths) {
157
+ ctx.imports.push({
158
+ source: imp.source,
159
+ names: imp.names,
160
+ line: node.startPosition.row + 1,
161
+ rustUse: true,
162
+ });
163
+ }
164
+ }
177
165
 
178
- case 'macro_invocation': {
179
- const macroNode = node.child(0);
180
- if (macroNode) {
181
- calls.push({ name: `${macroNode.text}!`, line: node.startPosition.row + 1 });
182
- }
183
- break;
184
- }
166
+ function handleRustCallExpr(node, ctx) {
167
+ const fn = node.childForFieldName('function');
168
+ if (!fn) return;
169
+ if (fn.type === 'identifier') {
170
+ ctx.calls.push({ name: fn.text, line: node.startPosition.row + 1 });
171
+ } else if (fn.type === 'field_expression') {
172
+ const field = fn.childForFieldName('field');
173
+ if (field) {
174
+ const value = fn.childForFieldName('value');
175
+ const call = { name: field.text, line: node.startPosition.row + 1 };
176
+ if (value) call.receiver = value.text;
177
+ ctx.calls.push(call);
178
+ }
179
+ } else if (fn.type === 'scoped_identifier') {
180
+ const name = fn.childForFieldName('name');
181
+ if (name) {
182
+ const path = fn.childForFieldName('path');
183
+ const call = { name: name.text, line: node.startPosition.row + 1 };
184
+ if (path) call.receiver = path.text;
185
+ ctx.calls.push(call);
185
186
  }
187
+ }
188
+ }
186
189
 
187
- for (let i = 0; i < node.childCount; i++) walkRustNode(node.child(i));
190
+ function handleRustMacroInvocation(node, ctx) {
191
+ const macroNode = node.child(0);
192
+ if (macroNode) {
193
+ ctx.calls.push({ name: `${macroNode.text}!`, line: node.startPosition.row + 1 });
188
194
  }
195
+ }
189
196
 
190
- walkRustNode(tree.rootNode);
191
- return { definitions, calls, imports, classes, exports };
197
+ function findCurrentImpl(node) {
198
+ let current = node.parent;
199
+ while (current) {
200
+ if (current.type === 'impl_item') {
201
+ const typeNode = current.childForFieldName('type');
202
+ return typeNode ? typeNode.text : null;
203
+ }
204
+ current = current.parent;
205
+ }
206
+ return null;
192
207
  }
193
208
 
194
209
  // ── Child extraction helpers ────────────────────────────────────────────────
@@ -12,6 +12,7 @@ import { buildExtensionSet } from '../ast-analysis/shared.js';
12
12
  import { walkWithVisitors } from '../ast-analysis/visitor.js';
13
13
  import { createAstStoreVisitor } from '../ast-analysis/visitors/ast-store-visitor.js';
14
14
  import { bulkNodeIdsByFile, openReadonlyOrFail } from '../db/index.js';
15
+ import { buildFileConditionSQL } from '../db/query-builder.js';
15
16
  import { debug } from '../infrastructure/logger.js';
16
17
  import { outputResult } from '../infrastructure/result-formatter.js';
17
18
  import { paginateResult } from '../shared/paginate.js';
@@ -193,9 +194,10 @@ export function astQueryData(pattern, customDbPath, opts = {}) {
193
194
  params.push(kind);
194
195
  }
195
196
 
196
- if (file) {
197
- where += ' AND a.file LIKE ?';
198
- params.push(`%${file}%`);
197
+ {
198
+ const fc = buildFileConditionSQL(file, 'a.file');
199
+ where += fc.sql;
200
+ params.push(...fc.params);
199
201
  }
200
202
 
201
203
  if (noTests) {
@@ -8,6 +8,7 @@
8
8
 
9
9
  import path from 'node:path';
10
10
  import { openReadonlyOrFail } from '../db/index.js';
11
+ import { normalizeFileFilter } from '../db/query-builder.js';
11
12
  import { bfsTransitiveCallers } from '../domain/analysis/impact.js';
12
13
  import { explainData } from '../domain/queries.js';
13
14
  import { loadConfig } from '../infrastructure/config.js';
@@ -100,7 +101,7 @@ function readPhase44(db, nodeId) {
100
101
  export function auditData(target, customDbPath, opts = {}) {
101
102
  const noTests = opts.noTests || false;
102
103
  const maxDepth = opts.depth || 3;
103
- const file = opts.file;
104
+ const fileFilters = normalizeFileFilter(opts.file);
104
105
  const kind = opts.kind;
105
106
 
106
107
  // 1. Get structure via explainData
@@ -109,7 +110,8 @@ export function auditData(target, customDbPath, opts = {}) {
109
110
  // Apply --file and --kind filters for function targets
110
111
  let results = explained.results;
111
112
  if (explained.kind === 'function') {
112
- if (file) results = results.filter((r) => r.file.includes(file));
113
+ if (fileFilters.length > 0)
114
+ results = results.filter((r) => fileFilters.some((f) => r.file.includes(f)));
113
115
  if (kind) results = results.filter((r) => r.kind === kind);
114
116
  }
115
117
 
@@ -94,104 +94,119 @@ export function resolveModules(boundaryConfig) {
94
94
  // ─── Validation ──────────────────────────────────────────────────────
95
95
 
96
96
  /**
97
- * Validate a boundary configuration object.
98
- * @param {object} config - The `manifesto.boundaries` config
99
- * @returns {{ valid: boolean, errors: string[] }}
97
+ * Validate the `modules` section of a boundary config.
98
+ * @param {object} modules
99
+ * @param {string[]} errors - Mutated: push any validation errors
100
100
  */
101
- export function validateBoundaryConfig(config) {
102
- const errors = [];
101
+ function validateModules(modules, errors) {
102
+ if (!modules || typeof modules !== 'object' || Object.keys(modules).length === 0) {
103
+ errors.push('boundaries.modules must be a non-empty object');
104
+ return;
105
+ }
106
+ for (const [name, value] of Object.entries(modules)) {
107
+ if (typeof value === 'string') continue;
108
+ if (value && typeof value === 'object' && typeof value.match === 'string') continue;
109
+ errors.push(`boundaries.modules.${name}: must be a glob string or { match: "<glob>" }`);
110
+ }
111
+ }
103
112
 
104
- if (!config || typeof config !== 'object') {
105
- return { valid: false, errors: ['boundaries config must be an object'] };
113
+ /**
114
+ * Validate the `preset` field of a boundary config.
115
+ * @param {string|null|undefined} preset
116
+ * @param {string[]} errors - Mutated: push any validation errors
117
+ */
118
+ function validatePreset(preset, errors) {
119
+ if (preset == null) return;
120
+ if (typeof preset !== 'string' || !PRESETS[preset]) {
121
+ errors.push(
122
+ `boundaries.preset: must be one of ${Object.keys(PRESETS).join(', ')} (got "${preset}")`,
123
+ );
106
124
  }
125
+ }
107
126
 
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>" }`);
127
+ /**
128
+ * Validate a single rule's target list (`notTo` or `onlyTo`).
129
+ * @param {*} list - The target list value
130
+ * @param {string} field - "notTo" or "onlyTo"
131
+ * @param {number} idx - Rule index for error messages
132
+ * @param {Set<string>} moduleNames
133
+ * @param {string[]} errors - Mutated
134
+ */
135
+ function validateTargetList(list, field, idx, moduleNames, errors) {
136
+ if (!Array.isArray(list)) {
137
+ errors.push(`boundaries.rules[${idx}]: "${field}" must be an array`);
138
+ return;
139
+ }
140
+ for (const target of list) {
141
+ if (!moduleNames.has(target)) {
142
+ errors.push(`boundaries.rules[${idx}]: "${field}" references unknown module "${target}"`);
120
143
  }
121
144
  }
145
+ }
122
146
 
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
- );
147
+ /**
148
+ * Validate the `rules` array of a boundary config.
149
+ * @param {Array} rules
150
+ * @param {object|undefined} modules - The modules config (for cross-referencing names)
151
+ * @param {string[]} errors - Mutated
152
+ */
153
+ function validateRules(rules, modules, errors) {
154
+ if (!rules) return;
155
+ if (!Array.isArray(rules)) {
156
+ errors.push('boundaries.rules must be an array');
157
+ return;
158
+ }
159
+ const moduleNames = modules ? new Set(Object.keys(modules)) : new Set();
160
+ for (let i = 0; i < rules.length; i++) {
161
+ const rule = rules[i];
162
+ if (!rule.from) {
163
+ errors.push(`boundaries.rules[${i}]: missing "from" field`);
164
+ } else if (!moduleNames.has(rule.from)) {
165
+ errors.push(`boundaries.rules[${i}]: "from" references unknown module "${rule.from}"`);
166
+ }
167
+ if (rule.notTo && rule.onlyTo) {
168
+ errors.push(`boundaries.rules[${i}]: cannot have both "notTo" and "onlyTo"`);
169
+ }
170
+ if (!rule.notTo && !rule.onlyTo) {
171
+ errors.push(`boundaries.rules[${i}]: must have either "notTo" or "onlyTo"`);
129
172
  }
173
+ if (rule.notTo) validateTargetList(rule.notTo, 'notTo', i, moduleNames, errors);
174
+ if (rule.onlyTo) validateTargetList(rule.onlyTo, 'onlyTo', i, moduleNames, errors);
130
175
  }
176
+ }
131
177
 
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
+ * Validate that module layer assignments match preset layers.
180
+ * @param {object} config
181
+ * @param {string[]} errors - Mutated
182
+ */
183
+ function validateLayerAssignments(config, errors) {
184
+ if (!config.preset || !PRESETS[config.preset] || !config.modules) return;
185
+ const presetLayers = new Set(PRESETS[config.preset].layers);
186
+ for (const [name, value] of Object.entries(config.modules)) {
187
+ if (typeof value === 'object' && value.layer && !presetLayers.has(value.layer)) {
188
+ errors.push(
189
+ `boundaries.modules.${name}: layer "${value.layer}" not in preset "${config.preset}" (valid: ${[...presetLayers].join(', ')})`,
190
+ );
178
191
  }
179
192
  }
193
+ }
180
194
 
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
- }
195
+ /**
196
+ * Validate a boundary configuration object.
197
+ * @param {object} config - The `manifesto.boundaries` config
198
+ * @returns {{ valid: boolean, errors: string[] }}
199
+ */
200
+ export function validateBoundaryConfig(config) {
201
+ if (!config || typeof config !== 'object') {
202
+ return { valid: false, errors: ['boundaries config must be an object'] };
193
203
  }
194
204
 
205
+ const errors = [];
206
+ validateModules(config.modules, errors);
207
+ validatePreset(config.preset, errors);
208
+ validateRules(config.rules, config.modules, errors);
209
+ validateLayerAssignments(config, errors);
195
210
  return { valid: errors.length === 0, errors };
196
211
  }
197
212