@brainwav/diagram 1.0.8 → 1.1.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/.diagram/contracts/machine-command-coverage.json +73 -0
  2. package/.diagram/migration/finalization-policy.json +20 -0
  3. package/LICENSE +202 -21
  4. package/README.md +132 -339
  5. package/package.json +46 -13
  6. package/scripts/refresh-diagram-context.sh +274 -182
  7. package/src/analyzers/default-analyzer.js +11 -0
  8. package/src/analyzers/index.js +34 -0
  9. package/src/artifacts/agent-context.js +105 -0
  10. package/src/artifacts/artifact-budget.js +224 -0
  11. package/src/artifacts/brief.js +153 -0
  12. package/src/artifacts/evidence-manifest.js +206 -0
  13. package/src/artifacts/evidence-summary.js +29 -0
  14. package/src/commands/analyze.js +125 -0
  15. package/src/commands/changed.js +185 -0
  16. package/src/commands/context.js +110 -0
  17. package/src/commands/diff.js +142 -0
  18. package/src/commands/doctor.js +335 -0
  19. package/src/commands/explain.js +273 -0
  20. package/src/commands/generate-all.js +170 -0
  21. package/src/commands/generate-animated.js +50 -0
  22. package/src/commands/generate-video.js +65 -0
  23. package/src/commands/generate.js +522 -0
  24. package/src/commands/init.js +123 -0
  25. package/src/commands/output.js +76 -0
  26. package/src/commands/scan.js +624 -0
  27. package/src/commands/shared.js +396 -0
  28. package/src/commands/validate.js +328 -0
  29. package/src/commands/video-shared.js +105 -0
  30. package/src/commands/workflow-pr.js +26 -0
  31. package/src/confidence/pipeline.js +186 -0
  32. package/src/config/diagramrc.js +79 -0
  33. package/src/context/build-context-pack.js +291 -0
  34. package/src/context/normalize-diagram-manifest.js +282 -0
  35. package/src/core/analysis-generation-analyze-components.js +102 -0
  36. package/src/core/analysis-generation-analyze-dependencies.js +33 -0
  37. package/src/core/analysis-generation-analyze-files.js +48 -0
  38. package/src/core/analysis-generation-analyze-options.js +73 -0
  39. package/src/core/analysis-generation-analyze.js +63 -0
  40. package/src/core/analysis-generation-constants.js +53 -0
  41. package/src/core/analysis-generation-diagrams-core-architecture.js +105 -0
  42. package/src/core/analysis-generation-diagrams-core-dependency.js +68 -0
  43. package/src/core/analysis-generation-diagrams-core-sequence.js +142 -0
  44. package/src/core/analysis-generation-diagrams-core-shapes.js +104 -0
  45. package/src/core/analysis-generation-diagrams-core.js +12 -0
  46. package/src/core/analysis-generation-diagrams-empty.js +68 -0
  47. package/src/core/analysis-generation-diagrams-erd.js +59 -0
  48. package/src/core/analysis-generation-diagrams-limit.js +27 -0
  49. package/src/core/analysis-generation-diagrams-role-ai-agent.js +103 -0
  50. package/src/core/analysis-generation-diagrams-role-ai-context.js +186 -0
  51. package/src/core/analysis-generation-diagrams-role-ai.js +11 -0
  52. package/src/core/analysis-generation-diagrams-role-data.js +182 -0
  53. package/src/core/analysis-generation-diagrams-role-helpers.js +129 -0
  54. package/src/core/analysis-generation-diagrams-role-security.js +129 -0
  55. package/src/core/analysis-generation-diagrams-role.js +25 -0
  56. package/src/core/analysis-generation-diagrams.js +182 -0
  57. package/src/core/analysis-generation-role-tags-constants.js +55 -0
  58. package/src/core/analysis-generation-role-tags-imports.js +32 -0
  59. package/src/core/analysis-generation-role-tags-infer.js +49 -0
  60. package/src/core/analysis-generation-role-tags-match.js +19 -0
  61. package/src/core/analysis-generation-role-tags.js +7 -0
  62. package/src/core/analysis-generation-utils-core.js +308 -0
  63. package/src/core/analysis-generation-utils-graph.js +321 -0
  64. package/src/core/analysis-generation-utils-resolution.js +76 -0
  65. package/src/core/analysis-generation-utils.js +9 -0
  66. package/src/core/analysis-generation.js +44 -0
  67. package/src/diagram.js +178 -1761
  68. package/src/formatters/console.js +198 -0
  69. package/src/formatters/index.js +41 -0
  70. package/src/formatters/json.js +113 -0
  71. package/src/formatters/junit.js +123 -0
  72. package/src/graph.js +159 -0
  73. package/src/incremental/cache.js +210 -0
  74. package/src/ir/architecture-ir.js +48 -0
  75. package/src/migration/evidence.js +262 -0
  76. package/src/migration/finalization-policy.js +35 -0
  77. package/src/renderers/report-html.js +265 -0
  78. package/src/rules/factory.js +108 -0
  79. package/src/rules/types/base.js +54 -0
  80. package/src/rules/types/import-rule.js +286 -0
  81. package/src/rules.js +380 -0
  82. package/src/schema/erd-confidence.js +56 -0
  83. package/src/schema/erd-extractor.js +504 -0
  84. package/src/schema/erd-model.js +176 -0
  85. package/src/schema/rules-schema.js +170 -0
  86. package/src/utils/suggestions.js +67 -0
  87. package/src/video.js +4 -5
  88. package/src/workflow/git-helpers.js +576 -0
  89. package/src/workflow/pr-command.js +694 -0
  90. package/src/workflow/pr-impact.js +848 -0
  91. package/src/workflow/sort-utils.js +16 -0
@@ -0,0 +1,286 @@
1
+ const { Rule } = require('./base');
2
+ const path = require('path');
3
+ const picomatch = require('picomatch');
4
+
5
+ /**
6
+ * Import constraint rule
7
+ * Validates imports against allowed/forbidden patterns
8
+ * Supports inward_only for directional layer constraints
9
+ */
10
+ class ImportRule extends Rule {
11
+ /**
12
+ * Get compiled layer matchers for this rule
13
+ * @returns {Array<Function>}
14
+ */
15
+ get layerMatchers() {
16
+ if (!this._layerMatchers) {
17
+ const layers = Array.isArray(this.layer) ? this.layer : [this.layer];
18
+ this._layerMatchers = layers
19
+ .filter(l => typeof l === 'string' && l.trim() !== '')
20
+ .map(l => picomatch(l.trim(), { dot: true }));
21
+ }
22
+ return this._layerMatchers;
23
+ }
24
+
25
+ /**
26
+ * Validate a file against this rule
27
+ * @param {Object} file - Component file object
28
+ * @param {ComponentGraph} graph - Component graph for lookups
29
+ * @param {Object} context - Optional context with inwardOnlyMatchers
30
+ * @returns {Array<Violation>} Array of violations
31
+ */
32
+ validate(file, graph, context = {}) {
33
+ const violations = [];
34
+ const imports = Array.isArray(file?.imports) ? file.imports : [];
35
+
36
+ const truncateText = (value, max = 100) => String(value ?? '').slice(0, max);
37
+
38
+ // Helper to safely extract import path
39
+ const getImportPath = (importInfo) => {
40
+ if (typeof importInfo === 'string') return importInfo;
41
+ if (importInfo && typeof importInfo.path === 'string') return importInfo.path;
42
+ return null;
43
+ };
44
+
45
+ // Helper to safely extract line number
46
+ const getLineNumber = (importInfo) => {
47
+ if (importInfo && Number.isInteger(importInfo.line) && importInfo.line > 0) {
48
+ return importInfo.line;
49
+ }
50
+ return undefined;
51
+ };
52
+
53
+ // Check must_not_import_from constraints
54
+ if (Array.isArray(this.config.must_not_import_from)) {
55
+ for (const importInfo of imports) {
56
+ const importPath = getImportPath(importInfo);
57
+ if (!importPath) continue;
58
+
59
+ for (const forbidden of this.config.must_not_import_from) {
60
+ try {
61
+ if (this._matchesPattern(importPath, forbidden, file.filePath)) {
62
+ violations.push({
63
+ ruleName: this.name,
64
+ severity: 'error',
65
+ file: file.filePath,
66
+ line: getLineNumber(importInfo),
67
+ message: `Forbidden import: "${truncateText(importPath)}" matches "${truncateText(forbidden)}"`,
68
+ suggestion: 'Remove this import or add to allowed list',
69
+ relatedFile: importPath
70
+ });
71
+ break; // Found match, no need to check other forbidden patterns
72
+ }
73
+ } catch (e) {
74
+ // Regex error, skip this pattern
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ // Check may_import_from whitelist constraints
81
+ if (Array.isArray(this.config.may_import_from) && this.config.may_import_from.length > 0) {
82
+ for (const importInfo of imports) {
83
+ const importPath = getImportPath(importInfo);
84
+ if (!importPath) continue;
85
+
86
+ let isAllowed = false;
87
+ for (const allowed of this.config.may_import_from) {
88
+ try {
89
+ if (this._matchesPattern(importPath, allowed, file.filePath)) {
90
+ isAllowed = true;
91
+ break; // Found match, no need to check other allowed patterns
92
+ }
93
+ } catch (e) {
94
+ // Regex error, continue checking
95
+ }
96
+ }
97
+
98
+ if (!isAllowed) {
99
+ violations.push({
100
+ ruleName: this.name,
101
+ severity: 'error',
102
+ file: file.filePath,
103
+ line: getLineNumber(importInfo),
104
+ message: `Import not in whitelist: "${truncateText(importPath)}"`,
105
+ suggestion: `Add to may_import_from or use allowed import`,
106
+ relatedFile: importPath
107
+ });
108
+ }
109
+ }
110
+ }
111
+
112
+ // Check must_import_from required constraints
113
+ if (Array.isArray(this.config.must_import_from) && this.config.must_import_from.length > 0) {
114
+ for (const required of this.config.must_import_from) {
115
+ let hasImport = false;
116
+ for (const importInfo of imports) {
117
+ const importPath = getImportPath(importInfo);
118
+ if (!importPath) continue;
119
+
120
+ try {
121
+ if (this._matchesPattern(importPath, required, file.filePath)) {
122
+ hasImport = true;
123
+ break;
124
+ }
125
+ } catch (e) {
126
+ // Regex error, continue checking
127
+ }
128
+ }
129
+
130
+ if (!hasImport) {
131
+ violations.push({
132
+ ruleName: this.name,
133
+ severity: 'error',
134
+ file: file.filePath,
135
+ message: `Missing required import matching "${truncateText(required)}"`,
136
+ suggestion: `Add an import that matches "${truncateText(required, 200)}"`
137
+ });
138
+ }
139
+ }
140
+ }
141
+
142
+ // Check inward_only constraint (who imports THIS file)
143
+ // This checks DEPENDENTS, not dependencies
144
+ if (this.config.inward_only === true) {
145
+ const protectedMatchers = context?.inwardOnlyMatchers;
146
+ if (protectedMatchers && protectedMatchers.size > 0 && file?.name) {
147
+ // Get who imports THIS file (dependents)
148
+ const dependents = graph.getDependents(file.name);
149
+
150
+ for (const dependent of dependents) {
151
+ if (!dependent?.filePath) continue;
152
+
153
+ // Fast path: skip if dependent is in same layer
154
+ if (this.layerMatchers.length > 0 && this.matchesLayer(dependent.filePath, this.layerMatchers)) {
155
+ continue;
156
+ }
157
+
158
+ // Check if dependent is in ANOTHER protected layer
159
+ for (const [ruleName, { matchers }] of protectedMatchers) {
160
+ if (ruleName === this.name) continue; // Skip self
161
+
162
+ if (matchers && matchers.length > 0 && this.matchesLayer(dependent.filePath, matchers)) {
163
+ violations.push({
164
+ ruleName: this.name,
165
+ severity: 'error',
166
+ file: dependent.filePath, // The VIOLATOR (dependent file)
167
+ line: this._findImportLine(dependent, file.filePath),
168
+ message: `Cannot import from protected layer "${this.name}": layer "${ruleName}" has inward_only constraint`,
169
+ suggestion: `Move shared logic to an unprotected module (e.g., src/shared/) or remove inward_only from one layer`,
170
+ relatedFile: file.filePath // The PROTECTED file
171
+ });
172
+ break; // One violation per dependent, not per matching rule
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ return violations;
180
+ }
181
+
182
+ /**
183
+ * Find the line number of the import that targets a specific file
184
+ * @param {Object} dependent - Dependent component with imports
185
+ * @param {string} targetFilePath - The file being imported
186
+ * @returns {number|undefined}
187
+ */
188
+ _findImportLine(dependent, targetFilePath) {
189
+ const imports = dependent?.imports || [];
190
+ for (const imp of imports) {
191
+ const importPath = typeof imp === 'string' ? imp : imp?.path;
192
+ if (!importPath) continue;
193
+
194
+ // Check if this import resolves to targetFilePath
195
+ if (this._resolvesTo(importPath, dependent.filePath, targetFilePath)) {
196
+ return typeof imp === 'object' ? imp.line : undefined;
197
+ }
198
+ }
199
+ return undefined;
200
+ }
201
+
202
+ /**
203
+ * Check if an import path resolves to a target file path
204
+ * @param {string} importPath - The import path (may be relative)
205
+ * @param {string} fromFile - The file containing the import
206
+ * @param {string} targetFilePath - The expected resolved path
207
+ * @returns {boolean}
208
+ */
209
+ _resolvesTo(importPath, fromFile, targetFilePath) {
210
+ // External packages never match internal paths
211
+ if (!importPath?.startsWith('.')) {
212
+ return false;
213
+ }
214
+
215
+ const fromDir = path.dirname(fromFile || '.');
216
+ const resolved = path.normalize(path.join(fromDir, importPath));
217
+
218
+ // Check with common extensions
219
+ const extensions = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs'];
220
+ for (const ext of extensions) {
221
+ if (resolved + ext === targetFilePath) return true;
222
+ }
223
+
224
+ // Check index files
225
+ for (const ext of extensions) {
226
+ if (path.join(resolved, 'index' + ext) === targetFilePath) return true;
227
+ }
228
+
229
+ return resolved === targetFilePath;
230
+ }
231
+
232
+ /**
233
+ * Check if an import path matches a pattern
234
+ * @param {string} importPath - The import path
235
+ * @param {string} pattern - The pattern to match against
236
+ * @param {string} sourceFile - The file containing the import (for relative path resolution)
237
+ * @returns {boolean}
238
+ */
239
+ _matchesPattern(importPath, pattern, sourceFile) {
240
+ // Handle empty or invalid inputs
241
+ if (!pattern || typeof pattern !== 'string' || pattern.trim() === '') {
242
+ return false;
243
+ }
244
+ if (!importPath || typeof importPath !== 'string') {
245
+ return false;
246
+ }
247
+
248
+ // Normalize paths for comparison
249
+ const normalizedImport = importPath.replace(/\\/g, '/');
250
+ const normalizedPattern = pattern.replace(/\\/g, '/').replace(/\/$/, ''); // Remove trailing slash
251
+
252
+ // Exact match
253
+ if (normalizedImport === normalizedPattern) {
254
+ return true;
255
+ }
256
+
257
+ // Check if import is within the pattern directory (path boundary)
258
+ // e.g., 'src/ui/Button' matches 'src/ui' but 'src/ui-core' does not
259
+ if (normalizedImport.startsWith(normalizedPattern + '/')) {
260
+ return true;
261
+ }
262
+
263
+ // Glob pattern matching for patterns with wildcards
264
+ if (normalizedPattern.includes('*') || normalizedPattern.includes('?')) {
265
+ try {
266
+ const matcher = picomatch(normalizedPattern, { dot: true });
267
+ return matcher(normalizedImport);
268
+ } catch (e) {
269
+ // Invalid glob pattern
270
+ return false;
271
+ }
272
+ }
273
+
274
+ // Handle relative imports by resolving against source file
275
+ if (importPath.startsWith('.')) {
276
+ const sourceDir = path.dirname(sourceFile || '.').replace(/\\/g, '/');
277
+ const resolvedPath = path.posix.normalize(path.posix.join(sourceDir, normalizedImport));
278
+ return resolvedPath === normalizedPattern ||
279
+ resolvedPath.startsWith(normalizedPattern + '/');
280
+ }
281
+
282
+ return false;
283
+ }
284
+ }
285
+
286
+ module.exports = { ImportRule };
package/src/rules.js ADDED
@@ -0,0 +1,380 @@
1
+ const YAML = require('yaml');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const picomatch = require('picomatch');
5
+
6
+ const MAX_CONFIG_SIZE = 1024 * 1024; // 1MB limit
7
+ const MAX_PATTERN_CACHE_SIZE = 5000;
8
+
9
+ /**
10
+ * Architecture Rules Engine
11
+ * Validates codebase against declarative rules
12
+ */
13
+ class RulesEngine {
14
+ constructor() {
15
+ this.patternCache = new Map();
16
+ }
17
+
18
+ /**
19
+ * Load and parse YAML configuration file
20
+ * @param {string} configPath - Path to .architecture.yml
21
+ * @returns {Object} Parsed configuration
22
+ */
23
+ loadConfig(configPath) {
24
+ // Security: Read file first, then check size to avoid TOCTOU race
25
+ let content;
26
+ try {
27
+ content = fs.readFileSync(configPath, 'utf8');
28
+ } catch (e) {
29
+ throw new Error(`Config file not found or not accessible: ${configPath}`);
30
+ }
31
+
32
+ const contentSize = Buffer.byteLength(content, 'utf8');
33
+ if (contentSize > MAX_CONFIG_SIZE) {
34
+ throw new Error(
35
+ `Config file too large (${contentSize} bytes)`
36
+ );
37
+ }
38
+
39
+ try {
40
+ return YAML.parse(content, {
41
+ prettyErrors: true,
42
+ strict: true,
43
+ maxAliasCount: 100, // Prevent Billion Laughs attack
44
+ customTags: [] // Disable dangerous tags like !!js/function
45
+ });
46
+ } catch (error) {
47
+ const enhanced = new Error(
48
+ `Failed to parse ${configPath}: ${error.message}`
49
+ );
50
+ enhanced.filepath = configPath;
51
+ enhanced.originalError = error;
52
+ throw enhanced;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Validate a pattern for security issues
58
+ * @param {string} pattern - Glob pattern
59
+ * @param {string} context - Context for error messages
60
+ */
61
+ validatePattern(pattern, context = 'pattern') {
62
+ if (typeof pattern !== 'string' || pattern.trim() === '') {
63
+ throw new Error(`Invalid ${context}: pattern must be a non-empty string`);
64
+ }
65
+ if (pattern.includes('\0')) {
66
+ const preview = pattern.slice(0, 50) + (pattern.length > 50 ? '...' : '');
67
+ throw new Error(`Invalid ${context}: null bytes are not allowed in pattern: "${preview}"`);
68
+ }
69
+
70
+ const normalizedPattern = path.posix.normalize(pattern.trim().replace(/\\/g, '/'));
71
+
72
+ if (normalizedPattern === '..' || normalizedPattern.startsWith('../')) {
73
+ throw new Error(
74
+ `Invalid ${context}: directory traversal not allowed`
75
+ );
76
+ }
77
+ if (path.posix.isAbsolute(normalizedPattern) || /^[a-zA-Z]:\//.test(normalizedPattern)) {
78
+ throw new Error(
79
+ `Invalid ${context}: absolute paths not allowed`
80
+ );
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Get or create a compiled pattern matcher
86
+ * @param {string} pattern - Glob pattern
87
+ * @param {Object} options - picomatch options
88
+ * @returns {Function}
89
+ */
90
+ getMatcher(pattern, options = { dot: true }) {
91
+ // Validate options are serializable
92
+ let optionsKey;
93
+ try {
94
+ optionsKey = JSON.stringify(options);
95
+ } catch (e) {
96
+ optionsKey = String(options);
97
+ }
98
+
99
+ // Include options in cache key
100
+ const key = `${pattern}::${optionsKey}`;
101
+
102
+ if (!this.patternCache.has(key)) {
103
+ this.validatePattern(pattern);
104
+ if (this.patternCache.size >= MAX_PATTERN_CACHE_SIZE) {
105
+ this.patternCache.clear();
106
+ }
107
+ this.patternCache.set(key, picomatch(pattern, options));
108
+ }
109
+
110
+ return this.patternCache.get(key);
111
+ }
112
+
113
+ /**
114
+ * Compile layer patterns for a rule
115
+ * @param {Object} rule - Rule configuration
116
+ * @returns {Array<Function>} Compiled matchers
117
+ */
118
+ compileLayerPatterns(rule) {
119
+ const layers = Array.isArray(rule?.layer) ? rule.layer : [rule?.layer];
120
+ const normalizedLayers = layers
121
+ .filter(layer => typeof layer === 'string' && layer.trim() !== '')
122
+ .map(layer => layer.trim());
123
+
124
+ if (normalizedLayers.length === 0) {
125
+ throw new Error(`Rule "${rule?.name || 'unnamed'}" has no valid layer patterns`);
126
+ }
127
+
128
+ return normalizedLayers.map(layer => this.getMatcher(layer, { dot: true }));
129
+ }
130
+
131
+ /**
132
+ * Validate rules against component graph
133
+ * @param {Array<Rule>} rules - Rule instances
134
+ * @param {ComponentGraph} graph - Component graph
135
+ * @returns {Object} Validation results
136
+ */
137
+ validate(rules, graph) {
138
+ if (!graph || typeof graph.getFilesInLayer !== 'function') {
139
+ throw new TypeError('graph must expose getFilesInLayer(matchers)');
140
+ }
141
+
142
+ const safeRules = Array.isArray(rules) ? rules : [];
143
+ const results = {
144
+ summary: {
145
+ total: safeRules.length,
146
+ passed: 0,
147
+ failed: 0,
148
+ violations: 0
149
+ },
150
+ rules: []
151
+ };
152
+
153
+ const seenRuleNames = new Set();
154
+
155
+ // Pre-compute inward_only layer matchers ONCE (performance optimization)
156
+ const MAX_INWARD_ONLY_RULES = 50;
157
+ const inwardOnlyMatchers = new Map();
158
+
159
+ for (const rule of safeRules) {
160
+ if (rule?.config?.inward_only === true && rule?.config?.layer) {
161
+ const ruleName = rule.name || 'unnamed';
162
+ const matchers = this.compileLayerPatterns({ layer: rule.config.layer });
163
+ inwardOnlyMatchers.set(ruleName, {
164
+ pattern: rule.config.layer,
165
+ matchers
166
+ });
167
+ }
168
+ }
169
+
170
+ // Security limit: prevent config explosion
171
+ if (inwardOnlyMatchers.size > MAX_INWARD_ONLY_RULES) {
172
+ throw new Error(
173
+ `Too many inward_only rules (${inwardOnlyMatchers.size}). Maximum is ${MAX_INWARD_ONLY_RULES}.`
174
+ );
175
+ }
176
+
177
+ // Build context for explicit parameter passing (no graph mutation)
178
+ const context = { inwardOnlyMatchers };
179
+
180
+ for (let index = 0; index < safeRules.length; index++) {
181
+ const rule = safeRules[index] || {};
182
+ const ruleName =
183
+ typeof rule.name === 'string' && rule.name.trim() !== ''
184
+ ? rule.name
185
+ : `rule-${index + 1}`;
186
+ const ruleResult = {
187
+ name: ruleName,
188
+ description: typeof rule.description === 'string' ? rule.description : '',
189
+ status: 'passed',
190
+ filesChecked: 0,
191
+ violations: []
192
+ };
193
+
194
+ if (seenRuleNames.has(ruleName)) {
195
+ ruleResult.violations.push({
196
+ ruleName,
197
+ severity: 'error',
198
+ message: `Duplicate rule name "${ruleName}" detected`
199
+ });
200
+ } else {
201
+ seenRuleNames.add(ruleName);
202
+ }
203
+
204
+ let filesInLayer = [];
205
+ try {
206
+ const matchers = this.compileLayerPatterns(rule);
207
+ filesInLayer = graph.getFilesInLayer(matchers);
208
+ ruleResult.filesChecked = filesInLayer.length;
209
+ } catch (error) {
210
+ ruleResult.status = 'failed';
211
+ ruleResult.violations.push({
212
+ ruleName,
213
+ severity: 'error',
214
+ message: `Rule setup failed: ${error.message}`
215
+ });
216
+ results.summary.failed++;
217
+ results.summary.violations += ruleResult.violations.length;
218
+ results.rules.push(ruleResult);
219
+ continue;
220
+ }
221
+
222
+ // Skip if no files match
223
+ if (filesInLayer.length === 0) {
224
+ if (ruleResult.violations.length > 0) {
225
+ ruleResult.status = 'failed';
226
+ results.summary.failed++;
227
+ results.summary.violations += ruleResult.violations.length;
228
+ } else {
229
+ ruleResult.status = 'skipped';
230
+ ruleResult.message = 'No files matched layer pattern';
231
+ }
232
+ results.rules.push(ruleResult);
233
+ continue;
234
+ }
235
+
236
+ if (typeof rule.validate !== 'function') {
237
+ ruleResult.violations.push({
238
+ ruleName,
239
+ severity: 'error',
240
+ message: `Rule "${ruleName}" does not implement validate()`
241
+ });
242
+ }
243
+
244
+ // Validate each file with error handling per rule
245
+ for (const file of filesInLayer) {
246
+ if (!file || typeof file !== 'object') continue;
247
+ try {
248
+ if (typeof rule.validate !== 'function') {
249
+ break;
250
+ }
251
+ const violations = rule.validate(file, graph, context);
252
+ if (Array.isArray(violations) && violations.length > 0) {
253
+ ruleResult.violations.push(...violations);
254
+ } else if (violations != null && !Array.isArray(violations)) {
255
+ ruleResult.violations.push({
256
+ ruleName,
257
+ severity: 'error',
258
+ file: file.filePath,
259
+ message: 'Rule returned invalid violation payload'
260
+ });
261
+ }
262
+ } catch (validationError) {
263
+ ruleResult.violations.push({
264
+ ruleName,
265
+ severity: 'error',
266
+ file: file.filePath,
267
+ message: `Rule validation failed: ${validationError.message}`
268
+ });
269
+ }
270
+ }
271
+
272
+ // Determine status
273
+ if (ruleResult.violations.length > 0) {
274
+ ruleResult.status = 'failed';
275
+ results.summary.failed++;
276
+ results.summary.violations += ruleResult.violations.length;
277
+ } else {
278
+ results.summary.passed++;
279
+ }
280
+
281
+ results.rules.push(ruleResult);
282
+ }
283
+
284
+ return results;
285
+ }
286
+
287
+ /**
288
+ * Find configuration file
289
+ * @param {string} searchPath - Directory to search
290
+ * @returns {string|null} Path to config file or null
291
+ */
292
+ findConfig(searchPath) {
293
+ const configNames = [
294
+ '.architecture.yml',
295
+ '.architecture.yaml',
296
+ 'architecture.config.yml',
297
+ 'architecture.config.yaml'
298
+ ];
299
+
300
+ // Case-insensitive search on Windows
301
+ const isWindows = process.platform === 'win32';
302
+
303
+ for (const name of configNames) {
304
+ const fullPath = path.join(searchPath, name);
305
+ if (fs.existsSync(fullPath)) {
306
+ return fullPath;
307
+ }
308
+
309
+ // On Windows, also try case-insensitive match
310
+ if (isWindows) {
311
+ const lowerName = name.toLowerCase();
312
+ try {
313
+ const files = fs.readdirSync(searchPath);
314
+ const match = files.find(f => f.toLowerCase() === lowerName);
315
+ if (match) {
316
+ return path.join(searchPath, match);
317
+ }
318
+ } catch (e) {
319
+ // Directory not readable
320
+ }
321
+ }
322
+ }
323
+
324
+ return null;
325
+ }
326
+
327
+ /**
328
+ * Preview which files match each rule (dry-run mode)
329
+ * @param {Array<Rule>} rules - Rule instances
330
+ * @param {ComponentGraph} graph - Component graph
331
+ * @returns {Object} File matching results
332
+ */
333
+ previewMatches(rules, graph) {
334
+ const preview = {
335
+ rules: []
336
+ };
337
+
338
+ if (!graph || typeof graph.getFilesInLayer !== 'function') {
339
+ return {
340
+ rules: [{
341
+ name: 'preview',
342
+ layer: '',
343
+ error: 'Invalid graph input'
344
+ }]
345
+ };
346
+ }
347
+
348
+ const MAX_PREVIEW_FILES = 100; // Limit output size
349
+ const safeRules = Array.isArray(rules) ? rules : [];
350
+
351
+ for (let index = 0; index < safeRules.length; index++) {
352
+ const rule = safeRules[index] || {};
353
+ try {
354
+ const matchers = this.compileLayerPatterns(rule);
355
+ const filesInLayer = graph.getFilesInLayer(matchers);
356
+
357
+ const matchedFiles = filesInLayer.map(f => f.filePath);
358
+ const truncated = matchedFiles.length > MAX_PREVIEW_FILES;
359
+
360
+ preview.rules.push({
361
+ name: rule.name,
362
+ layer: rule.layer,
363
+ matchedFiles: truncated ? matchedFiles.slice(0, MAX_PREVIEW_FILES) : matchedFiles,
364
+ truncated: truncated,
365
+ totalFiles: matchedFiles.length
366
+ });
367
+ } catch (e) {
368
+ preview.rules.push({
369
+ name: rule.name || `rule-${index + 1}`,
370
+ layer: rule.layer,
371
+ error: e.message
372
+ });
373
+ }
374
+ }
375
+
376
+ return preview;
377
+ }
378
+ }
379
+
380
+ module.exports = { RulesEngine, MAX_CONFIG_SIZE };