@brainwav/diagram 1.0.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/LICENSE +21 -0
- package/README.md +300 -0
- package/package.json +57 -0
- package/src/diagram.js +1118 -0
- package/src/video.js +388 -0
package/src/diagram.js
ADDED
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { glob } = require('glob');
|
|
7
|
+
const chalk = require('chalk');
|
|
8
|
+
const { spawn, execFileSync } = require('child_process');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const zlib = require('zlib');
|
|
12
|
+
const { getOpenCommand, getNpxCommandCandidates } = require('./utils/commands');
|
|
13
|
+
|
|
14
|
+
// Video generation (lazy loaded)
|
|
15
|
+
let videoModule;
|
|
16
|
+
function getVideoModule() {
|
|
17
|
+
if (!videoModule) {
|
|
18
|
+
try {
|
|
19
|
+
videoModule = require('./video.js');
|
|
20
|
+
} catch (e) {
|
|
21
|
+
console.error(chalk.red('❌ Video generation requires Playwright. Install with: npm install playwright'));
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return videoModule;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const program = new Command();
|
|
29
|
+
|
|
30
|
+
// Utility functions
|
|
31
|
+
function detectLanguage(filePath) {
|
|
32
|
+
if (typeof filePath !== 'string') return 'unknown';
|
|
33
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
34
|
+
const map = {
|
|
35
|
+
'.ts': 'typescript', '.tsx': 'typescript',
|
|
36
|
+
'.mts': 'typescript', '.cts': 'typescript',
|
|
37
|
+
'.js': 'javascript', '.jsx': 'javascript',
|
|
38
|
+
'.mjs': 'javascript', '.cjs': 'javascript',
|
|
39
|
+
'.py': 'python', '.go': 'go', '.rs': 'rust',
|
|
40
|
+
'.java': 'java', '.rb': 'ruby', '.php': 'php',
|
|
41
|
+
};
|
|
42
|
+
return map[ext] || 'unknown';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function inferType(filePath, content) {
|
|
46
|
+
const base = path.basename(filePath).toLowerCase();
|
|
47
|
+
if (base.includes('service')) return 'service';
|
|
48
|
+
if (base.includes('component') || base.endsWith('.tsx') || base.endsWith('.jsx')) return 'component';
|
|
49
|
+
if (content.includes('class ') && content.includes('extends')) return 'class';
|
|
50
|
+
if (content.includes('export default function') || content.includes('export function')) return 'function';
|
|
51
|
+
if (content.includes('module.exports') || content.includes('export ')) return 'module';
|
|
52
|
+
return 'file';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractImports(content, lang) {
|
|
56
|
+
const imports = [];
|
|
57
|
+
if (lang === 'typescript' || lang === 'javascript') {
|
|
58
|
+
// ES6 imports with timeout protection against ReDoS
|
|
59
|
+
const es6Regex = /import\s+(?:(?:\{[^}]*?\}|\*\s+as\s+\w+|\w+)\s+from\s+)?["']([^"']+)["']/g;
|
|
60
|
+
const es6 = [...content.matchAll(es6Regex)];
|
|
61
|
+
es6.forEach(m => imports.push(m[1]));
|
|
62
|
+
// CommonJS requires
|
|
63
|
+
const cjs = [...content.matchAll(/require\s*\(\s*["']([^"']+)["']\s*\)/g)];
|
|
64
|
+
cjs.forEach(m => imports.push(m[1]));
|
|
65
|
+
// Dynamic imports
|
|
66
|
+
const dynamic = [...content.matchAll(/import\s*\(\s*["']([^"']+)["']\s*\)/g)];
|
|
67
|
+
dynamic.forEach(m => imports.push(m[1]));
|
|
68
|
+
} else if (lang === 'python') {
|
|
69
|
+
const py = [...content.matchAll(/(?:from|import)\s+([\w.]+)/g)];
|
|
70
|
+
py.forEach(m => imports.push(m[1]));
|
|
71
|
+
} else if (lang === 'go') {
|
|
72
|
+
const go = [...content.matchAll(/import\s+(?:\(\s*)?["']([^"']+)["']/g)];
|
|
73
|
+
go.forEach(m => imports.push(m[1]));
|
|
74
|
+
}
|
|
75
|
+
return imports;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extract imports with line number information
|
|
80
|
+
* @param {string} content - File content
|
|
81
|
+
* @param {string} lang - Language
|
|
82
|
+
* @returns {Array<{path: string, line: number}>}
|
|
83
|
+
*/
|
|
84
|
+
function extractImportsWithPositions(content, lang) {
|
|
85
|
+
const imports = [];
|
|
86
|
+
const lines = content.split(/\r?\n/);
|
|
87
|
+
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
const line = lines[i];
|
|
90
|
+
const lineNum = i + 1;
|
|
91
|
+
|
|
92
|
+
if (lang === 'typescript' || lang === 'javascript') {
|
|
93
|
+
// ES6 imports
|
|
94
|
+
const es6 = line.match(/import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+)?["']([^"']+)["']/);
|
|
95
|
+
if (es6) {
|
|
96
|
+
imports.push({ path: es6[1], line: lineNum });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// CommonJS requires
|
|
101
|
+
const cjs = line.match(/require\s*\(\s*["']([^"']+)["']\s*\)/);
|
|
102
|
+
if (cjs) {
|
|
103
|
+
imports.push({ path: cjs[1], line: lineNum });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Dynamic imports
|
|
108
|
+
const dynamic = line.match(/import\s*\(\s*["']([^"']+)["']\s*\)/);
|
|
109
|
+
if (dynamic) {
|
|
110
|
+
imports.push({ path: dynamic[1], line: lineNum });
|
|
111
|
+
}
|
|
112
|
+
} else if (lang === 'python') {
|
|
113
|
+
const py = line.match(/(?:from|import)\s+([\w.]+)/);
|
|
114
|
+
if (py) {
|
|
115
|
+
imports.push({ path: py[1], line: lineNum });
|
|
116
|
+
}
|
|
117
|
+
} else if (lang === 'go') {
|
|
118
|
+
const go = line.match(/import\s+(?:\(\s*)?["']([^"']+)["']/);
|
|
119
|
+
if (go) {
|
|
120
|
+
imports.push({ path: go[1], line: lineNum });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return imports;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sanitize(name) {
|
|
129
|
+
// Ensure unique, valid mermaid ID
|
|
130
|
+
const base = name.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^[0-9]/, '_$&');
|
|
131
|
+
// Add hash suffix to prevent collisions (using SHA-256)
|
|
132
|
+
const hash = crypto.createHash('sha256').update(name).digest('hex').slice(0, 8);
|
|
133
|
+
return `${base}_${hash}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function escapeMermaid(str) {
|
|
137
|
+
if (!str) return '';
|
|
138
|
+
return str
|
|
139
|
+
.replace(/"/g, '\\"')
|
|
140
|
+
.replace(/\[/g, '\\[')
|
|
141
|
+
.replace(/\]/g, '\\]')
|
|
142
|
+
.replace(/\(/g, '\\(')
|
|
143
|
+
.replace(/\)/g, '\\)')
|
|
144
|
+
.replace(/#/g, '\\#')
|
|
145
|
+
.replace(/</g, '\\<')
|
|
146
|
+
.replace(/>/g, '\\>')
|
|
147
|
+
.replace(/\{/g, '\\{')
|
|
148
|
+
.replace(/\}/g, '\\}')
|
|
149
|
+
.replace(/\|/g, '\\|');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizePath(inputPath) {
|
|
153
|
+
// Always use forward slashes for consistency
|
|
154
|
+
return inputPath.replace(/\\/g, '/');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const IMPORT_RESOLUTION_SUFFIXES = [
|
|
158
|
+
'',
|
|
159
|
+
'.ts',
|
|
160
|
+
'.tsx',
|
|
161
|
+
'.js',
|
|
162
|
+
'.jsx',
|
|
163
|
+
'.mjs',
|
|
164
|
+
'.mts',
|
|
165
|
+
'.cts',
|
|
166
|
+
'/index.ts',
|
|
167
|
+
'/index.tsx',
|
|
168
|
+
'/index.js',
|
|
169
|
+
'/index.jsx',
|
|
170
|
+
'/index.mjs',
|
|
171
|
+
'/index.mts',
|
|
172
|
+
'/index.cts'
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
function toComparablePath(p) {
|
|
176
|
+
return normalizePath(String(p || '')).replace(/^\.\//, '');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getImportPath(importInfo) {
|
|
180
|
+
if (typeof importInfo === 'string') return importInfo;
|
|
181
|
+
if (importInfo && typeof importInfo.path === 'string') return importInfo.path;
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function resolveInternalImport(fromFilePath, importPath, rootPath) {
|
|
186
|
+
if (typeof fromFilePath !== 'string' || typeof importPath !== 'string') {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
if (!importPath.startsWith('.')) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const fromDir = path.dirname(fromFilePath);
|
|
194
|
+
|
|
195
|
+
// In analysis mode we can enforce root boundaries with absolute paths
|
|
196
|
+
if (rootPath) {
|
|
197
|
+
const absoluteTarget = path.resolve(rootPath, fromDir, importPath);
|
|
198
|
+
const relativeToRoot = toComparablePath(path.relative(rootPath, absoluteTarget));
|
|
199
|
+
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return relativeToRoot;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Fallback for precomputed data without root path
|
|
206
|
+
const posixFromDir = normalizePath(fromDir);
|
|
207
|
+
const posixImport = normalizePath(importPath);
|
|
208
|
+
return toComparablePath(path.posix.normalize(path.posix.join(posixFromDir, posixImport)));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function findComponentByResolvedPath(components, resolvedPath) {
|
|
212
|
+
const comparablePath = toComparablePath(resolvedPath);
|
|
213
|
+
const candidates = new Set(
|
|
214
|
+
IMPORT_RESOLUTION_SUFFIXES.map(suffix => toComparablePath(comparablePath + suffix))
|
|
215
|
+
);
|
|
216
|
+
return components.find(c => candidates.has(toComparablePath(c.filePath)));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function getExternalPackageName(importPath) {
|
|
220
|
+
if (typeof importPath !== 'string') return null;
|
|
221
|
+
if (!importPath) return null;
|
|
222
|
+
if (importPath.startsWith('@')) {
|
|
223
|
+
const [scope, pkg] = importPath.split('/');
|
|
224
|
+
return scope && pkg ? `${scope}/${pkg}` : scope || null;
|
|
225
|
+
}
|
|
226
|
+
return importPath.split('/')[0] || null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Analysis
|
|
230
|
+
async function analyze(rootPath, options) {
|
|
231
|
+
// Validate maxFiles with strict parsing
|
|
232
|
+
let maxFiles = parseInt(options.maxFiles, 10);
|
|
233
|
+
if (isNaN(maxFiles) || maxFiles < 1 || maxFiles > 10000) {
|
|
234
|
+
maxFiles = 100;
|
|
235
|
+
}
|
|
236
|
+
// Extra safety: ensure within safe bounds
|
|
237
|
+
maxFiles = Math.min(Math.max(maxFiles, 1), 10000);
|
|
238
|
+
|
|
239
|
+
// Validate patterns type
|
|
240
|
+
let patterns = ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.py', '**/*.go', '**/*.rs'];
|
|
241
|
+
if (options.patterns) {
|
|
242
|
+
if (typeof options.patterns !== 'string') {
|
|
243
|
+
throw new TypeError('patterns must be a string');
|
|
244
|
+
}
|
|
245
|
+
patterns = options.patterns.split(',');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let exclude = ['node_modules/**', '.git/**', 'dist/**', 'build/**', '*.test.*', '*.spec.*'];
|
|
249
|
+
if (options.exclude) {
|
|
250
|
+
if (typeof options.exclude !== 'string') {
|
|
251
|
+
throw new TypeError('exclude must be a string');
|
|
252
|
+
}
|
|
253
|
+
exclude = options.exclude.split(',');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const files = [];
|
|
257
|
+
for (const pattern of patterns) {
|
|
258
|
+
if (!pattern || pattern.trim() === '') continue;
|
|
259
|
+
try {
|
|
260
|
+
const matches = await glob(pattern.trim(), { cwd: rootPath, absolute: true, ignore: exclude });
|
|
261
|
+
files.push(...matches);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
console.warn(chalk.yellow(`⚠️ Invalid pattern: ${pattern}`));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const uniqueFiles = [...new Set(files)].slice(0, maxFiles);
|
|
268
|
+
const components = [];
|
|
269
|
+
const languages = {};
|
|
270
|
+
const directories = new Set();
|
|
271
|
+
const entryPoints = [];
|
|
272
|
+
const seenNames = new Set();
|
|
273
|
+
|
|
274
|
+
for (const filePath of uniqueFiles) {
|
|
275
|
+
try {
|
|
276
|
+
// Security: Check file size before reading
|
|
277
|
+
const stats = fs.statSync(filePath);
|
|
278
|
+
if (stats.size > 10 * 1024 * 1024) { // 10MB limit
|
|
279
|
+
console.warn(chalk.yellow(`⚠️ Skipping large file: ${path.basename(filePath)} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`));
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
283
|
+
const lang = detectLanguage(filePath);
|
|
284
|
+
let rel = normalizePath(path.relative(rootPath, filePath));
|
|
285
|
+
const dir = path.dirname(rel);
|
|
286
|
+
if (dir === '.') {
|
|
287
|
+
rel = './' + rel;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
languages[lang] = (languages[lang] || 0) + 1;
|
|
291
|
+
if (dir !== '.') directories.add(dir);
|
|
292
|
+
|
|
293
|
+
// Support more entry point patterns (with escaped regex)
|
|
294
|
+
const entryPattern = /\/(index|main|app|server)\.(ts|js|tsx|jsx|mts|mjs|py|go|rs)$/i;
|
|
295
|
+
if (entryPattern.test(rel)) {
|
|
296
|
+
entryPoints.push(rel);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Handle duplicate names
|
|
300
|
+
let baseName = path.basename(filePath, path.extname(filePath));
|
|
301
|
+
let uniqueName = baseName;
|
|
302
|
+
let counter = 1;
|
|
303
|
+
while (seenNames.has(uniqueName)) {
|
|
304
|
+
uniqueName = `${baseName}_${counter}`;
|
|
305
|
+
counter++;
|
|
306
|
+
}
|
|
307
|
+
seenNames.add(uniqueName);
|
|
308
|
+
|
|
309
|
+
components.push({
|
|
310
|
+
name: uniqueName,
|
|
311
|
+
originalName: baseName,
|
|
312
|
+
filePath: rel,
|
|
313
|
+
type: inferType(filePath, content),
|
|
314
|
+
imports: extractImportsWithPositions(content, lang),
|
|
315
|
+
directory: dir,
|
|
316
|
+
});
|
|
317
|
+
} catch (e) {
|
|
318
|
+
if (process.env.DEBUG) {
|
|
319
|
+
// Sanitize path to avoid info disclosure - show only basename
|
|
320
|
+
const safePath = path.basename(filePath);
|
|
321
|
+
console.error(chalk.gray(`Skipped ${safePath}: ${e.message}`));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Resolve dependencies
|
|
327
|
+
for (const comp of components) {
|
|
328
|
+
comp.dependencies = [];
|
|
329
|
+
for (const imp of comp.imports) {
|
|
330
|
+
const importPath = getImportPath(imp);
|
|
331
|
+
if (!importPath) continue;
|
|
332
|
+
const resolved = resolveInternalImport(comp.filePath, importPath, rootPath);
|
|
333
|
+
if (!resolved) continue;
|
|
334
|
+
const dep = findComponentByResolvedPath(components, resolved);
|
|
335
|
+
if (dep) comp.dependencies.push(dep.name);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { rootPath, components, entryPoints, languages, directories: [...directories].sort() };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Diagram generators
|
|
343
|
+
function generateArchitecture(data, focus) {
|
|
344
|
+
if (!data || !Array.isArray(data.components)) {
|
|
345
|
+
return 'graph TD\n Note["No data available"]';
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const lines = ['graph TD'];
|
|
349
|
+
const focusNorm = focus ? normalizePath(focus) : null;
|
|
350
|
+
// Use exact path matching for focus
|
|
351
|
+
const comps = focusNorm
|
|
352
|
+
? data.components.filter(c => {
|
|
353
|
+
const normalizedFilePath = normalizePath(c.filePath || '');
|
|
354
|
+
const normalizedName = c.name || '';
|
|
355
|
+
// Check if focus is at path boundary
|
|
356
|
+
return normalizedFilePath === focusNorm ||
|
|
357
|
+
normalizedFilePath.startsWith(focusNorm + '/') ||
|
|
358
|
+
normalizedName === focusNorm;
|
|
359
|
+
})
|
|
360
|
+
: data.components;
|
|
361
|
+
|
|
362
|
+
if (comps.length === 0) {
|
|
363
|
+
lines.push(' Note["No components found' + (focus ? ' for focus: ' + escapeMermaid(focus) : '') + '"]');
|
|
364
|
+
return lines.join('\n');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const byDir = new Map();
|
|
368
|
+
for (const c of comps) {
|
|
369
|
+
const dir = c.directory || 'root';
|
|
370
|
+
if (!byDir.has(dir)) byDir.set(dir, []);
|
|
371
|
+
byDir.get(dir).push(c);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (const [dir, items] of byDir) {
|
|
375
|
+
if (items.length === 0) continue;
|
|
376
|
+
lines.push(` subgraph ${sanitize(dir)}["${escapeMermaid(dir)}"]`);
|
|
377
|
+
for (const c of items) {
|
|
378
|
+
const shape = c.type === 'service' ? '[[' : '[';
|
|
379
|
+
const end = c.type === 'service' ? ']]' : ']';
|
|
380
|
+
lines.push(` ${sanitize(c.name)}${shape}"${escapeMermaid(c.originalName)}"${end}`);
|
|
381
|
+
}
|
|
382
|
+
lines.push(' end');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
for (const c of comps) {
|
|
386
|
+
for (const d of c.dependencies) {
|
|
387
|
+
if (comps.find(x => x.name === d)) {
|
|
388
|
+
lines.push(` ${sanitize(c.name)} --> ${sanitize(d)}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Track styled nodes to avoid duplicates
|
|
394
|
+
const styledNodes = new Set();
|
|
395
|
+
for (const ep of data.entryPoints) {
|
|
396
|
+
const epName = path.basename(ep, path.extname(ep));
|
|
397
|
+
const comp = comps.find(c => c.originalName === epName);
|
|
398
|
+
if (comp && !styledNodes.has(comp.name)) {
|
|
399
|
+
lines.push(` style ${sanitize(comp.name)} fill:#4f46e5,color:#fff`);
|
|
400
|
+
styledNodes.add(comp.name);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return lines.join('\n');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function generateSequence(data) {
|
|
408
|
+
if (!data || !Array.isArray(data.components)) {
|
|
409
|
+
return 'sequenceDiagram\n Note over User,App: No data available';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const lines = ['sequenceDiagram'];
|
|
413
|
+
// Use configurable limit with warning
|
|
414
|
+
const MAX_SERVICES = 6;
|
|
415
|
+
const services = data.components.filter(c => c.type === 'service' || c.name === 'index').slice(0, MAX_SERVICES);
|
|
416
|
+
if (data.components.length > MAX_SERVICES) {
|
|
417
|
+
console.warn(chalk.yellow(`⚠️ Sequence diagram limited to ${MAX_SERVICES} services`));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (services.length === 0) {
|
|
421
|
+
lines.push(' Note over User,App: No services detected');
|
|
422
|
+
return lines.join('\n');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Track used sanitized names to prevent collisions
|
|
426
|
+
const usedNames = new Map();
|
|
427
|
+
const getSafeName = (service) => {
|
|
428
|
+
const base = sanitize(service.name);
|
|
429
|
+
if (!usedNames.has(base)) {
|
|
430
|
+
usedNames.set(base, service.name);
|
|
431
|
+
return base;
|
|
432
|
+
}
|
|
433
|
+
// Collision - append number
|
|
434
|
+
let i = 1;
|
|
435
|
+
let newName = `${base}_${i}`;
|
|
436
|
+
while (usedNames.has(newName)) {
|
|
437
|
+
i++;
|
|
438
|
+
newName = `${base}_${i}`;
|
|
439
|
+
}
|
|
440
|
+
usedNames.set(newName, service.name);
|
|
441
|
+
return newName;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const safeNames = services.map(getSafeName);
|
|
445
|
+
|
|
446
|
+
for (let i = 0; i < services.length; i++) {
|
|
447
|
+
lines.push(` participant ${safeNames[i]} as ${escapeMermaid(services[i].originalName)}`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
for (let i = 0; i < services.length - 1; i++) {
|
|
451
|
+
lines.push(` ${safeNames[i]}->>${safeNames[i+1]}: calls`);
|
|
452
|
+
}
|
|
453
|
+
return lines.join('\n');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function generateDependency(data, focus) {
|
|
457
|
+
if (!data || !Array.isArray(data.components)) {
|
|
458
|
+
return 'graph LR\n Note["No data available"]';
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const lines = ['graph LR'];
|
|
462
|
+
const focusNorm = focus ? normalizePath(focus) : null;
|
|
463
|
+
const comps = focusNorm ? data.components.filter(c => {
|
|
464
|
+
const normalizedPath = normalizePath(c.filePath || '');
|
|
465
|
+
return normalizedPath === focusNorm || normalizedPath.startsWith(focusNorm + '/');
|
|
466
|
+
}) : data.components;
|
|
467
|
+
|
|
468
|
+
if (comps.length === 0) {
|
|
469
|
+
lines.push(' Note["No components found"]');
|
|
470
|
+
return lines.join('\n');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const external = new Set();
|
|
474
|
+
|
|
475
|
+
for (const c of comps) {
|
|
476
|
+
const imports = Array.isArray(c.imports) ? c.imports : [];
|
|
477
|
+
for (const importInfo of imports) {
|
|
478
|
+
const importPath = getImportPath(importInfo);
|
|
479
|
+
if (!importPath) continue;
|
|
480
|
+
if (!importPath.startsWith('.')) {
|
|
481
|
+
const pkg = getExternalPackageName(importPath);
|
|
482
|
+
if (pkg) {
|
|
483
|
+
external.add(pkg);
|
|
484
|
+
lines.push(` ${sanitize(pkg)}["${escapeMermaid(pkg)}"] --> ${sanitize(c.name)}`);
|
|
485
|
+
}
|
|
486
|
+
} else {
|
|
487
|
+
const basePath = resolveInternalImport(c.filePath, importPath, data.rootPath);
|
|
488
|
+
if (!basePath) continue;
|
|
489
|
+
const resolved = findComponentByResolvedPath(comps, basePath);
|
|
490
|
+
if (resolved) lines.push(` ${sanitize(c.name)} --> ${sanitize(resolved.name)}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
for (const e of external) {
|
|
496
|
+
lines.push(` style ${sanitize(e)} fill:#f59e0b,color:#fff`);
|
|
497
|
+
}
|
|
498
|
+
return lines.join('\n');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function generateClass(data) {
|
|
502
|
+
if (!data || !Array.isArray(data.components)) {
|
|
503
|
+
return 'classDiagram\n note "No data available"';
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const lines = ['classDiagram'];
|
|
507
|
+
const MAX_CLASSES = 20;
|
|
508
|
+
const classes = data.components.filter(c => c.type === 'class' || c.type === 'component').slice(0, MAX_CLASSES);
|
|
509
|
+
if (data.components.length > MAX_CLASSES) {
|
|
510
|
+
console.warn(chalk.yellow(`⚠️ Class diagram limited to ${MAX_CLASSES} classes`));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (classes.length === 0) {
|
|
514
|
+
lines.push(' note "No classes found"');
|
|
515
|
+
return lines.join('\n');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
for (const c of classes) {
|
|
519
|
+
lines.push(` class ${sanitize(c.name)} {`);
|
|
520
|
+
lines.push(` +${escapeMermaid(c.filePath)}`);
|
|
521
|
+
lines.push(' }');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
for (const c of classes) {
|
|
525
|
+
const deps = (c.dependencies || []).slice(0, 3);
|
|
526
|
+
for (const d of deps) {
|
|
527
|
+
if (classes.find(x => x.name === d)) {
|
|
528
|
+
lines.push(` ${sanitize(c.name)} --> ${sanitize(d)}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return lines.join('\n');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function generateFlow(data) {
|
|
536
|
+
if (!data || !Array.isArray(data.components)) {
|
|
537
|
+
return 'flowchart TD\n Start(["Start"])\n End(["End"])\n Start --> End';
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const lines = ['flowchart TD'];
|
|
541
|
+
lines.push(' Start(["Start"])');
|
|
542
|
+
const MAX_COMPONENTS = 8;
|
|
543
|
+
const comps = data.components.slice(0, MAX_COMPONENTS);
|
|
544
|
+
if (data.components.length > MAX_COMPONENTS) {
|
|
545
|
+
console.warn(chalk.yellow(`⚠️ Flow diagram limited to ${MAX_COMPONENTS} components`));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (comps.length === 0) {
|
|
549
|
+
lines.push(' End(["End"])');
|
|
550
|
+
lines.push(' Start --> End');
|
|
551
|
+
return lines.join('\n');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
let prev = 'Start';
|
|
555
|
+
for (const c of comps) {
|
|
556
|
+
const safeName = sanitize(c.name);
|
|
557
|
+
lines.push(` ${safeName}["${escapeMermaid(c.originalName)}"]`);
|
|
558
|
+
lines.push(` ${prev} --> ${safeName}`);
|
|
559
|
+
prev = safeName;
|
|
560
|
+
}
|
|
561
|
+
lines.push(' End(["End"])');
|
|
562
|
+
lines.push(` ${prev} --> End`);
|
|
563
|
+
return lines.join('\n');
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function generate(data, type, focus) {
|
|
567
|
+
switch (type) {
|
|
568
|
+
case 'architecture': return generateArchitecture(data, focus);
|
|
569
|
+
case 'sequence': return generateSequence(data);
|
|
570
|
+
case 'dependency': return generateDependency(data, focus);
|
|
571
|
+
case 'class': return generateClass(data);
|
|
572
|
+
case 'flow': return generateFlow(data);
|
|
573
|
+
default:
|
|
574
|
+
console.warn(chalk.yellow(`⚠️ Unknown diagram type "${type}", using architecture`));
|
|
575
|
+
return generateArchitecture(data, focus);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// URL shortening for large diagrams
|
|
580
|
+
function createMermaidUrl(mermaidCode) {
|
|
581
|
+
// If diagram is very large, provide text file instead
|
|
582
|
+
if (mermaidCode.length > 5000) {
|
|
583
|
+
return { url: null, large: true };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
const payload = JSON.stringify({ code: mermaidCode });
|
|
588
|
+
const compressed = zlib.deflateSync(payload);
|
|
589
|
+
const encoded = compressed
|
|
590
|
+
.toString('base64')
|
|
591
|
+
.replace(/\+/g, '-')
|
|
592
|
+
.replace(/\//g, '_')
|
|
593
|
+
.replace(/=+$/g, '');
|
|
594
|
+
const url = `https://mermaid.live/edit#pako:${encoded}`;
|
|
595
|
+
|
|
596
|
+
// Check if URL is too long for browser
|
|
597
|
+
if (url.length > 8000) {
|
|
598
|
+
return { url: null, large: true };
|
|
599
|
+
}
|
|
600
|
+
return { url, large: false };
|
|
601
|
+
} catch (e) {
|
|
602
|
+
return { url: null, large: true };
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Validate output path to prevent directory traversal
|
|
607
|
+
function validateOutputPath(outputPath, rootPath) {
|
|
608
|
+
if (typeof outputPath !== 'string' || outputPath.trim() === '') {
|
|
609
|
+
throw new Error('Invalid path: output path is required');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Security: Check for null bytes
|
|
613
|
+
if (outputPath.includes('\0')) {
|
|
614
|
+
throw new Error('Invalid path: null bytes detected');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Resolve symlinks to prevent symlink attacks
|
|
618
|
+
let realRoot;
|
|
619
|
+
try {
|
|
620
|
+
realRoot = fs.realpathSync(rootPath);
|
|
621
|
+
} catch (e) {
|
|
622
|
+
throw new Error(`Invalid project path: ${rootPath}`);
|
|
623
|
+
}
|
|
624
|
+
const resolved = path.isAbsolute(outputPath)
|
|
625
|
+
? path.resolve(outputPath)
|
|
626
|
+
: path.resolve(realRoot, outputPath);
|
|
627
|
+
|
|
628
|
+
const resolveViaExistingAncestor = (targetPath) => {
|
|
629
|
+
const pending = [];
|
|
630
|
+
let probe = targetPath;
|
|
631
|
+
|
|
632
|
+
while (!fs.existsSync(probe)) {
|
|
633
|
+
pending.unshift(path.basename(probe));
|
|
634
|
+
const parent = path.dirname(probe);
|
|
635
|
+
if (parent === probe) {
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
probe = parent;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const canonicalBase = fs.realpathSync(probe);
|
|
642
|
+
return path.join(canonicalBase, ...pending);
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
const canonicalResolved = resolveViaExistingAncestor(resolved);
|
|
646
|
+
const relative = path.relative(realRoot, canonicalResolved);
|
|
647
|
+
|
|
648
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
649
|
+
throw new Error(`Invalid path: directory traversal detected in "${outputPath}"`);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return canonicalResolved;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function resolveRootPathOrExit(targetPath) {
|
|
656
|
+
const root = path.resolve(targetPath || '.');
|
|
657
|
+
try {
|
|
658
|
+
const stats = fs.statSync(root);
|
|
659
|
+
if (!stats.isDirectory()) {
|
|
660
|
+
console.error(chalk.red('❌ Path error:'), `Target is not a directory: ${root}`);
|
|
661
|
+
process.exit(2);
|
|
662
|
+
}
|
|
663
|
+
} catch (error) {
|
|
664
|
+
console.error(chalk.red('❌ Path error:'), `Target directory not found: ${root}`);
|
|
665
|
+
process.exit(2);
|
|
666
|
+
}
|
|
667
|
+
return root;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function openPreviewUrl(url) {
|
|
671
|
+
const { cmd, args } = getOpenCommand(url, process.platform);
|
|
672
|
+
try {
|
|
673
|
+
const child = spawn(cmd, args, {
|
|
674
|
+
stdio: 'ignore',
|
|
675
|
+
detached: true,
|
|
676
|
+
windowsHide: true
|
|
677
|
+
});
|
|
678
|
+
child.on('error', (err) => {
|
|
679
|
+
console.error(chalk.yellow('⚠️ Failed to open browser:'), err.message);
|
|
680
|
+
});
|
|
681
|
+
child.unref();
|
|
682
|
+
} catch (err) {
|
|
683
|
+
console.error(chalk.yellow('⚠️ Failed to open browser:'), err.message);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function runMermaidCli(args) {
|
|
688
|
+
const candidates = getNpxCommandCandidates(process.platform);
|
|
689
|
+
let lastError = null;
|
|
690
|
+
for (const candidate of candidates) {
|
|
691
|
+
try {
|
|
692
|
+
execFileSync(candidate, args, { stdio: 'pipe', windowsHide: true });
|
|
693
|
+
return;
|
|
694
|
+
} catch (error) {
|
|
695
|
+
lastError = error;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (lastError) {
|
|
699
|
+
throw lastError;
|
|
700
|
+
}
|
|
701
|
+
throw new Error('npx command not found');
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const ALLOWED_THEMES = ['default', 'dark', 'forest', 'neutral', 'light'];
|
|
705
|
+
|
|
706
|
+
function normalizeThemeOption(theme, fallback = 'default') {
|
|
707
|
+
const normalized = String(theme || fallback).toLowerCase();
|
|
708
|
+
return ALLOWED_THEMES.includes(normalized) ? normalized : fallback;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function validateExistingPathInRoot(targetPath, rootPath, label = 'path') {
|
|
712
|
+
const realRoot = fs.realpathSync(rootPath);
|
|
713
|
+
const realTarget = fs.realpathSync(targetPath);
|
|
714
|
+
const relative = path.relative(realRoot, realTarget);
|
|
715
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
716
|
+
throw new Error(`Invalid ${label}: path escapes project root`);
|
|
717
|
+
}
|
|
718
|
+
return realTarget;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Commands
|
|
722
|
+
program
|
|
723
|
+
.name('diagram')
|
|
724
|
+
.description('Generate architecture diagrams from code')
|
|
725
|
+
.version('1.0.0');
|
|
726
|
+
|
|
727
|
+
program
|
|
728
|
+
.command('analyze [path]')
|
|
729
|
+
.description('Analyze codebase structure')
|
|
730
|
+
.option('-p, --patterns <list>', 'File patterns (comma-separated)', '**/*.ts,**/*.tsx,**/*.js,**/*.jsx,**/*.py,**/*.go,**/*.rs')
|
|
731
|
+
.option('-e, --exclude <list>', 'Exclude patterns', 'node_modules/**,.git/**,dist/**')
|
|
732
|
+
.option('-m, --max-files <n>', 'Max files to analyze', '100')
|
|
733
|
+
.option('-j, --json', 'Output as JSON')
|
|
734
|
+
.action(async (targetPath, options) => {
|
|
735
|
+
const root = resolveRootPathOrExit(targetPath);
|
|
736
|
+
if (!options.json) {
|
|
737
|
+
console.log(chalk.blue('Analyzing'), root);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const data = await analyze(root, options);
|
|
741
|
+
|
|
742
|
+
if (options.json) {
|
|
743
|
+
console.log(JSON.stringify(data, null, 2));
|
|
744
|
+
} else {
|
|
745
|
+
console.log(chalk.green('\n📊 Summary'));
|
|
746
|
+
console.log(` Files: ${data.components.length}`);
|
|
747
|
+
console.log(` Languages: ${Object.entries(data.languages).map(([k,v]) => `${k}(${v})`).join(', ') || 'none'}`);
|
|
748
|
+
console.log(` Entry points: ${data.entryPoints.join(', ') || 'none'}`);
|
|
749
|
+
console.log(`\n${chalk.yellow('Components:')}`);
|
|
750
|
+
data.components.slice(0, 15).forEach(c => {
|
|
751
|
+
const deps = c.dependencies.length > 0 ? ` → ${c.dependencies.slice(0, 3).join(', ')}` : '';
|
|
752
|
+
console.log(` ${c.originalName} (${c.type})${deps}`);
|
|
753
|
+
});
|
|
754
|
+
if (data.components.length > 15) {
|
|
755
|
+
console.log(chalk.gray(` ... and ${data.components.length - 15} more`));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
program
|
|
761
|
+
.command('generate [path]')
|
|
762
|
+
.description('Generate a diagram')
|
|
763
|
+
.option('-t, --type <type>', 'Diagram type: architecture, sequence, dependency, class, flow', 'architecture')
|
|
764
|
+
.option('-f, --focus <module>', 'Focus on specific module')
|
|
765
|
+
.option('-o, --output <file>', 'Output file (SVG/PNG)')
|
|
766
|
+
.option('-m, --max-files <n>', 'Max files to analyze', '100')
|
|
767
|
+
.option('--theme <theme>', 'Theme: default, dark, forest, neutral', 'default')
|
|
768
|
+
.option('--open', 'Open in browser')
|
|
769
|
+
.action(async (targetPath, options) => {
|
|
770
|
+
const root = resolveRootPathOrExit(targetPath);
|
|
771
|
+
const requestedTheme = String(options.theme || 'default').toLowerCase();
|
|
772
|
+
const safeTheme = normalizeThemeOption(options.theme, 'default');
|
|
773
|
+
if (requestedTheme !== safeTheme) {
|
|
774
|
+
console.warn(chalk.yellow(`⚠️ Unknown theme "${options.theme}", using "${safeTheme}"`));
|
|
775
|
+
}
|
|
776
|
+
console.log(chalk.blue('Generating'), options.type, 'diagram for', root);
|
|
777
|
+
|
|
778
|
+
const data = await analyze(root, options);
|
|
779
|
+
const mermaid = generate(data, options.type, options.focus);
|
|
780
|
+
|
|
781
|
+
console.log(chalk.green('\n📐 Mermaid Diagram:\n'));
|
|
782
|
+
console.log('```mermaid');
|
|
783
|
+
console.log(mermaid);
|
|
784
|
+
console.log('```\n');
|
|
785
|
+
|
|
786
|
+
// Preview URL
|
|
787
|
+
const { url, large } = createMermaidUrl(mermaid);
|
|
788
|
+
|
|
789
|
+
if (large || !url) {
|
|
790
|
+
console.log(chalk.yellow('⚠️ Diagram is too large for preview URL.'));
|
|
791
|
+
console.log(chalk.cyan('💾 Save to file:'), 'diagram generate . --output diagram.svg');
|
|
792
|
+
} else {
|
|
793
|
+
console.log(chalk.cyan('🔗 Preview:'), url);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Save to file if requested
|
|
797
|
+
if (options.output) {
|
|
798
|
+
// Validate output path for security
|
|
799
|
+
let safeOutput;
|
|
800
|
+
try {
|
|
801
|
+
safeOutput = validateOutputPath(options.output, root);
|
|
802
|
+
} catch (err) {
|
|
803
|
+
console.error(chalk.red('❌ Output path error:'), err.message);
|
|
804
|
+
process.exit(2);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Ensure output directory exists
|
|
808
|
+
const outputDir = path.dirname(safeOutput);
|
|
809
|
+
if (!fs.existsSync(outputDir)) {
|
|
810
|
+
fs.mkdirSync(outputDir, { recursive: true, mode: 0o755 });
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const ext = path.extname(options.output).toLowerCase();
|
|
814
|
+
if (ext === '.md' || ext === '.mmd') {
|
|
815
|
+
fs.writeFileSync(safeOutput, mermaid);
|
|
816
|
+
console.log(chalk.green('✅ Saved to'), options.output);
|
|
817
|
+
} else {
|
|
818
|
+
// Try to render
|
|
819
|
+
let tempFile = null;
|
|
820
|
+
try {
|
|
821
|
+
// Use crypto for secure random filename
|
|
822
|
+
const randomId = crypto.randomBytes(16).toString('hex');
|
|
823
|
+
tempFile = path.join(os.tmpdir(), `diagram-${Date.now()}-${randomId}.mmd`);
|
|
824
|
+
fs.writeFileSync(tempFile, `%%{init: {'theme': '${safeTheme}'}}%%\n${mermaid}`);
|
|
825
|
+
runMermaidCli(['-y', '@mermaid-js/mermaid-cli', 'mmdc', '-i', tempFile, '-o', safeOutput, '-b', 'transparent']);
|
|
826
|
+
fs.unlinkSync(tempFile);
|
|
827
|
+
console.log(chalk.green('✅ Rendered to'), options.output);
|
|
828
|
+
} catch (e) {
|
|
829
|
+
if (tempFile && fs.existsSync(tempFile)) {
|
|
830
|
+
try { fs.unlinkSync(tempFile); } catch (e2) {}
|
|
831
|
+
}
|
|
832
|
+
console.error(chalk.red('❌ Could not render output file. Install mermaid-cli: npm i -g @mermaid-js/mermaid-cli'));
|
|
833
|
+
if (process.env.DEBUG) console.error(chalk.gray(e.message));
|
|
834
|
+
process.exit(2);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (options.open && url) {
|
|
840
|
+
// Security: Validate URL protocol
|
|
841
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
842
|
+
console.error(chalk.red('❌ Invalid URL protocol'));
|
|
843
|
+
} else {
|
|
844
|
+
openPreviewUrl(url);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
program
|
|
850
|
+
.command('all [path]')
|
|
851
|
+
.description('Generate all diagram types')
|
|
852
|
+
.option('-o, --output-dir <dir>', 'Output directory', './diagrams')
|
|
853
|
+
.option('-p, --patterns <list>', 'File patterns', '**/*.ts,**/*.tsx,**/*.js,**/*.jsx,**/*.py,**/*.go,**/*.rs')
|
|
854
|
+
.option('-e, --exclude <list>', 'Exclude patterns', 'node_modules/**,.git/**,dist/**')
|
|
855
|
+
.option('-m, --max-files <n>', 'Max files to analyze', '100')
|
|
856
|
+
.action(async (targetPath, options) => {
|
|
857
|
+
const root = resolveRootPathOrExit(targetPath);
|
|
858
|
+
let outDir;
|
|
859
|
+
try {
|
|
860
|
+
outDir = validateOutputPath(options.outputDir, root);
|
|
861
|
+
} catch (err) {
|
|
862
|
+
console.error(chalk.red('❌ Output path error:'), err.message);
|
|
863
|
+
process.exit(2);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
console.log(chalk.blue('Analyzing'), root);
|
|
867
|
+
const data = await analyze(root, options);
|
|
868
|
+
|
|
869
|
+
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
870
|
+
|
|
871
|
+
const types = ['architecture', 'sequence', 'dependency', 'class', 'flow'];
|
|
872
|
+
|
|
873
|
+
for (const type of types) {
|
|
874
|
+
const mermaid = generate(data, type);
|
|
875
|
+
const file = path.join(outDir, `${type}.mmd`);
|
|
876
|
+
fs.writeFileSync(file, mermaid);
|
|
877
|
+
console.log(chalk.green('✅'), type, '→', file);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
console.log(chalk.cyan('\n🔗 Preview all at: https://mermaid.live'));
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
program
|
|
884
|
+
.command('video [path]')
|
|
885
|
+
.description('Generate an animated video of the diagram')
|
|
886
|
+
.option('-t, --type <type>', 'Diagram type', 'architecture')
|
|
887
|
+
.option('-o, --output <file>', 'Output file (.mp4, .webm, .mov)', 'diagram.mp4')
|
|
888
|
+
.option('-d, --duration <sec>', 'Video duration in seconds', '5')
|
|
889
|
+
.option('-f, --fps <n>', 'Frames per second', '30')
|
|
890
|
+
.option('--width <n>', 'Video width', '1280')
|
|
891
|
+
.option('--height <n>', 'Video height', '720')
|
|
892
|
+
.option('--theme <theme>', 'Theme: default, dark, forest, neutral', 'dark')
|
|
893
|
+
.option('-m, --max-files <n>', 'Max files to analyze', '100')
|
|
894
|
+
.action(async (targetPath, options) => {
|
|
895
|
+
const root = resolveRootPathOrExit(targetPath);
|
|
896
|
+
const safeTheme = normalizeThemeOption(options.theme, 'dark');
|
|
897
|
+
|
|
898
|
+
// Validate output path
|
|
899
|
+
let safeOutput;
|
|
900
|
+
try {
|
|
901
|
+
safeOutput = validateOutputPath(options.output, root);
|
|
902
|
+
} catch (err) {
|
|
903
|
+
console.error(chalk.red('❌ Output path error:'), err.message);
|
|
904
|
+
process.exit(2);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const outputDir = path.dirname(safeOutput);
|
|
908
|
+
if (!fs.existsSync(outputDir)) {
|
|
909
|
+
fs.mkdirSync(outputDir, { recursive: true, mode: 0o755 });
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
console.log(chalk.blue('🎬 Generating video for'), root);
|
|
913
|
+
|
|
914
|
+
const data = await analyze(root, options);
|
|
915
|
+
const mermaid = generate(data, options.type);
|
|
916
|
+
|
|
917
|
+
const { generateVideo } = getVideoModule();
|
|
918
|
+
|
|
919
|
+
await generateVideo(mermaid, safeOutput, {
|
|
920
|
+
duration: parseInt(options.duration) || 5,
|
|
921
|
+
fps: parseInt(options.fps) || 30,
|
|
922
|
+
width: parseInt(options.width) || 1280,
|
|
923
|
+
height: parseInt(options.height) || 720,
|
|
924
|
+
theme: safeTheme
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
program
|
|
929
|
+
.command('animate [path]')
|
|
930
|
+
.description('Generate animated SVG with CSS animations')
|
|
931
|
+
.option('-t, --type <type>', 'Diagram type', 'architecture')
|
|
932
|
+
.option('-o, --output <file>', 'Output file', 'diagram-animated.svg')
|
|
933
|
+
.option('--theme <theme>', 'Theme', 'dark')
|
|
934
|
+
.option('-m, --max-files <n>', 'Max files to analyze', '100')
|
|
935
|
+
.action(async (targetPath, options) => {
|
|
936
|
+
const root = resolveRootPathOrExit(targetPath);
|
|
937
|
+
const safeTheme = normalizeThemeOption(options.theme, 'dark');
|
|
938
|
+
|
|
939
|
+
// Validate output path
|
|
940
|
+
let safeOutput;
|
|
941
|
+
try {
|
|
942
|
+
safeOutput = validateOutputPath(options.output, root);
|
|
943
|
+
} catch (err) {
|
|
944
|
+
console.error(chalk.red('❌ Output path error:'), err.message);
|
|
945
|
+
process.exit(2);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const outputDir = path.dirname(safeOutput);
|
|
949
|
+
if (!fs.existsSync(outputDir)) {
|
|
950
|
+
fs.mkdirSync(outputDir, { recursive: true, mode: 0o755 });
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
console.log(chalk.blue('✨ Generating animated SVG for'), root);
|
|
954
|
+
|
|
955
|
+
const data = await analyze(root, options);
|
|
956
|
+
const mermaid = generate(data, options.type);
|
|
957
|
+
|
|
958
|
+
const { generateAnimatedSVG } = getVideoModule();
|
|
959
|
+
|
|
960
|
+
await generateAnimatedSVG(mermaid, safeOutput, {
|
|
961
|
+
theme: safeTheme
|
|
962
|
+
});
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
program
|
|
966
|
+
.command('test [path]')
|
|
967
|
+
.description('Validate architecture against .architecture.yml rules')
|
|
968
|
+
.option('-c, --config <file>', 'Config file path', '.architecture.yml')
|
|
969
|
+
.option('-f, --format <format>', 'Output format: console, json, junit', 'console')
|
|
970
|
+
.option('-o, --output <file>', 'Output file (for json/junit formats)')
|
|
971
|
+
.option('-p, --patterns <list>', 'File patterns', '**/*.ts,**/*.tsx,**/*.js,**/*.jsx,**/*.py,**/*.go,**/*.rs')
|
|
972
|
+
.option('-e, --exclude <list>', 'Exclude patterns', 'node_modules/**,.git/**,dist/**')
|
|
973
|
+
.option('-m, --max-files <n>', 'Max files to analyze', '100')
|
|
974
|
+
.option('--dry-run', 'Preview file matching without validation', false)
|
|
975
|
+
.option('--verbose', 'Show detailed output', false)
|
|
976
|
+
.option('--init', 'Generate starter configuration file', false)
|
|
977
|
+
.option('--force', 'Overwrite existing configuration with --init', false)
|
|
978
|
+
.action(async (targetPath, options) => {
|
|
979
|
+
const { RulesEngine } = require('./rules');
|
|
980
|
+
const { ComponentGraph } = require('./graph');
|
|
981
|
+
const { RuleFactory } = require('./rules/factory');
|
|
982
|
+
const { formatResults } = require('./formatters/index');
|
|
983
|
+
const { validateConfig, getDefaultConfig } = require('./schema/rules-schema');
|
|
984
|
+
const YAML = require('yaml');
|
|
985
|
+
|
|
986
|
+
const root = resolveRootPathOrExit(targetPath);
|
|
987
|
+
const engine = new RulesEngine();
|
|
988
|
+
const startTime = Date.now();
|
|
989
|
+
const outputsMachineFormat =
|
|
990
|
+
!options.output && (options.format === 'json' || options.format === 'junit');
|
|
991
|
+
const quietMachineOutput = outputsMachineFormat && !options.verbose;
|
|
992
|
+
|
|
993
|
+
// Init mode - generate starter config
|
|
994
|
+
if (options.init) {
|
|
995
|
+
const configPath = path.join(root, '.architecture.yml');
|
|
996
|
+
|
|
997
|
+
if (fs.existsSync(configPath) && !options.force) {
|
|
998
|
+
console.error(chalk.yellow('⚠️ Configuration already exists:'), configPath);
|
|
999
|
+
console.log(chalk.gray(' Use --force to overwrite'));
|
|
1000
|
+
process.exit(2);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const defaultConfig = getDefaultConfig();
|
|
1004
|
+
const yaml = YAML.stringify(defaultConfig, {
|
|
1005
|
+
indent: 2,
|
|
1006
|
+
lineWidth: 0
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
fs.writeFileSync(configPath, yaml);
|
|
1010
|
+
console.log(chalk.green('✅ Created configuration:'), configPath);
|
|
1011
|
+
console.log(chalk.gray('\nEdit the file to define your architecture rules, then run:'));
|
|
1012
|
+
console.log(chalk.cyan(' diagram test'));
|
|
1013
|
+
process.exit(0);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Find or use specified config
|
|
1017
|
+
let configPath = options.config;
|
|
1018
|
+
if (!path.isAbsolute(configPath)) {
|
|
1019
|
+
configPath = path.join(root, configPath);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Validate config path is within project root (security check)
|
|
1023
|
+
const relativeConfigPath = path.relative(root, configPath);
|
|
1024
|
+
if (relativeConfigPath.startsWith('..') || path.isAbsolute(relativeConfigPath)) {
|
|
1025
|
+
console.error(chalk.red('❌ Invalid config path: directory traversal detected'));
|
|
1026
|
+
process.exit(2);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (!fs.existsSync(configPath)) {
|
|
1030
|
+
// Try to find config in root
|
|
1031
|
+
const found = engine.findConfig(root);
|
|
1032
|
+
if (!found) {
|
|
1033
|
+
console.error(chalk.red('❌ No .architecture.yml found. Run: diagram test --init'));
|
|
1034
|
+
process.exit(2);
|
|
1035
|
+
}
|
|
1036
|
+
configPath = found;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Load config
|
|
1040
|
+
let config;
|
|
1041
|
+
try {
|
|
1042
|
+
config = engine.loadConfig(configPath);
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
console.error(chalk.red('❌ Config error:'), error.message);
|
|
1045
|
+
process.exit(2);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Validate config against schema
|
|
1049
|
+
const validation = validateConfig(config);
|
|
1050
|
+
if (!validation.valid) {
|
|
1051
|
+
console.error(chalk.red('❌ Schema validation failed:'));
|
|
1052
|
+
for (const err of validation.errors) {
|
|
1053
|
+
console.error(chalk.red(` • ${err.path}: ${err.message}`));
|
|
1054
|
+
}
|
|
1055
|
+
process.exit(2);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Analyze codebase
|
|
1059
|
+
if (!quietMachineOutput) {
|
|
1060
|
+
console.log(chalk.blue('🔍 Analyzing'), root);
|
|
1061
|
+
}
|
|
1062
|
+
const data = await analyze(root, options);
|
|
1063
|
+
const graph = new ComponentGraph(data);
|
|
1064
|
+
|
|
1065
|
+
// Create rules
|
|
1066
|
+
let rules;
|
|
1067
|
+
try {
|
|
1068
|
+
rules = RuleFactory.createRules(config);
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
console.error(chalk.red('❌ Rule error:'), error.message);
|
|
1071
|
+
process.exit(2);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Dry run mode - just show file matching
|
|
1075
|
+
if (options.dryRun) {
|
|
1076
|
+
const preview = engine.previewMatches(rules, graph);
|
|
1077
|
+
console.log(chalk.cyan('\n📋 Dry Run - File Matching Preview\n'));
|
|
1078
|
+
for (const rule of preview.rules) {
|
|
1079
|
+
console.log(chalk.bold(rule.name));
|
|
1080
|
+
console.log(' Layer:', chalk.gray(Array.isArray(rule.layer) ? rule.layer.join(', ') : rule.layer));
|
|
1081
|
+
console.log(' Matched files:', rule.matchedFiles.length);
|
|
1082
|
+
if (options.verbose) {
|
|
1083
|
+
for (const file of rule.matchedFiles) {
|
|
1084
|
+
console.log(' -', file);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
console.log();
|
|
1088
|
+
}
|
|
1089
|
+
process.exit(0);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Run validation
|
|
1093
|
+
if (!quietMachineOutput) {
|
|
1094
|
+
console.log(chalk.blue('🧪 Validating'), rules.length, 'rules...\n');
|
|
1095
|
+
}
|
|
1096
|
+
const results = engine.validate(rules, graph);
|
|
1097
|
+
|
|
1098
|
+
// Validate output path if specified
|
|
1099
|
+
let safeOutput = options.output;
|
|
1100
|
+
if (safeOutput) {
|
|
1101
|
+
try {
|
|
1102
|
+
safeOutput = validateOutputPath(safeOutput, root);
|
|
1103
|
+
} catch (err) {
|
|
1104
|
+
console.error(chalk.red('❌ Output path error:'), err.message);
|
|
1105
|
+
process.exit(2);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Output results
|
|
1110
|
+
const exitCode = formatResults(results, options.format, {
|
|
1111
|
+
output: safeOutput,
|
|
1112
|
+
verbose: options.verbose
|
|
1113
|
+
}, startTime);
|
|
1114
|
+
|
|
1115
|
+
process.exit(exitCode);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
program.parse();
|