@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,335 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const { execFileSync } = require('child_process');
|
|
5
|
+
const { getNpxCommandCandidates, getFfmpegCommandCandidates } = require('../utils/commands');
|
|
6
|
+
const { isShallowClone } = require('../workflow/git-helpers');
|
|
7
|
+
const { resolveRootPathOrExit } = require('./shared');
|
|
8
|
+
const { buildMachineEnvelope } = require('./output');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Checks whether the Mermaid CLI can be invoked via npx from the specified project root.
|
|
12
|
+
*
|
|
13
|
+
* Attempts platform-specific npx command candidates to run `@mermaid-js/mermaid-cli --version`.
|
|
14
|
+
* @param {string} root - Filesystem path used as the child process working directory when probing.
|
|
15
|
+
* @returns {{status: 'pass'|'warn', message: string, fix?: string}} An object describing the probe result.
|
|
16
|
+
* - `status: 'pass'` when a candidate successfully returns version output; `message` contains the trimmed output or `'mermaid-cli available'`.
|
|
17
|
+
* - `status: 'warn'` when no candidate succeeded; `message` explains the absence and `fix` suggests `npm install -g @mermaid-js/mermaid-cli`.
|
|
18
|
+
*/
|
|
19
|
+
function checkMermaidCli(root) {
|
|
20
|
+
const candidates = getNpxCommandCandidates(process.platform);
|
|
21
|
+
for (const candidate of candidates) {
|
|
22
|
+
try {
|
|
23
|
+
const output = execFileSync(candidate, ['-y', '@mermaid-js/mermaid-cli', '--version'], {
|
|
24
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
25
|
+
cwd: root,
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
timeout: 10000,
|
|
28
|
+
}).trim();
|
|
29
|
+
return { status: 'pass', message: output || 'mermaid-cli available' };
|
|
30
|
+
} catch (_error) {
|
|
31
|
+
// Try next candidate.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
status: 'warn',
|
|
36
|
+
message: 'Mermaid CLI is not currently available via npx',
|
|
37
|
+
fix: 'npm install -g @mermaid-js/mermaid-cli',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Verify that Playwright (and its Chromium runtime) is available for the project at the given root.
|
|
43
|
+
*
|
|
44
|
+
* Attempts to resolve a local `playwright` package and perform a headless Chromium launch; if not found, falls back to checking available npx candidates for the Playwright CLI.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} root - Filesystem path of the project root used to resolve packages and run probes.
|
|
47
|
+
* @returns {{status: 'pass'|'warn'|'fail', message: string, fix?: string}} `status` is `'pass'` when a runtime or CLI was detected; `'warn'` when Playwright is present but Chromium is unavailable or no runtime/CLI was detected. `message` summarises the detection result; `fix` provides an installation hint when applicable.
|
|
48
|
+
*/
|
|
49
|
+
function checkPlaywright(root) {
|
|
50
|
+
const quickLaunchScript = [
|
|
51
|
+
'const { chromium } = require("playwright");',
|
|
52
|
+
'(async () => {',
|
|
53
|
+
' const browser = await chromium.launch({ headless: true });',
|
|
54
|
+
' await browser.close();',
|
|
55
|
+
'})().catch((error) => {',
|
|
56
|
+
' console.error(error.message || String(error));',
|
|
57
|
+
' process.exit(1);',
|
|
58
|
+
'});',
|
|
59
|
+
].join('\n');
|
|
60
|
+
let pkg = null;
|
|
61
|
+
try {
|
|
62
|
+
pkg = require.resolve('playwright', { paths: [path.join(root, 'node_modules')] });
|
|
63
|
+
} catch (_resolveError) {
|
|
64
|
+
pkg = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (pkg) {
|
|
68
|
+
try {
|
|
69
|
+
execFileSync('node', ['-e', quickLaunchScript], {
|
|
70
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
71
|
+
cwd: root,
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
timeout: 15000,
|
|
74
|
+
});
|
|
75
|
+
return { status: 'pass', message: `playwright runtime verified (${pkg})` };
|
|
76
|
+
} catch (_launchError) {
|
|
77
|
+
return {
|
|
78
|
+
status: 'warn',
|
|
79
|
+
message: 'Playwright package found but Chromium runtime is unavailable',
|
|
80
|
+
fix: 'npx playwright install chromium',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const candidates = getNpxCommandCandidates(process.platform);
|
|
86
|
+
for (const candidate of candidates) {
|
|
87
|
+
try {
|
|
88
|
+
const output = execFileSync(candidate, ['playwright', '--version'], {
|
|
89
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
90
|
+
cwd: root,
|
|
91
|
+
encoding: 'utf8',
|
|
92
|
+
timeout: 10000,
|
|
93
|
+
}).trim();
|
|
94
|
+
return { status: 'pass', message: output || 'playwright CLI available' };
|
|
95
|
+
} catch (_playwrightError) {
|
|
96
|
+
// Try next npx candidate.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
status: 'warn',
|
|
101
|
+
message: 'Playwright runtime not detected',
|
|
102
|
+
fix: 'npx playwright install chromium',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Checks whether an ffmpeg executable can be invoked from the environment.
|
|
108
|
+
*
|
|
109
|
+
* Tries to detect a usable ffmpeg binary and, on success, returns a pass result whose message is the first line of ffmpeg's version output (trimmed). If detection fails, returns a warning with a platform-appropriate installation hint in `fix`.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} root - Directory used as the current working directory when probing for ffmpeg.
|
|
112
|
+
* @returns {{status: 'pass'|'warn', message: string, fix?: string}} `status` is `'pass'` when ffmpeg was detected, otherwise `'warn'`. `message` summarises the detection or the absence; `fix` is provided for the `'warn'` case with an installation hint.
|
|
113
|
+
*/
|
|
114
|
+
function checkFfmpeg(root) {
|
|
115
|
+
const candidates = getFfmpegCommandCandidates(process.platform);
|
|
116
|
+
for (const candidate of candidates) {
|
|
117
|
+
try {
|
|
118
|
+
const output = execFileSync(candidate, ['-version'], {
|
|
119
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
120
|
+
cwd: root,
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
timeout: 5000,
|
|
123
|
+
windowsHide: true,
|
|
124
|
+
});
|
|
125
|
+
const firstLine = output.split('\n')[0] || `${candidate} available`;
|
|
126
|
+
return { status: 'pass', message: firstLine.trim() };
|
|
127
|
+
} catch (_error) {
|
|
128
|
+
// Continue.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
let installHint = 'install ffmpeg and ensure it is on PATH';
|
|
132
|
+
if (process.platform === 'darwin') {
|
|
133
|
+
installHint = 'brew install ffmpeg';
|
|
134
|
+
} else if (process.platform === 'linux') {
|
|
135
|
+
installHint = 'sudo apt install ffmpeg';
|
|
136
|
+
} else if (process.platform === 'win32') {
|
|
137
|
+
installHint = 'Install ffmpeg (for example with winget: winget install Gyan.FFmpeg)';
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
status: 'warn',
|
|
141
|
+
message: 'ffmpeg not detected',
|
|
142
|
+
fix: installHint,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Remove the probe directory created by the doctor checks if it did not exist beforehand.
|
|
148
|
+
*
|
|
149
|
+
* Attempts to remove `diagramDir` only when `existedBefore` is false; errors during removal are ignored.
|
|
150
|
+
* @param {string} diagramDir - Filesystem path of the probe directory to remove.
|
|
151
|
+
* @param {boolean} existedBefore - Whether the directory existed prior to the probe. If `true`, no action is taken.
|
|
152
|
+
*/
|
|
153
|
+
function removeDoctorProbeDirIfCreated(diagramDir, existedBefore) {
|
|
154
|
+
if (existedBefore) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
fs.rmdirSync(diagramDir);
|
|
159
|
+
} catch (_cleanupError) {
|
|
160
|
+
// Keep directory if not empty or not removable.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Verifies that the process can create and remove files under the repository diagram directory.
|
|
166
|
+
*
|
|
167
|
+
* Attempts to create a probe file inside <root>/.diagram and cleans up any probe artifacts it created.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} root - Filesystem path of the repository root to test write access for.
|
|
170
|
+
* @returns {{status: 'pass'|'fail', message: string, fix?: string}} An object where `status` is `'pass'` when write access is confirmed or `'fail'` when it is not; `message` describes the outcome; `fix` suggests a permission command when applicable.
|
|
171
|
+
*/
|
|
172
|
+
function checkWritePermissions(root) {
|
|
173
|
+
const diagramDir = path.join(root, '.diagram');
|
|
174
|
+
const existedBefore = fs.existsSync(diagramDir);
|
|
175
|
+
try {
|
|
176
|
+
if (!existedBefore) {
|
|
177
|
+
fs.mkdirSync(diagramDir, { recursive: true });
|
|
178
|
+
}
|
|
179
|
+
const probePath = path.join(diagramDir, '.doctor-write-probe');
|
|
180
|
+
fs.writeFileSync(probePath, 'ok');
|
|
181
|
+
fs.rmSync(probePath, { force: true });
|
|
182
|
+
removeDoctorProbeDirIfCreated(diagramDir, existedBefore);
|
|
183
|
+
return { status: 'pass', message: `write access confirmed for ${diagramDir}` };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
removeDoctorProbeDirIfCreated(diagramDir, existedBefore);
|
|
186
|
+
return {
|
|
187
|
+
status: 'fail',
|
|
188
|
+
message: `write access failed for ${diagramDir}: ${error.message}`,
|
|
189
|
+
fix: `chmod -R u+rw "${diagramDir}"`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Check that npm's cache directory is configured and is writable.
|
|
196
|
+
*
|
|
197
|
+
* Attempts to read the npm cache path from configuration and verify write access by creating and removing a probe file.
|
|
198
|
+
*
|
|
199
|
+
* @returns {{status: 'pass'|'warn'|'fail', message: string, fix?: string}}
|
|
200
|
+
* - `status: 'pass'` when the cache directory is writable; `message` contains the cache path.
|
|
201
|
+
* - `status: 'warn'` when the cache path is empty or the npm config could not be read; `fix` suggests how to inspect or set the cache.
|
|
202
|
+
* - `status: 'fail'` when the cache path exists but is not writable; `message` contains the path and error, and `fix` suggests permission commands (platform-specific).
|
|
203
|
+
*/
|
|
204
|
+
function checkNpmCacheHealth() {
|
|
205
|
+
try {
|
|
206
|
+
const cachePath = execFileSync('npm', ['config', 'get', 'cache'], {
|
|
207
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
208
|
+
encoding: 'utf8',
|
|
209
|
+
timeout: 10000,
|
|
210
|
+
}).trim();
|
|
211
|
+
if (!cachePath) {
|
|
212
|
+
return { status: 'warn', message: 'npm cache path is empty', fix: 'npm config set cache ~/.npm' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
fs.mkdirSync(cachePath, { recursive: true });
|
|
217
|
+
const probePath = path.join(cachePath, '.doctor-npm-cache-probe');
|
|
218
|
+
fs.writeFileSync(probePath, 'ok');
|
|
219
|
+
fs.rmSync(probePath, { force: true });
|
|
220
|
+
return { status: 'pass', message: `npm cache writable: ${cachePath}` };
|
|
221
|
+
} catch (error) {
|
|
222
|
+
let fix = `sudo chown -R "$(id -u):$(id -g)" "${cachePath}"`;
|
|
223
|
+
if (process.platform === 'win32') {
|
|
224
|
+
fix = `Run an elevated PowerShell and grant cache access (for example: takeown /F "${cachePath}" /R /D Y && icacls "${cachePath}" /grant "%USERNAME%":(OI)(CI)F /T).`;
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
status: 'fail',
|
|
228
|
+
message: `npm cache not writable: ${cachePath} (${error.message})`,
|
|
229
|
+
fix,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return {
|
|
234
|
+
status: 'warn',
|
|
235
|
+
message: `could not read npm cache config: ${error.message}`,
|
|
236
|
+
fix: 'npm config get cache',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Register the `doctor` CLI command that runs environment diagnostics for a diagram project.
|
|
243
|
+
*
|
|
244
|
+
* The command performs a fixed set of checks (Mermaid CLI, Playwright, ffmpeg, Git history depth,
|
|
245
|
+
* write permissions and npm cache health), summarises results, and prints output in either text
|
|
246
|
+
* (default) or JSON formats. When invoked the command will call `process.exit` with a non-zero
|
|
247
|
+
* status if any checks fail or if `--strict` is set and warnings are present.
|
|
248
|
+
*
|
|
249
|
+
* @param {import('commander').Command} program - Commander program instance to register the command on.
|
|
250
|
+
*/
|
|
251
|
+
function registerDoctorCommand(program) {
|
|
252
|
+
program
|
|
253
|
+
.command('doctor [path]')
|
|
254
|
+
.description('Run environment diagnostics for architecture evidence workflows')
|
|
255
|
+
.option('--format <type>', 'Output format (text, json)', 'text')
|
|
256
|
+
.option('--strict', 'Fail when diagnostics include warnings', false)
|
|
257
|
+
.option('--deterministic', 'Use deterministic machine output', false)
|
|
258
|
+
.action((targetPath, options) => {
|
|
259
|
+
const root = resolveRootPathOrExit(targetPath);
|
|
260
|
+
const checks = [
|
|
261
|
+
{ id: 'mermaid_cli', label: 'Mermaid CLI', ...checkMermaidCli(root) },
|
|
262
|
+
{ id: 'playwright', label: 'Playwright', ...checkPlaywright(root) },
|
|
263
|
+
{ id: 'ffmpeg', label: 'ffmpeg', ...checkFfmpeg(root) },
|
|
264
|
+
{
|
|
265
|
+
id: 'git_shallow_clone',
|
|
266
|
+
label: 'Git history depth',
|
|
267
|
+
...(isShallowClone(root)
|
|
268
|
+
? { status: 'warn', message: 'shallow clone detected', fix: 'git fetch --unshallow' }
|
|
269
|
+
: { status: 'pass', message: 'full git history available' }),
|
|
270
|
+
},
|
|
271
|
+
{ id: 'write_permissions', label: 'Write permissions', ...checkWritePermissions(root) },
|
|
272
|
+
{ id: 'npm_cache', label: 'npm cache health', ...checkNpmCacheHealth() },
|
|
273
|
+
];
|
|
274
|
+
const strictFailure = Boolean(options.strict) && checks.some((check) => check.status === 'warn');
|
|
275
|
+
const failedChecks = checks.filter((check) => check.status === 'fail');
|
|
276
|
+
const exitCode = (failedChecks.length > 0 || strictFailure) ? 1 : 0;
|
|
277
|
+
|
|
278
|
+
const summary = {
|
|
279
|
+
pass: checks.filter((check) => check.status === 'pass').length,
|
|
280
|
+
warn: checks.filter((check) => check.status === 'warn').length,
|
|
281
|
+
fail: failedChecks.length,
|
|
282
|
+
};
|
|
283
|
+
const formatStr = (options.format || 'text').toLowerCase();
|
|
284
|
+
if (formatStr === 'json') {
|
|
285
|
+
const payload = buildMachineEnvelope({
|
|
286
|
+
schemaVersion: '1.0',
|
|
287
|
+
command: 'doctor',
|
|
288
|
+
rootPath: root,
|
|
289
|
+
deterministic: Boolean(options.deterministic),
|
|
290
|
+
status: exitCode === 0 ? 'success' : 'failure',
|
|
291
|
+
data: { checks, summary },
|
|
292
|
+
errors: [
|
|
293
|
+
...failedChecks,
|
|
294
|
+
...(strictFailure ? [{ id: 'strict_warn_policy', message: 'Strict mode treats warnings as failures.' }] : []),
|
|
295
|
+
],
|
|
296
|
+
agentSummary: {
|
|
297
|
+
changedComponents: 0,
|
|
298
|
+
riskReasons: checks.filter((check) => check.status !== 'pass').map((check) => check.id),
|
|
299
|
+
suggestedReviewerChecks: [
|
|
300
|
+
'Resolve all fail-status diagnostics before CI artifact generation.',
|
|
301
|
+
'Treat warn-status diagnostics as reliability debt if not immediately blocking.',
|
|
302
|
+
],
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
306
|
+
process.exit(exitCode);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
console.log(chalk.blue('\n𩺠archscope doctor'));
|
|
310
|
+
for (const check of checks) {
|
|
311
|
+
const icon = check.status === 'pass' ? 'ā
' : (check.status === 'warn' ? 'ā ļø' : 'ā');
|
|
312
|
+
const line = `${icon} ${check.label}: ${check.message}`;
|
|
313
|
+
if (check.status === 'pass') {
|
|
314
|
+
console.log(chalk.green(line));
|
|
315
|
+
} else if (check.status === 'warn') {
|
|
316
|
+
console.log(chalk.yellow(line));
|
|
317
|
+
} else {
|
|
318
|
+
console.log(chalk.red(line));
|
|
319
|
+
}
|
|
320
|
+
if (check.fix) {
|
|
321
|
+
console.log(chalk.gray(` Fix: ${check.fix}`));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
console.log(chalk.cyan('\nNext steps:'));
|
|
326
|
+
console.log(' 1) Resolve any ā checks first, then rerun `archscope doctor`.');
|
|
327
|
+
console.log(' 2) Run `archscope generate-all . --artifact-profile agent` once diagnostics are clean.');
|
|
328
|
+
|
|
329
|
+
process.exit(exitCode);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = {
|
|
334
|
+
registerDoctorCommand,
|
|
335
|
+
};
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const {
|
|
3
|
+
applyDiagramRcDefaults,
|
|
4
|
+
getDiagramRcFromProgram,
|
|
5
|
+
resolveRootPathOrExit,
|
|
6
|
+
runAnalysisPipeline,
|
|
7
|
+
} = require('./shared');
|
|
8
|
+
const { buildMachineEnvelope } = require('./output');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Produce a sanitized node identifier suitable for Mermaid by replacing any character
|
|
12
|
+
* that is not a letter, digit or underscore with an underscore; optionally ensure uniqueness.
|
|
13
|
+
*
|
|
14
|
+
* @param {any} name - Value to convert into an identifier; will be stringified.
|
|
15
|
+
* @param {Map<string,number>} [registry] - Optional map that tracks occurrences of generated bases;
|
|
16
|
+
* when provided, the first occurrence returns the base, subsequent occurrences return `base_<N>` where
|
|
17
|
+
* `N` is the zero-based occurrence index.
|
|
18
|
+
* @returns {string} The sanitised identifier, uniquified with a `_<N>` suffix when `registry` indicates a duplicate.
|
|
19
|
+
*/
|
|
20
|
+
function sanitizeNodeId(name, registry) {
|
|
21
|
+
const base = String(name).replace(/[^a-zA-Z0-9_]/g, '_');
|
|
22
|
+
if (!registry) {
|
|
23
|
+
return base;
|
|
24
|
+
}
|
|
25
|
+
const seen = registry.get(base) || 0;
|
|
26
|
+
registry.set(base, seen + 1);
|
|
27
|
+
return seen === 0 ? base : `${base}_${seen}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Locate a component whose `name`, `originalName` or `filePath` matches a query, preferring exact matches.
|
|
32
|
+
* @param {Array<Object>} components - Array of component objects; each should have `name`, `originalName` and `filePath` properties.
|
|
33
|
+
* @param {string} query - Search term; compared case-insensitively. An empty or missing query returns `null`.
|
|
34
|
+
* @returns {Object|null} The first component with an exact case-insensitive match on `name`, `originalName` or `filePath`, or if none, the first component where any of those fields contains the query as a substring; `null` if no match.
|
|
35
|
+
*/
|
|
36
|
+
function findComponent(components, query) {
|
|
37
|
+
const normalizedQuery = String(query || '').toLowerCase().trim();
|
|
38
|
+
if (!normalizedQuery) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return components.find((component) =>
|
|
42
|
+
component.name.toLowerCase() === normalizedQuery
|
|
43
|
+
|| component.originalName.toLowerCase() === normalizedQuery
|
|
44
|
+
|| component.filePath.toLowerCase() === normalizedQuery
|
|
45
|
+
) || components.find((component) =>
|
|
46
|
+
component.name.toLowerCase().includes(normalizedQuery)
|
|
47
|
+
|| component.originalName.toLowerCase().includes(normalizedQuery)
|
|
48
|
+
|| component.filePath.toLowerCase().includes(normalizedQuery)
|
|
49
|
+
) || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the dependency neighbourhood around a target component up to a specified depth.
|
|
54
|
+
*
|
|
55
|
+
* @param {Array<Object>} components - All analysed components; each object is expected to include `name` and may include `originalName`, `filePath`, `roleTags`, `type` and `dependencies` (array of component names).
|
|
56
|
+
* @param {Object} target - The component to centre the neighbourhood on; its `name` is used as the seed and its `dependencies` are treated as outgoing edges.
|
|
57
|
+
* @param {number} maxDepth - Maximum traversal depth from the target (direct neighbours are at depth 1).
|
|
58
|
+
* @returns {Object} An object containing:
|
|
59
|
+
* - `neighborhood` {Array<Object>} ā components included in the neighbourhood set,
|
|
60
|
+
* - `incoming` {Array<string>} ā sorted names of components whose dependencies include the target,
|
|
61
|
+
* - `outgoing` {Array<string>} ā sorted names of the target's direct dependencies.
|
|
62
|
+
*/
|
|
63
|
+
function buildNeighborhood(components, target, maxDepth) {
|
|
64
|
+
const byName = new Map(components.map((component) => [component.name, component]));
|
|
65
|
+
const selected = new Set([target.name]);
|
|
66
|
+
const queue = [{ name: target.name, depth: 0 }];
|
|
67
|
+
|
|
68
|
+
// Precompute reverse-dependency index
|
|
69
|
+
const reverseDeps = new Map();
|
|
70
|
+
for (const component of components) {
|
|
71
|
+
for (const depName of component.dependencies || []) {
|
|
72
|
+
if (!reverseDeps.has(depName)) {
|
|
73
|
+
reverseDeps.set(depName, []);
|
|
74
|
+
}
|
|
75
|
+
reverseDeps.get(depName).push(component.name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
while (queue.length > 0) {
|
|
80
|
+
const current = queue.shift();
|
|
81
|
+
if (current.depth >= maxDepth) continue;
|
|
82
|
+
const component = byName.get(current.name);
|
|
83
|
+
if (!component) continue;
|
|
84
|
+
|
|
85
|
+
for (const depName of component.dependencies || []) {
|
|
86
|
+
if (!selected.has(depName)) {
|
|
87
|
+
selected.add(depName);
|
|
88
|
+
queue.push({ name: depName, depth: current.depth + 1 });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const dependents = reverseDeps.get(component.name) || [];
|
|
93
|
+
for (const dependentName of dependents) {
|
|
94
|
+
if (!selected.has(dependentName)) {
|
|
95
|
+
selected.add(dependentName);
|
|
96
|
+
queue.push({ name: dependentName, depth: current.depth + 1 });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const neighborhood = components.filter((component) => selected.has(component.name));
|
|
102
|
+
const incoming = components
|
|
103
|
+
.filter((component) => (component.dependencies || []).includes(target.name))
|
|
104
|
+
.map((component) => component.name)
|
|
105
|
+
.sort();
|
|
106
|
+
const outgoing = [...(target.dependencies || [])].sort();
|
|
107
|
+
return { neighborhood, incoming, outgoing };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build a Mermaid graph describing the dependency neighbourhood around a target component.
|
|
112
|
+
*
|
|
113
|
+
* Produces a `graph TD` Mermaid string that declares nodes for each component in `neighborhood`,
|
|
114
|
+
* adds edges for dependencies that exist within the neighbourhood, and applies distinct classes
|
|
115
|
+
* to highlight the target component versus other neighbours.
|
|
116
|
+
*
|
|
117
|
+
* @param {Array<Object>} neighborhood - Array of component objects; each should include `name`, `originalName`, and `dependencies`.
|
|
118
|
+
* @param {string} targetName - The `name` of the target component to be highlighted.
|
|
119
|
+
* @returns {string} A Mermaid diagram string representing the neighbourhood graph with styling for the target and neighbour nodes.
|
|
120
|
+
*/
|
|
121
|
+
function buildNeighborhoodDiagram(neighborhood, targetName) {
|
|
122
|
+
const byName = new Map(neighborhood.map((component) => [component.name, component]));
|
|
123
|
+
const idRegistry = new Map();
|
|
124
|
+
const idByName = new Map();
|
|
125
|
+
const lines = ['graph TD'];
|
|
126
|
+
const classTarget = [];
|
|
127
|
+
const classNeighbor = [];
|
|
128
|
+
|
|
129
|
+
for (const component of neighborhood) {
|
|
130
|
+
const id = sanitizeNodeId(component.name, idRegistry);
|
|
131
|
+
idByName.set(component.name, id);
|
|
132
|
+
lines.push(` ${id}["${component.originalName}"]`);
|
|
133
|
+
if (component.name === targetName) {
|
|
134
|
+
classTarget.push(id);
|
|
135
|
+
} else {
|
|
136
|
+
classNeighbor.push(id);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const component of neighborhood) {
|
|
141
|
+
const from = idByName.get(component.name);
|
|
142
|
+
for (const depName of component.dependencies || []) {
|
|
143
|
+
if (!byName.has(depName)) continue;
|
|
144
|
+
const to = idByName.get(depName);
|
|
145
|
+
lines.push(` ${from} --> ${to}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (classTarget.length > 0) {
|
|
150
|
+
lines.push(' classDef target fill:#1f2937,color:#fff,stroke:#111827,stroke-width:2px;');
|
|
151
|
+
lines.push(` class ${classTarget.join(',')} target;`);
|
|
152
|
+
}
|
|
153
|
+
if (classNeighbor.length > 0) {
|
|
154
|
+
lines.push(' classDef neighbor fill:#dbeafe,color:#1e3a8a,stroke:#3b82f6,stroke-width:1px;');
|
|
155
|
+
lines.push(` class ${classNeighbor.join(',')} neighbor;`);
|
|
156
|
+
}
|
|
157
|
+
return lines.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Register the `explain` CLI command which reports a component's dependency neighbourhood as human-readable text or JSON and includes a Mermaid diagram.
|
|
162
|
+
*
|
|
163
|
+
* The command is mounted on the provided `program` and accepts a component query and optional path, plus options such as `--depth`, `--format=json`, `--deterministic` and filtering patterns. When `--format json` is used the command emits a structured machine envelope including target details, sorted neighborhood entries, incoming/outgoing lists and the Mermaid diagram. If the queried component cannot be found the process exits with code `2`.
|
|
164
|
+
*
|
|
165
|
+
* @param {import('commander').Command} program - Commander program instance to register the command on.
|
|
166
|
+
*/
|
|
167
|
+
function registerExplainCommand(program) {
|
|
168
|
+
program
|
|
169
|
+
.command('explain <component> [path]')
|
|
170
|
+
.description('Explain a component dependency neighborhood with text + Mermaid')
|
|
171
|
+
.option('--depth <n>', 'Neighborhood depth', '2')
|
|
172
|
+
.option('-m, --max-files <n>', 'Max files to analyze')
|
|
173
|
+
.option('-p, --patterns <list>', 'File patterns (comma-separated)')
|
|
174
|
+
.option('-e, --exclude <list>', 'Exclude patterns')
|
|
175
|
+
.option('--analyzer <name>', 'Analyzer plugin to use', 'default')
|
|
176
|
+
.option('-f, --format <type>', 'Output format (text, json)', 'text')
|
|
177
|
+
.option('--deterministic', 'Use deterministic machine output', false)
|
|
178
|
+
.option('-q, --quiet', 'Suppress non-essential logging', false)
|
|
179
|
+
.action(async (componentQuery, targetPath, rawOptions) => {
|
|
180
|
+
const options = applyDiagramRcDefaults(rawOptions, getDiagramRcFromProgram(program), ['patterns', 'exclude', 'maxFiles']);
|
|
181
|
+
const root = resolveRootPathOrExit(targetPath);
|
|
182
|
+
const formatStr = (options.format || 'text').toLowerCase();
|
|
183
|
+
const isJson = formatStr === 'json';
|
|
184
|
+
const depth = Math.max(1, parseInt(options.depth, 10) || 2);
|
|
185
|
+
|
|
186
|
+
const pipeline = await runAnalysisPipeline(root, options, 'explain');
|
|
187
|
+
const components = pipeline.analysis.components || [];
|
|
188
|
+
const target = findComponent(components, componentQuery);
|
|
189
|
+
if (!target) {
|
|
190
|
+
console.error(chalk.red(`ā Component "${componentQuery}" not found.`));
|
|
191
|
+
console.error(chalk.gray('Fix: run `diagram analyze .` and choose a component name from the output list.'));
|
|
192
|
+
process.exit(2);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const { neighborhood, incoming, outgoing } = buildNeighborhood(components, target, depth);
|
|
196
|
+
const mermaid = buildNeighborhoodDiagram(neighborhood, target.name);
|
|
197
|
+
|
|
198
|
+
if (isJson) {
|
|
199
|
+
const payload = buildMachineEnvelope({
|
|
200
|
+
schemaVersion: '1.0',
|
|
201
|
+
command: 'explain',
|
|
202
|
+
rootPath: root,
|
|
203
|
+
deterministic: Boolean(options.deterministic),
|
|
204
|
+
data: {
|
|
205
|
+
target: {
|
|
206
|
+
name: target.name,
|
|
207
|
+
originalName: target.originalName,
|
|
208
|
+
filePath: target.filePath,
|
|
209
|
+
roleTags: target.roleTags || [],
|
|
210
|
+
type: target.type,
|
|
211
|
+
},
|
|
212
|
+
incoming,
|
|
213
|
+
outgoing,
|
|
214
|
+
neighborhood: neighborhood
|
|
215
|
+
.map((component) => ({
|
|
216
|
+
name: component.name,
|
|
217
|
+
originalName: component.originalName,
|
|
218
|
+
filePath: component.filePath,
|
|
219
|
+
roleTags: component.roleTags || [],
|
|
220
|
+
type: component.type,
|
|
221
|
+
}))
|
|
222
|
+
.sort((a, b) => a.filePath.localeCompare(b.filePath)),
|
|
223
|
+
mermaid,
|
|
224
|
+
},
|
|
225
|
+
agentSummary: {
|
|
226
|
+
changedComponents: neighborhood.length,
|
|
227
|
+
riskReasons: [],
|
|
228
|
+
suggestedReviewerChecks: [
|
|
229
|
+
'Inspect incoming dependencies for unintended coupling.',
|
|
230
|
+
'Confirm outgoing dependencies reflect intended layer direction.',
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!options.quiet) {
|
|
239
|
+
console.log(chalk.green('\nš Component Explanation'));
|
|
240
|
+
console.log(` Target: ${target.originalName} (${target.filePath})`);
|
|
241
|
+
console.log(` Depth: ${depth}`);
|
|
242
|
+
console.log(` Incoming: ${incoming.length}`);
|
|
243
|
+
console.log(` Outgoing: ${outgoing.length}`);
|
|
244
|
+
console.log(` Neighborhood size: ${neighborhood.length}`);
|
|
245
|
+
|
|
246
|
+
console.log(chalk.yellow('\nIncoming dependencies:'));
|
|
247
|
+
if (incoming.length === 0) {
|
|
248
|
+
console.log(' - none');
|
|
249
|
+
} else {
|
|
250
|
+
incoming.forEach((item) => console.log(` - ${item}`));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
console.log(chalk.yellow('\nOutgoing dependencies:'));
|
|
254
|
+
if (outgoing.length === 0) {
|
|
255
|
+
console.log(' - none');
|
|
256
|
+
} else {
|
|
257
|
+
outgoing.forEach((item) => console.log(` - ${item}`));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log(chalk.green('\nš Mermaid neighborhood:\n'));
|
|
261
|
+
console.log('```mermaid');
|
|
262
|
+
console.log(mermaid);
|
|
263
|
+
console.log('```');
|
|
264
|
+
console.log(chalk.cyan('\nNext steps:'));
|
|
265
|
+
console.log(' 1) Run `archscope validate .` if this component crosses protected boundaries.');
|
|
266
|
+
console.log(' 2) Run `archscope workflow pr` to estimate blast-radius risk for current changes.');
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
module.exports = {
|
|
272
|
+
registerExplainCommand,
|
|
273
|
+
};
|