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