@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/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();