@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.
- package/.diagram/contracts/machine-command-coverage.json +73 -0
- package/.diagram/migration/finalization-policy.json +20 -0
- package/LICENSE +202 -21
- package/README.md +132 -339
- package/package.json +46 -13
- package/scripts/refresh-diagram-context.sh +274 -182
- package/src/analyzers/default-analyzer.js +11 -0
- package/src/analyzers/index.js +34 -0
- package/src/artifacts/agent-context.js +105 -0
- package/src/artifacts/artifact-budget.js +224 -0
- package/src/artifacts/brief.js +153 -0
- package/src/artifacts/evidence-manifest.js +206 -0
- package/src/artifacts/evidence-summary.js +29 -0
- package/src/commands/analyze.js +125 -0
- package/src/commands/changed.js +185 -0
- package/src/commands/context.js +110 -0
- package/src/commands/diff.js +142 -0
- package/src/commands/doctor.js +335 -0
- package/src/commands/explain.js +273 -0
- package/src/commands/generate-all.js +170 -0
- package/src/commands/generate-animated.js +50 -0
- package/src/commands/generate-video.js +65 -0
- package/src/commands/generate.js +522 -0
- package/src/commands/init.js +123 -0
- package/src/commands/output.js +76 -0
- package/src/commands/scan.js +624 -0
- package/src/commands/shared.js +396 -0
- package/src/commands/validate.js +328 -0
- package/src/commands/video-shared.js +105 -0
- package/src/commands/workflow-pr.js +26 -0
- package/src/confidence/pipeline.js +186 -0
- package/src/config/diagramrc.js +79 -0
- package/src/context/build-context-pack.js +291 -0
- package/src/context/normalize-diagram-manifest.js +282 -0
- package/src/core/analysis-generation-analyze-components.js +102 -0
- package/src/core/analysis-generation-analyze-dependencies.js +33 -0
- package/src/core/analysis-generation-analyze-files.js +48 -0
- package/src/core/analysis-generation-analyze-options.js +73 -0
- package/src/core/analysis-generation-analyze.js +63 -0
- package/src/core/analysis-generation-constants.js +53 -0
- package/src/core/analysis-generation-diagrams-core-architecture.js +105 -0
- package/src/core/analysis-generation-diagrams-core-dependency.js +68 -0
- package/src/core/analysis-generation-diagrams-core-sequence.js +142 -0
- package/src/core/analysis-generation-diagrams-core-shapes.js +104 -0
- package/src/core/analysis-generation-diagrams-core.js +12 -0
- package/src/core/analysis-generation-diagrams-empty.js +68 -0
- package/src/core/analysis-generation-diagrams-erd.js +59 -0
- package/src/core/analysis-generation-diagrams-limit.js +27 -0
- package/src/core/analysis-generation-diagrams-role-ai-agent.js +103 -0
- package/src/core/analysis-generation-diagrams-role-ai-context.js +186 -0
- package/src/core/analysis-generation-diagrams-role-ai.js +11 -0
- package/src/core/analysis-generation-diagrams-role-data.js +182 -0
- package/src/core/analysis-generation-diagrams-role-helpers.js +129 -0
- package/src/core/analysis-generation-diagrams-role-security.js +129 -0
- package/src/core/analysis-generation-diagrams-role.js +25 -0
- package/src/core/analysis-generation-diagrams.js +182 -0
- package/src/core/analysis-generation-role-tags-constants.js +55 -0
- package/src/core/analysis-generation-role-tags-imports.js +32 -0
- package/src/core/analysis-generation-role-tags-infer.js +49 -0
- package/src/core/analysis-generation-role-tags-match.js +19 -0
- package/src/core/analysis-generation-role-tags.js +7 -0
- package/src/core/analysis-generation-utils-core.js +308 -0
- package/src/core/analysis-generation-utils-graph.js +321 -0
- package/src/core/analysis-generation-utils-resolution.js +76 -0
- package/src/core/analysis-generation-utils.js +9 -0
- package/src/core/analysis-generation.js +44 -0
- package/src/diagram.js +178 -1761
- package/src/formatters/console.js +198 -0
- package/src/formatters/index.js +41 -0
- package/src/formatters/json.js +113 -0
- package/src/formatters/junit.js +123 -0
- package/src/graph.js +159 -0
- package/src/incremental/cache.js +210 -0
- package/src/ir/architecture-ir.js +48 -0
- package/src/migration/evidence.js +262 -0
- package/src/migration/finalization-policy.js +35 -0
- package/src/renderers/report-html.js +265 -0
- package/src/rules/factory.js +108 -0
- package/src/rules/types/base.js +54 -0
- package/src/rules/types/import-rule.js +286 -0
- package/src/rules.js +380 -0
- package/src/schema/erd-confidence.js +56 -0
- package/src/schema/erd-extractor.js +504 -0
- package/src/schema/erd-model.js +176 -0
- package/src/schema/rules-schema.js +170 -0
- package/src/utils/suggestions.js +67 -0
- package/src/video.js +4 -5
- package/src/workflow/git-helpers.js +576 -0
- package/src/workflow/pr-command.js +694 -0
- package/src/workflow/pr-impact.js +848 -0
- 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 };
|