@brainwav/diagram 1.0.7 → 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 +180 -1760
  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,176 @@
1
+ function sanitizeToken(value) {
2
+ return String(value || '')
3
+ .trim()
4
+ .replace(/[`"'\\]/g, '')
5
+ .replace(/[^A-Za-z0-9_]/g, '_');
6
+ }
7
+
8
+ function canonicalEntityName(value) {
9
+ const token = sanitizeToken(value);
10
+ return token ? token.toUpperCase() : '';
11
+ }
12
+
13
+ function canonicalAttributeName(value) {
14
+ return sanitizeToken(value);
15
+ }
16
+
17
+ function canonicalType(value) {
18
+ const token = sanitizeToken(value);
19
+ return token || 'unknown';
20
+ }
21
+
22
+ function toKeyFlags(flags) {
23
+ const order = ['PK', 'FK', 'UK'];
24
+ const seen = new Set((Array.isArray(flags) ? flags : []).map((flag) => String(flag).toUpperCase()));
25
+ return order.filter((flag) => seen.has(flag));
26
+ }
27
+
28
+ function mergeAttributes(existingAttributes, incomingAttributes) {
29
+ const byName = new Map();
30
+
31
+ for (const attribute of [...existingAttributes, ...incomingAttributes]) {
32
+ const name = canonicalAttributeName(attribute?.name);
33
+ if (!name) continue;
34
+ const prev = byName.get(name);
35
+ const next = {
36
+ name,
37
+ type: canonicalType(attribute?.type),
38
+ nullable: Boolean(attribute?.nullable),
39
+ keyFlags: toKeyFlags(attribute?.keyFlags),
40
+ };
41
+
42
+ if (!prev) {
43
+ byName.set(name, next);
44
+ continue;
45
+ }
46
+
47
+ const mergedFlags = toKeyFlags([...(prev.keyFlags || []), ...(next.keyFlags || [])]);
48
+ byName.set(name, {
49
+ name,
50
+ type: prev.type !== 'unknown' ? prev.type : next.type,
51
+ nullable: prev.nullable || next.nullable,
52
+ keyFlags: mergedFlags,
53
+ });
54
+ }
55
+
56
+ const keyPriority = ['PK', 'FK', 'UK'];
57
+ const score = (attribute) => {
58
+ if ((attribute.keyFlags || []).length === 0) return keyPriority.length;
59
+ const positions = attribute.keyFlags
60
+ .map((flag) => keyPriority.indexOf(flag))
61
+ .filter((index) => index >= 0);
62
+ return positions.length > 0 ? Math.min(...positions) : keyPriority.length;
63
+ };
64
+
65
+ return [...byName.values()].sort((a, b) => {
66
+ const scoreDiff = score(a) - score(b);
67
+ if (scoreDiff !== 0) return scoreDiff;
68
+ return a.name.localeCompare(b.name);
69
+ });
70
+ }
71
+
72
+ function normalizeErdModel(input) {
73
+ const entitiesInput = Array.isArray(input?.entities) ? input.entities : [];
74
+ const relationshipsInput = Array.isArray(input?.relationships) ? input.relationships : [];
75
+ const diagnostics = Array.isArray(input?.diagnostics) ? input.diagnostics : [];
76
+ const sourceFiles = Array.isArray(input?.sourceFiles) ? input.sourceFiles : [];
77
+ const sourcePrecedence = Array.isArray(input?.sourcePrecedence) ? input.sourcePrecedence : [];
78
+
79
+ const entitiesByName = new Map();
80
+ for (const entity of entitiesInput) {
81
+ const name = canonicalEntityName(entity?.name);
82
+ if (!name) continue;
83
+ const source = entity?.source === 'inferred' ? 'inferred' : 'explicit';
84
+ const existing = entitiesByName.get(name);
85
+ const attributes = mergeAttributes(existing?.attributes || [], entity?.attributes || []);
86
+ const merged = {
87
+ name,
88
+ source: existing?.source === 'explicit' || source === 'explicit' ? 'explicit' : 'inferred',
89
+ attributes,
90
+ };
91
+ entitiesByName.set(name, merged);
92
+ }
93
+
94
+ const entities = [...entitiesByName.values()].sort((a, b) => a.name.localeCompare(b.name));
95
+ const entitySet = new Set(entities.map((entity) => entity.name));
96
+ const relationshipMap = new Map();
97
+
98
+ for (const relationship of relationshipsInput) {
99
+ const fromEntity = canonicalEntityName(relationship?.fromEntity);
100
+ const toEntity = canonicalEntityName(relationship?.toEntity);
101
+ if (!fromEntity || !toEntity) continue;
102
+ if (!entitySet.has(fromEntity) || !entitySet.has(toEntity)) continue;
103
+
104
+ const provenance = relationship?.provenance === 'inferred' ? 'inferred' : 'explicit';
105
+ const cardinality = String(relationship?.cardinality || '||--o{');
106
+ const relationshipKey = `${fromEntity}|${toEntity}|${cardinality}`;
107
+ const existing = relationshipMap.get(relationshipKey);
108
+ if (!existing || existing.provenance === 'inferred') {
109
+ relationshipMap.set(relationshipKey, {
110
+ fromEntity,
111
+ toEntity,
112
+ cardinality,
113
+ provenance,
114
+ });
115
+ }
116
+ }
117
+
118
+ const relationships = [...relationshipMap.values()].sort((a, b) => {
119
+ if (a.fromEntity !== b.fromEntity) return a.fromEntity.localeCompare(b.fromEntity);
120
+ if (a.toEntity !== b.toEntity) return a.toEntity.localeCompare(b.toEntity);
121
+ if (a.provenance !== b.provenance) return a.provenance.localeCompare(b.provenance);
122
+ return a.cardinality.localeCompare(b.cardinality);
123
+ });
124
+
125
+ return {
126
+ entities,
127
+ relationships,
128
+ diagnostics,
129
+ sourceFiles: [...new Set(sourceFiles)].sort(),
130
+ sourcePrecedence: [...new Set(sourcePrecedence)],
131
+ };
132
+ }
133
+
134
+ function renderErdMermaid(model, options = {}) {
135
+ const entities = Array.isArray(model?.entities) ? model.entities : [];
136
+ const relationships = Array.isArray(model?.relationships) ? model.relationships : [];
137
+ const lines = ['erDiagram'];
138
+
139
+ if (options.lowConfidenceMarker) {
140
+ const percent = typeof options.inferenceShare === 'number'
141
+ ? Math.round(options.inferenceShare * 100)
142
+ : null;
143
+ const marker = percent === null
144
+ ? 'low-confidence: inferred relationships are above preferred threshold'
145
+ : `low-confidence: inferred relationships are ${percent}% of all relationships`;
146
+ lines.push(` %% ${marker}`);
147
+ }
148
+
149
+ for (const entity of entities) {
150
+ lines.push(` ${entity.name} {`);
151
+ for (const attribute of entity.attributes || []) {
152
+ const flags = (attribute.keyFlags || []).join(' ');
153
+ const suffix = flags ? ` ${flags}` : '';
154
+ lines.push(` ${canonicalType(attribute.type)} ${canonicalAttributeName(attribute.name)}${suffix}`);
155
+ }
156
+ lines.push(' }');
157
+ }
158
+
159
+ if (relationships.length > 0) {
160
+ lines.push('');
161
+ }
162
+
163
+ for (const relationship of relationships) {
164
+ lines.push(
165
+ ` ${relationship.fromEntity} ${relationship.cardinality} ${relationship.toEntity} : ${relationship.provenance}`
166
+ );
167
+ }
168
+
169
+ return lines.join('\n');
170
+ }
171
+
172
+ module.exports = {
173
+ canonicalEntityName,
174
+ normalizeErdModel,
175
+ renderErdMermaid,
176
+ };
@@ -0,0 +1,170 @@
1
+ const { z } = require('zod');
2
+
3
+ /**
4
+ * Zod schema for architecture rules validation
5
+ * Used to validate .architecture.yml files
6
+ */
7
+
8
+ // Single rule schema
9
+ const ruleSchema = z.object({
10
+ name: z.string()
11
+ .min(1, 'Rule name is required')
12
+ .max(100, 'Rule name must be less than 100 characters'),
13
+
14
+ description: z.string()
15
+ .max(500, 'Description must be less than 500 characters')
16
+ .optional(),
17
+
18
+ baseline: z.number()
19
+ .int('Baseline must be an integer')
20
+ .min(0, 'Baseline must be non-negative')
21
+ .optional()
22
+ .describe('Accepted violation count. New violations beyond this will fail.'),
23
+
24
+ layer: z.union([
25
+ z.string().min(1, 'Layer pattern is required'),
26
+ z.array(z.string().min(1, 'Layer pattern cannot be empty'))
27
+ .min(1, 'At least one layer pattern is required')
28
+ ]),
29
+
30
+ // Import constraint rules
31
+ must_not_import_from: z.array(z.string())
32
+ .optional()
33
+ .describe('List of patterns that files in this layer must not import'),
34
+
35
+ may_import_from: z.array(z.string())
36
+ .optional()
37
+ .describe('Whitelist of allowed import patterns'),
38
+
39
+ must_import_from: z.array(z.string())
40
+ .optional()
41
+ .describe('List of patterns that files in this layer must import'),
42
+
43
+ // Inward-only constraint: files in this layer cannot be imported by other inward_only layers
44
+ inward_only: z.boolean()
45
+ .optional()
46
+ .describe('If true, other inward_only layers cannot import from this layer'),
47
+
48
+ }).refine(
49
+ (data) => {
50
+ // At least one constraint must be specified
51
+ // Must check for non-empty arrays (empty array is truthy but invalid)
52
+ const hasMustNotImport = Array.isArray(data.must_not_import_from) && data.must_not_import_from.length > 0;
53
+ const hasMayImport = Array.isArray(data.may_import_from) && data.may_import_from.length > 0;
54
+ const hasMustImport = Array.isArray(data.must_import_from) && data.must_import_from.length > 0;
55
+ const hasInwardOnly = data.inward_only === true;
56
+ return hasMustNotImport || hasMayImport || hasMustImport || hasInwardOnly;
57
+ },
58
+ {
59
+ message: 'Rule must specify at least one constraint (must_not_import_from, may_import_from, must_import_from, or inward_only)',
60
+ path: ['constraints']
61
+ }
62
+ ).refine(
63
+ (data) => {
64
+ // Validate that layer patterns don't contain '..' or absolute paths
65
+ const patterns = Array.isArray(data.layer) ? data.layer : [data.layer];
66
+ return patterns.every(p => !p.includes('..') && !p.startsWith('/'));
67
+ },
68
+ {
69
+ message: 'Layer patterns cannot contain ".." or absolute paths',
70
+ path: ['layer']
71
+ }
72
+ );
73
+
74
+ // Full configuration schema
75
+ const configSchema = z.object({
76
+ version: z.string()
77
+ .regex(/^[0-9]+\.[0-9]+$/, 'Version must be in format "X.Y"')
78
+ .default('1.0'),
79
+
80
+ extends: z.string()
81
+ .optional()
82
+ .describe('Path to base configuration file to extend'),
83
+
84
+ rules: z.array(ruleSchema)
85
+ .min(1, 'At least one rule is required'),
86
+ }).strict(); // Reject unknown properties for safety
87
+
88
+ /**
89
+ * Validate configuration against schema
90
+ * @param {Object} config - Parsed configuration
91
+ * @returns {Object} Validation result
92
+ */
93
+ function validateConfig(config) {
94
+ const result = configSchema.safeParse(config);
95
+
96
+ if (!result.success) {
97
+ const errors = result.error.issues.map(err => ({
98
+ path: err.path.join('.'),
99
+ message: err.message,
100
+ value: String(err.received).slice(0, 100) // Truncate large values
101
+ }));
102
+
103
+ return {
104
+ valid: false,
105
+ errors
106
+ };
107
+ }
108
+
109
+ return {
110
+ valid: true,
111
+ data: result.data
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Validate a single rule
117
+ * @param {Object} rule - Rule configuration
118
+ * @returns {Object} Validation result
119
+ */
120
+ function validateRule(rule) {
121
+ const result = ruleSchema.safeParse(rule);
122
+
123
+ if (!result.success) {
124
+ return {
125
+ valid: false,
126
+ errors: result.error.issues.map(err => ({
127
+ path: err.path.join('.'),
128
+ message: err.message
129
+ }))
130
+ };
131
+ }
132
+
133
+ return {
134
+ valid: true,
135
+ data: result.data
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Get default configuration
141
+ * @returns {Object}
142
+ */
143
+ function getDefaultConfig() {
144
+ return {
145
+ version: '1.0',
146
+ rules: [
147
+ {
148
+ name: 'Domain isolation',
149
+ description: 'Domain logic should not depend on UI',
150
+ layer: 'src/domain',
151
+ must_not_import_from: ['src/ui', 'src/components']
152
+ },
153
+ {
154
+ name: 'API contract',
155
+ description: 'API routes only use domain and shared',
156
+ layer: 'src/api',
157
+ may_import_from: ['src/domain', 'src/shared', 'src/types'],
158
+ must_not_import_from: ['src/ui']
159
+ }
160
+ ]
161
+ };
162
+ }
163
+
164
+ module.exports = {
165
+ configSchema,
166
+ ruleSchema,
167
+ validateConfig,
168
+ validateRule,
169
+ getDefaultConfig
170
+ };
@@ -0,0 +1,67 @@
1
+ const chalk = require('chalk');
2
+
3
+ /**
4
+ * Compute the Levenshtein edit distance between two strings.
5
+ *
6
+ * @param {string} a - First string to compare.
7
+ * @param {string} b - Second string to compare.
8
+ * @returns {number} The minimum number of single-character insertions, deletions or substitutions required to transform `a` into `b`.
9
+ */
10
+ function levenshtein(a, b) {
11
+ const rows = a.length + 1;
12
+ const cols = b.length + 1;
13
+ const matrix = Array.from({ length: rows }, () => Array(cols).fill(0));
14
+
15
+ for (let i = 0; i < rows; i += 1) matrix[i][0] = i;
16
+ for (let j = 0; j < cols; j += 1) matrix[0][j] = j;
17
+
18
+ for (let i = 1; i < rows; i += 1) {
19
+ for (let j = 1; j < cols; j += 1) {
20
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
21
+ matrix[i][j] = Math.min(
22
+ matrix[i - 1][j] + 1,
23
+ matrix[i][j - 1] + 1,
24
+ matrix[i - 1][j - 1] + cost
25
+ );
26
+ }
27
+ }
28
+
29
+ return matrix[a.length][b.length];
30
+ }
31
+
32
+ /**
33
+ * Selects the closest matching option to the given input from an array of candidates.
34
+ *
35
+ * Compares `input` (stringified and lowercased) against each element of `options` (stringified and lowercased)
36
+ * using Levenshtein edit distance and returns the candidate with the lowest distance only if that distance
37
+ * is within an acceptance threshold proportional to the input length; otherwise returns `null`.
38
+ *
39
+ * @param {*} input - Value to match; it will be converted to a string for comparison.
40
+ * @param {Array<*>} options - Array of candidate values (each will be converted to a string).
41
+ * @returns {*} The best matching option from `options` if its edit distance is acceptable, `null` otherwise.
42
+ */
43
+ function findClosestMatch(input, options) {
44
+ if (!input || !Array.isArray(options) || options.length === 0) return null;
45
+
46
+ const normalizedInput = String(input).toLowerCase();
47
+ const scored = options
48
+ .map((option) => ({ option, score: levenshtein(normalizedInput, String(option).toLowerCase()) }))
49
+ .sort((a, b) => a.score - b.score);
50
+
51
+ const best = scored[0];
52
+ return best.score <= Math.max(2, Math.floor(normalizedInput.length / 3)) ? best.option : null;
53
+ }
54
+
55
+ /**
56
+ * Format a suggestion message for display.
57
+ * @param {string} suggestion - The suggested alternative to present to the user.
58
+ * @returns {string} A grey-coloured string in the form " Did you mean: <suggestion>".
59
+ */
60
+ function formatSuggestion(suggestion) {
61
+ return chalk.gray(` Did you mean: ${suggestion}`);
62
+ }
63
+
64
+ module.exports = {
65
+ findClosestMatch,
66
+ formatSuggestion,
67
+ };
package/src/video.js CHANGED
@@ -2,7 +2,6 @@ const { chromium } = require('playwright');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const os = require('os');
5
- const crypto = require('crypto');
6
5
  const { execFileSync } = require('child_process');
7
6
  const { pathToFileURL } = require('url');
8
7
  const chalk = require('chalk');
@@ -309,8 +308,8 @@ ${escapedCode}
309
308
  browser = await chromium.launch({ timeout: 60000 });
310
309
  const page = await browser.newPage();
311
310
 
312
- const randomId = crypto.randomBytes(16).toString('hex');
313
- tempFile = path.join(os.tmpdir(), `diagram-${Date.now()}-${randomId}.html`);
311
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'diagram-svg-'), { mode: 0o700 });
312
+ tempFile = path.join(tempDir, 'diagram.html');
314
313
  fs.writeFileSync(tempFile, htmlContent);
315
314
 
316
315
  const fileUrl = pathToFileURL(tempFile).href;
@@ -362,7 +361,7 @@ ${escapedCode}
362
361
  browser = null;
363
362
 
364
363
  if (tempFile && fs.existsSync(tempFile)) {
365
- fs.unlinkSync(tempFile);
364
+ try { fs.rmSync(path.dirname(tempFile), { recursive: true, force: true }); } catch (e) {}
366
365
  }
367
366
 
368
367
  if (svgContent) {
@@ -379,7 +378,7 @@ ${escapedCode}
379
378
  try { await browser.close(); } catch (e) {}
380
379
  }
381
380
  if (tempFile && fs.existsSync(tempFile)) {
382
- try { fs.unlinkSync(tempFile); } catch (e) {}
381
+ try { fs.rmSync(path.dirname(tempFile), { recursive: true, force: true }); } catch (e) {}
383
382
  }
384
383
  throw error;
385
384
  }