@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,576 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const chalk = require('chalk');
4
+ const { execFileSync } = require('child_process');
5
+ const micromatch = require('picomatch');
6
+ const {
7
+ compareStringsDeterministically,
8
+ sortStringsDeterministically,
9
+ } = require('./sort-utils');
10
+ const {
11
+ detectLanguage,
12
+ inferType,
13
+ extractImportsWithPositions,
14
+ normalizePath,
15
+ getImportPath,
16
+ resolveInternalImport,
17
+ findComponentByResolvedPath,
18
+ inferRoleTags,
19
+ } = require('../core/analysis-generation');
20
+
21
+ /**
22
+ * Validate git ref exists and is accessible
23
+ * @param {string} ref - Git ref (SHA, branch, tag)
24
+ * @param {string} root - Repository root path
25
+ * @returns {string} Resolved SHA
26
+ * @throws {Error} If ref is invalid or not found
27
+ */
28
+ function validateGitRef(ref, root) {
29
+ if (!ref || typeof ref !== 'string' || ref.trim() === '') {
30
+ throw new Error('Git ref is required');
31
+ }
32
+
33
+ // Security: Check for shell injection attempts
34
+ if (/[`$(){};|&<>]/.test(ref)) {
35
+ throw new Error('Invalid characters in git ref');
36
+ }
37
+
38
+ try {
39
+ // Use execFileSync for synchronous git operations with timeout
40
+ const sha = execFileSync('git', ['rev-parse', '--verify', ref], {
41
+ cwd: root,
42
+ encoding: 'utf8',
43
+ timeout: 10000, // 10 second timeout
44
+ maxBuffer: 1024 * 1024 // 1MB buffer
45
+ }).trim();
46
+
47
+ return sha;
48
+ } catch (error) {
49
+ if (error.killed) {
50
+ throw new Error(`Git operation timed out resolving ref: ${ref}`);
51
+ }
52
+ throw new Error(`Git ref not found: ${ref}`);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Check if repository has shallow clone (missing base refs)
58
+ * @param {string} root - Repository root path
59
+ * @returns {boolean} True if shallow clone
60
+ */
61
+ function isShallowClone(root) {
62
+ try {
63
+ const shallowFile = path.join(root, '.git', 'shallow');
64
+ return fs.existsSync(shallowFile);
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Detect PR refs from environment (GitHub Actions)
72
+ * @returns {{base: string|null, head: string|null}}
73
+ */
74
+ function detectPrRefsFromEnv() {
75
+ const env = process.env;
76
+
77
+ // GitHub Actions PR context
78
+ if (env.GITHUB_EVENT_NAME === 'pull_request') {
79
+ try {
80
+ const eventPath = env.GITHUB_EVENT_PATH;
81
+ if (eventPath && fs.existsSync(eventPath)) {
82
+ const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
83
+ return {
84
+ base: event.pull_request?.base?.sha || null,
85
+ head: event.pull_request?.head?.sha || null
86
+ };
87
+ }
88
+ } catch {
89
+ // Fall through to defaults
90
+ }
91
+ }
92
+
93
+ return { base: null, head: null };
94
+ }
95
+
96
+ /**
97
+ * Run git command with timeout and error handling
98
+ * @param {string[]} args - Git arguments
99
+ * @param {string} root - Repository root path
100
+ * @param {number} timeout - Timeout in milliseconds
101
+ * @returns {string} stdout from git command
102
+ * @throws {Error} If command fails or times out
103
+ */
104
+ function runGitCommand(args, root, timeout = 30000) {
105
+ try {
106
+ const result = execFileSync('git', args, {
107
+ cwd: root,
108
+ encoding: 'utf8',
109
+ timeout,
110
+ maxBuffer: 10 * 1024 * 1024, // 10MB buffer
111
+ windowsHide: true
112
+ });
113
+ return result;
114
+ } catch (error) {
115
+ if (error.killed) {
116
+ throw new Error(`Git operation timed out after ${timeout}ms`);
117
+ }
118
+ const stderr = error.stderr || '';
119
+ if (stderr.includes('bad revision') || stderr.includes('unknown revision')) {
120
+ throw new Error('Git ref not found in repository');
121
+ }
122
+ if (stderr.includes('not a git repository')) {
123
+ throw new Error(`Not a git repository: ${root}`);
124
+ }
125
+ throw new Error(`Git command failed: ${stderr || error.message}`);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Get changed files between two refs with rename detection
131
+ * @param {string} baseSha - Base commit SHA
132
+ * @param {string} headSha - Head commit SHA
133
+ * @param {string} root - Repository root path
134
+ * @returns {{changed: string[], renamed: {from: string, to: string}[], deleted: string[], added: string[]}}
135
+ */
136
+ function getChangedFiles(baseSha, headSha, root) {
137
+ // Use --name-status -M for rename detection (50% similarity threshold)
138
+ // -M detects renames, -M80% would use 80% threshold
139
+ const diffOutput = runGitCommand(
140
+ ['diff', '--name-status', '-M', `${baseSha}`, `${headSha}`],
141
+ root,
142
+ 60000 // 60 second timeout for large diffs
143
+ );
144
+
145
+ const changed = [];
146
+ const renamed = [];
147
+ const deleted = [];
148
+ const added = [];
149
+
150
+ for (const line of diffOutput.trim().split('\n')) {
151
+ if (!line.trim()) continue;
152
+
153
+ // Parse git diff --name-status -M output
154
+ // Format: STATUS\told_path\tnew_path (for renames/copies)
155
+ // Format: STATUS\tpath (for other changes)
156
+ const parts = line.split('\t');
157
+ const status = parts[0];
158
+
159
+ switch (status) {
160
+ case 'A':
161
+ added.push(parts[1]);
162
+ changed.push(parts[1]);
163
+ break;
164
+ case 'D':
165
+ deleted.push(parts[1]);
166
+ break;
167
+ case 'M':
168
+ changed.push(parts[1]);
169
+ break;
170
+ default:
171
+ if (status.startsWith('R')) {
172
+ // Rename: R### old_path new_path (### = similarity 000–100)
173
+ // -M defaults to 50% threshold; handle all values not just ≥90%.
174
+ const similarity = parseInt(status.slice(1), 10);
175
+ renamed.push({ from: parts[1], to: parts[2], similarity });
176
+ changed.push(parts[2]); // track new path as changed
177
+ } else if (status.startsWith('C')) {
178
+ // Copy: C### old_path new_path
179
+ added.push(parts[2]);
180
+ changed.push(parts[2]);
181
+ } else if (parts[1]) {
182
+ // Unknown status (T=type-change, U=unmerged, X=unknown) — treat as changed
183
+ changed.push(parts[1]);
184
+ }
185
+ break;
186
+ }
187
+ }
188
+
189
+ // Sort all arrays for deterministic output
190
+ return {
191
+ changed: sortStringsDeterministically(changed),
192
+ renamed: renamed.sort((a, b) => {
193
+ const fromCmp = compareStringsDeterministically(a.from, b.from);
194
+ if (fromCmp !== 0) return fromCmp;
195
+ return compareStringsDeterministically(a.to, b.to);
196
+ }),
197
+ deleted: sortStringsDeterministically(deleted),
198
+ added: sortStringsDeterministically(added)
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Read file content at a specific git ref
204
+ * @param {string} ref - Git ref (SHA, branch, tag)
205
+ * @param {string} filePath - Path to file relative to repo root
206
+ * @param {string} root - Repository root path
207
+ * @returns {string|null} File content or null if file doesn't exist at ref
208
+ */
209
+ function readFileAtRef(ref, filePath, root) {
210
+ // Security: Validate filePath doesn't contain shell injection
211
+ if (/[`$(){};|&<>]/.test(filePath)) {
212
+ throw new Error('Invalid characters in file path');
213
+ }
214
+
215
+ // Security: Prevent directory traversal
216
+ const normalizedPath = path.normalize(filePath);
217
+ if (normalizedPath.startsWith('..') || path.isAbsolute(normalizedPath)) {
218
+ throw new Error('Directory traversal detected in file path');
219
+ }
220
+
221
+ try {
222
+ const content = runGitCommand(
223
+ ['show', `${ref}:${normalizedPath}`],
224
+ root,
225
+ 10000 // 10 second timeout
226
+ );
227
+ return content;
228
+ } catch (error) {
229
+ // File doesn't exist at this ref
230
+ if (error.message.includes('does not exist') || error.message.includes('bad revision')) {
231
+ return null;
232
+ }
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Get list of files at a specific git ref
239
+ * @param {string} ref - Git ref (SHA, branch, tag)
240
+ * @param {string} root - Repository root path
241
+ * @param {object} options - Filter options
242
+ * @returns {string[]} List of file paths at ref
243
+ */
244
+ function listFilesAtRef(ref, root, options = {}) {
245
+ const {
246
+ patterns = ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.py', '**/*.go', '**/*.rs'],
247
+ exclude = ['node_modules/**', '.git/**', 'dist/**', 'build/**']
248
+ } = options;
249
+
250
+ // Use git ls-tree to get all tracked files at ref
251
+ const output = runGitCommand(
252
+ ['ls-tree', '-r', '--name-only', ref],
253
+ root,
254
+ 60000 // 60 second timeout for large repos
255
+ );
256
+
257
+ const allFiles = output.trim().split('\n').filter(f => f.trim());
258
+
259
+ // Filter by patterns and exclusions using minimatch-style matching
260
+ const includeMatchers = patterns.map(p => micromatch(p));
261
+ const excludeMatchers = exclude.map(p => micromatch(p));
262
+
263
+ const filteredFiles = allFiles.filter(filePath => {
264
+ // Check exclusions first
265
+ if (excludeMatchers.some(m => m(filePath))) {
266
+ return false;
267
+ }
268
+ // Check inclusions
269
+ return includeMatchers.some(m => m(filePath));
270
+ });
271
+
272
+ return filteredFiles.sort();
273
+ }
274
+
275
+ /**
276
+ * Analyze codebase at a specific git ref (snapshot analysis)
277
+ * @param {string} ref - Git ref (SHA, branch, tag)
278
+ * @param {string} root - Repository root path
279
+ * @param {object} options - Analysis options
280
+ * @returns {Promise<object>} Analysis result
281
+ */
282
+ async function analyzeAtRef(ref, root, options = {}) {
283
+ const maxFiles = Math.min(Math.max(parseInt(options.maxFiles, 10) || 100, 1), 10000);
284
+
285
+ // Get file list at ref
286
+ const fileList = listFilesAtRef(ref, root, options).slice(0, maxFiles);
287
+
288
+ const components = [];
289
+ const languages = {};
290
+ const directories = new Set();
291
+ const entryPoints = [];
292
+ const seenNames = new Set();
293
+
294
+ for (const filePath of fileList) {
295
+ try {
296
+ // Read file content at ref
297
+ const content = readFileAtRef(ref, filePath, root);
298
+ if (content === null) continue;
299
+
300
+ // Security: Check content size (10MB limit)
301
+ if (content.length > 10 * 1024 * 1024) {
302
+ continue;
303
+ }
304
+
305
+ const lang = detectLanguage(filePath);
306
+ let rel = normalizePath(filePath);
307
+ const dir = path.dirname(rel);
308
+ if (dir === '.') {
309
+ rel = `./${rel}`;
310
+ }
311
+
312
+ languages[lang] = (languages[lang] || 0) + 1;
313
+ if (dir !== '.') directories.add(dir);
314
+
315
+ // Entry point detection
316
+ const entryPattern = /\/(index|main|app|server)\.(ts|js|tsx|jsx|mts|mjs|py|go|rs)$/i;
317
+ if (entryPattern.test(rel)) {
318
+ entryPoints.push(rel);
319
+ }
320
+
321
+ // Handle duplicate names
322
+ let baseName = path.basename(filePath, path.extname(filePath));
323
+ let uniqueName = baseName;
324
+ let counter = 1;
325
+ while (seenNames.has(uniqueName)) {
326
+ uniqueName = `${baseName}_${counter}`;
327
+ counter++;
328
+ }
329
+ seenNames.add(uniqueName);
330
+
331
+ const imports = extractImportsWithPositions(content, lang);
332
+ const type = inferType(filePath, content);
333
+
334
+ components.push({
335
+ name: uniqueName,
336
+ originalName: baseName,
337
+ filePath: rel,
338
+ type,
339
+ imports,
340
+ roleTags: inferRoleTags(rel, baseName, content, imports, type),
341
+ directory: dir,
342
+ });
343
+ } catch (e) {
344
+ // Skip files that can't be read or parsed
345
+ if (process.env.DEBUG) {
346
+ console.error(chalk.gray(`Skipped ${filePath}: ${e.message}`));
347
+ }
348
+ }
349
+ }
350
+
351
+ // Resolve dependencies (same logic as analyze())
352
+ for (const comp of components) {
353
+ comp.dependencies = [];
354
+ for (const imp of comp.imports) {
355
+ const importPath = getImportPath(imp);
356
+ if (!importPath) continue;
357
+ const resolved = resolveInternalImport(comp.filePath, importPath, root);
358
+ if (!resolved) continue;
359
+ const dep = findComponentByResolvedPath(components, resolved);
360
+ if (dep) comp.dependencies.push({ name: dep.name, filePath: dep.filePath });
361
+ }
362
+ }
363
+
364
+ return {
365
+ rootPath: root,
366
+ ref,
367
+ components,
368
+ entryPoints,
369
+ languages,
370
+ directories: [...directories].sort()
371
+ };
372
+ }
373
+
374
+ function dependencyFilePathSet(dependencies) {
375
+ return new Set(
376
+ (dependencies || [])
377
+ .map((dependency) => (typeof dependency === 'object' ? dependency.filePath : dependency))
378
+ .filter(Boolean)
379
+ );
380
+ }
381
+
382
+ function countDependencyEdges(components) {
383
+ return components.reduce(
384
+ (sum, component) => sum + dependencyFilePathSet(component.dependencies).size,
385
+ 0
386
+ );
387
+ }
388
+
389
+ function buildTypeDistribution(components) {
390
+ return components.reduce((distribution, component) => {
391
+ distribution[component.type] = (distribution[component.type] || 0) + 1;
392
+ return distribution;
393
+ }, {});
394
+ }
395
+
396
+ /**
397
+ * Compute architecture diff between two analysis results
398
+ * @param {object} base - Base analysis result
399
+ * @param {object} head - Head analysis result
400
+ * @returns {object} Diff result
401
+ */
402
+ function computeArchitectureDiff(base, head) {
403
+ const baseComponents = new Map(base.components.map(c => [c.filePath, c]));
404
+ const headComponents = new Map(head.components.map(c => [c.filePath, c]));
405
+
406
+ // Find added, removed, and changed components
407
+ const added = [];
408
+ const removed = [];
409
+ const changed = [];
410
+
411
+ for (const [filePath, comp] of headComponents) {
412
+ if (!baseComponents.has(filePath)) {
413
+ added.push({ filePath, name: comp.name, type: comp.type });
414
+ } else {
415
+ const baseComp = baseComponents.get(filePath);
416
+ const baseDeps = dependencyFilePathSet(baseComp.dependencies);
417
+ const headDeps = dependencyFilePathSet(comp.dependencies);
418
+
419
+ const depsAdded = [...headDeps].filter(d => !baseDeps.has(d));
420
+ const depsRemoved = [...baseDeps].filter(d => !headDeps.has(d));
421
+
422
+ if (depsAdded.length > 0 || depsRemoved.length > 0) {
423
+ changed.push({
424
+ filePath,
425
+ name: comp.name,
426
+ type: comp.type,
427
+ dependenciesAdded: depsAdded,
428
+ dependenciesRemoved: depsRemoved
429
+ });
430
+ }
431
+ }
432
+ }
433
+
434
+ for (const [filePath, comp] of baseComponents) {
435
+ if (!headComponents.has(filePath)) {
436
+ removed.push({ filePath, name: comp.name, type: comp.type });
437
+ }
438
+ }
439
+
440
+ // Count edges
441
+ const baseEdgeCount = countDependencyEdges(base.components);
442
+ const headEdgeCount = countDependencyEdges(head.components);
443
+ const baseTypes = buildTypeDistribution(base.components);
444
+ const headTypes = buildTypeDistribution(head.components);
445
+
446
+ // Language distribution
447
+ const baseLangs = { ...base.languages };
448
+ const headLangs = { ...head.languages };
449
+
450
+ return {
451
+ summary: {
452
+ baseComponents: base.components.length,
453
+ headComponents: head.components.length,
454
+ componentDelta: head.components.length - base.components.length,
455
+ baseEdges: baseEdgeCount,
456
+ headEdges: headEdgeCount,
457
+ edgeDelta: headEdgeCount - baseEdgeCount,
458
+ addedCount: added.length,
459
+ removedCount: removed.length,
460
+ changedCount: changed.length
461
+ },
462
+ types: {
463
+ base: baseTypes,
464
+ head: headTypes
465
+ },
466
+ languages: {
467
+ base: baseLangs,
468
+ head: headLangs
469
+ },
470
+ components: {
471
+ added: added.sort((a, b) => compareStringsDeterministically(a.filePath, b.filePath)),
472
+ removed: removed.sort((a, b) => compareStringsDeterministically(a.filePath, b.filePath)),
473
+ changed: changed.sort((a, b) => compareStringsDeterministically(a.filePath, b.filePath))
474
+ }
475
+ };
476
+ }
477
+
478
+ /**
479
+ * Format a delta value with +/- sign and color
480
+ * @param {number} delta - Delta value
481
+ * @returns {string} Formatted string
482
+ */
483
+ function formatDelta(delta) {
484
+ if (delta > 0) return chalk.green(`+${delta}`);
485
+ if (delta < 0) return chalk.red(`${delta}`);
486
+ return chalk.gray('0');
487
+ }
488
+
489
+ /**
490
+ * Print architecture diff to console
491
+ * @param {object} diff - Diff result from computeArchitectureDiff
492
+ */
493
+ function printArchitectureDiff(diff) {
494
+ const { summary, types, languages, components } = diff;
495
+
496
+ // Summary section
497
+ console.log(chalk.cyan('📊 Summary'));
498
+ console.log(` Components: ${summary.baseComponents} → ${summary.headComponents} (${formatDelta(summary.componentDelta)})`);
499
+ console.log(` Edges: ${summary.baseEdges} → ${summary.headEdges} (${formatDelta(summary.edgeDelta)})`);
500
+ console.log('');
501
+
502
+ // Type distribution
503
+ console.log(chalk.cyan('📦 Component Types'));
504
+ const allTypes = new Set([...Object.keys(types.base), ...Object.keys(types.head)]);
505
+ for (const type of allTypes) {
506
+ const baseCount = types.base[type] || 0;
507
+ const headCount = types.head[type] || 0;
508
+ const delta = headCount - baseCount;
509
+ console.log(` ${type}: ${baseCount} → ${headCount} (${formatDelta(delta)})`);
510
+ }
511
+ console.log('');
512
+
513
+ // Language distribution
514
+ console.log(chalk.cyan('💻 Languages'));
515
+ const allLangs = new Set([...Object.keys(languages.base), ...Object.keys(languages.head)]);
516
+ for (const lang of allLangs) {
517
+ const baseCount = languages.base[lang] || 0;
518
+ const headCount = languages.head[lang] || 0;
519
+ const delta = headCount - baseCount;
520
+ console.log(` ${lang}: ${baseCount} → ${headCount} (${formatDelta(delta)})`);
521
+ }
522
+ console.log('');
523
+
524
+ // Added components
525
+ if (components.added.length > 0) {
526
+ console.log(chalk.green(`➕ Added (${components.added.length})`));
527
+ for (const c of components.added) {
528
+ console.log(` + ${c.filePath} (${c.type})`);
529
+ }
530
+ console.log('');
531
+ }
532
+
533
+ // Removed components
534
+ if (components.removed.length > 0) {
535
+ console.log(chalk.red(`➖ Removed (${components.removed.length})`));
536
+ for (const c of components.removed) {
537
+ console.log(` - ${c.filePath} (${c.type})`);
538
+ }
539
+ console.log('');
540
+ }
541
+
542
+ // Changed components
543
+ if (components.changed.length > 0) {
544
+ console.log(chalk.yellow(`📝 Changed (${components.changed.length})`));
545
+ for (const c of components.changed) {
546
+ console.log(` ~ ${c.filePath}`);
547
+ if (c.dependenciesAdded.length > 0) {
548
+ console.log(chalk.gray(` +deps: ${c.dependenciesAdded.slice(0, 3).join(', ')}${c.dependenciesAdded.length > 3 ? '...' : ''}`));
549
+ }
550
+ if (c.dependenciesRemoved.length > 0) {
551
+ console.log(chalk.gray(` -deps: ${c.dependenciesRemoved.slice(0, 3).join(', ')}${c.dependenciesRemoved.length > 3 ? '...' : ''}`));
552
+ }
553
+ }
554
+ console.log('');
555
+ }
556
+
557
+ // No changes
558
+ if (components.added.length === 0 && components.removed.length === 0 && components.changed.length === 0) {
559
+ console.log(chalk.gray(' No architectural changes detected'));
560
+ console.log('');
561
+ }
562
+ }
563
+
564
+ module.exports = {
565
+ validateGitRef,
566
+ isShallowClone,
567
+ detectPrRefsFromEnv,
568
+ runGitCommand,
569
+ getChangedFiles,
570
+ readFileAtRef,
571
+ listFilesAtRef,
572
+ analyzeAtRef,
573
+ computeArchitectureDiff,
574
+ printArchitectureDiff,
575
+ formatDelta,
576
+ };