@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.
- 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 +180 -1760
- 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,694 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const {
|
|
3
|
+
compareStringsDeterministically,
|
|
4
|
+
sortStringsDeterministically,
|
|
5
|
+
} = require('./sort-utils');
|
|
6
|
+
const {
|
|
7
|
+
computeDelta,
|
|
8
|
+
computeBlastRadiusFromDelta,
|
|
9
|
+
computeRiskFromDelta,
|
|
10
|
+
writePrImpactArtifacts,
|
|
11
|
+
} = require('./pr-impact');
|
|
12
|
+
const {
|
|
13
|
+
validateGitRef,
|
|
14
|
+
isShallowClone,
|
|
15
|
+
detectPrRefsFromEnv,
|
|
16
|
+
runGitCommand,
|
|
17
|
+
getChangedFiles,
|
|
18
|
+
analyzeAtRef,
|
|
19
|
+
} = require('./git-helpers');
|
|
20
|
+
const {
|
|
21
|
+
probeCapabilities,
|
|
22
|
+
buildConfidenceReport,
|
|
23
|
+
writeConfidenceReport,
|
|
24
|
+
shouldFailStrictConfidence,
|
|
25
|
+
} = require('../confidence/pipeline');
|
|
26
|
+
const { buildMachineEnvelope } = require('../commands/output');
|
|
27
|
+
|
|
28
|
+
const VALID_OUTPUT_FORMATS = Object.freeze(['text', 'json']);
|
|
29
|
+
const VALID_RISK_THRESHOLDS = Object.freeze(['none', 'low', 'medium', 'high']);
|
|
30
|
+
const RISK_LEVEL_SCORE = Object.freeze({ none: 0, low: 1, medium: 2, high: 3 });
|
|
31
|
+
|
|
32
|
+
function hasNoChangedFiles(changedFiles) {
|
|
33
|
+
return (
|
|
34
|
+
(changedFiles?.changed?.length || 0) === 0 &&
|
|
35
|
+
(changedFiles?.renamed?.length || 0) === 0 &&
|
|
36
|
+
(changedFiles?.added?.length || 0) === 0 &&
|
|
37
|
+
(changedFiles?.deleted?.length || 0) === 0
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeListOption(value, splitList) {
|
|
42
|
+
if (Array.isArray(value)) return value;
|
|
43
|
+
if (value === undefined) return undefined;
|
|
44
|
+
return splitList(String(value || ''));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isNonEmptyString(value) {
|
|
48
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createVerboseLogger(enabled) {
|
|
52
|
+
return (...args) => {
|
|
53
|
+
if (enabled) console.log(...args);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildWorkflowPrEnvelope({
|
|
58
|
+
result,
|
|
59
|
+
rootPath,
|
|
60
|
+
deterministic = false,
|
|
61
|
+
status = 'success',
|
|
62
|
+
artifactPaths = null,
|
|
63
|
+
errors = [],
|
|
64
|
+
}) {
|
|
65
|
+
return buildMachineEnvelope({
|
|
66
|
+
schemaVersion: '1.0',
|
|
67
|
+
command: 'workflow-pr',
|
|
68
|
+
rootPath,
|
|
69
|
+
status,
|
|
70
|
+
deterministic,
|
|
71
|
+
data: {
|
|
72
|
+
prImpact: result,
|
|
73
|
+
artifacts: artifactPaths,
|
|
74
|
+
},
|
|
75
|
+
errors,
|
|
76
|
+
agentSummary: result.agentSummary,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sortPrImpactResultDeterministically(result) {
|
|
81
|
+
result.changedFiles = sortStringsDeterministically(result.changedFiles);
|
|
82
|
+
result.renamedFiles = [...(result.renamedFiles || [])].sort((a, b) => {
|
|
83
|
+
const fromCmp = compareStringsDeterministically(a?.from, b?.from);
|
|
84
|
+
if (fromCmp !== 0) return fromCmp;
|
|
85
|
+
return compareStringsDeterministically(a?.to, b?.to);
|
|
86
|
+
});
|
|
87
|
+
result.deletedFiles = sortStringsDeterministically(result.deletedFiles);
|
|
88
|
+
result.addedFiles = sortStringsDeterministically(result.addedFiles);
|
|
89
|
+
result.unmodeledChanges = sortStringsDeterministically(result.unmodeledChanges);
|
|
90
|
+
result.changedComponents = [...(result.changedComponents || [])]
|
|
91
|
+
.map((component) => ({
|
|
92
|
+
...component,
|
|
93
|
+
dependenciesAdded: sortStringsDeterministically(component.dependenciesAdded),
|
|
94
|
+
dependenciesRemoved: sortStringsDeterministically(component.dependenciesRemoved),
|
|
95
|
+
roleTagsAdded: sortStringsDeterministically(component.roleTagsAdded),
|
|
96
|
+
roleTagsRemoved: sortStringsDeterministically(component.roleTagsRemoved),
|
|
97
|
+
roleTags: sortStringsDeterministically(component.roleTags),
|
|
98
|
+
}))
|
|
99
|
+
.sort((a, b) => compareStringsDeterministically(a?.filePath, b?.filePath));
|
|
100
|
+
result.dependencyEdgeDelta = {
|
|
101
|
+
...(result.dependencyEdgeDelta || {}),
|
|
102
|
+
added: sortStringsDeterministically(result?.dependencyEdgeDelta?.added),
|
|
103
|
+
removed: sortStringsDeterministically(result?.dependencyEdgeDelta?.removed),
|
|
104
|
+
};
|
|
105
|
+
result.blastRadius = {
|
|
106
|
+
...(result.blastRadius || {}),
|
|
107
|
+
impactedComponents: sortStringsDeterministically(result?.blastRadius?.impactedComponents),
|
|
108
|
+
};
|
|
109
|
+
result.risk = {
|
|
110
|
+
...(result.risk || {}),
|
|
111
|
+
flags: sortStringsDeterministically(result?.risk?.flags),
|
|
112
|
+
};
|
|
113
|
+
result.agentSummary = {
|
|
114
|
+
...(result.agentSummary || {}),
|
|
115
|
+
riskReasons: sortStringsDeterministically(result?.agentSummary?.riskReasons),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Register the `workflow pr` CLI command group and its action handler for computing
|
|
121
|
+
* PR architecture impact (base → head), producing analysis artifacts and exit codes.
|
|
122
|
+
*
|
|
123
|
+
* The command analyses changed files between two git refs, computes component deltas,
|
|
124
|
+
* blast radius and risk, optionally emits a confidence report, writes artifacts to disk,
|
|
125
|
+
* and exits with a risk-gated exit code suitable for CI usage.
|
|
126
|
+
*
|
|
127
|
+
* @param {import('commander').Command} program - The root Commander program to attach commands to.
|
|
128
|
+
* @param {Object} deps - Dependency injection for filesystem, git and config helpers.
|
|
129
|
+
* @param {Function} deps.resolveRootPathOrExit - Resolve the repository root path or terminate the process on error.
|
|
130
|
+
* @param {Function} deps.validateOutputPath - Validate and normalise an output directory path relative to the repo root.
|
|
131
|
+
* @param {Function} [deps.applyDiagramRcDefaults] - Optional function to merge CLI options with .diagramrc defaults.
|
|
132
|
+
* @param {Function} [deps.getDiagramRc] - Optional function to read .diagramrc configuration; returns an object.
|
|
133
|
+
* @param {Function} [deps.splitList] - Optional function to split comma-separated CLI lists into arrays.
|
|
134
|
+
* @returns {import('commander').Command} The registered `workflow` command (parent of `pr`).
|
|
135
|
+
*/
|
|
136
|
+
function registerWorkflowCommands(program, deps) {
|
|
137
|
+
const {
|
|
138
|
+
resolveRootPathOrExit,
|
|
139
|
+
validateOutputPath,
|
|
140
|
+
applyDiagramRcDefaults = (options) => options,
|
|
141
|
+
getDiagramRc = () => ({}),
|
|
142
|
+
splitList = (value) => String(value || '')
|
|
143
|
+
.split(',')
|
|
144
|
+
.map((item) => item.trim())
|
|
145
|
+
.filter(Boolean),
|
|
146
|
+
} = deps;
|
|
147
|
+
|
|
148
|
+
const workflowCommand = program
|
|
149
|
+
.command('workflow')
|
|
150
|
+
.description('Architecture impact workflows for CI and review');
|
|
151
|
+
|
|
152
|
+
workflowCommand
|
|
153
|
+
.command('pr [path]')
|
|
154
|
+
.description('Generate architecture impact report for a PR (base → head diff)')
|
|
155
|
+
.option('--base <ref>', 'Base git ref (SHA, branch, or tag) - required unless auto-detected')
|
|
156
|
+
.option('--head <ref>', 'Head git ref (SHA, branch, or tag) - defaults to HEAD')
|
|
157
|
+
.option('-o, --output-dir <dir>', 'Output directory for artifacts', '.diagram/pr-impact')
|
|
158
|
+
.option('-d, --manifest-dir <dir>', 'Directory containing manifest.json', '.diagram')
|
|
159
|
+
.option('--max-depth <n>', 'Maximum blast radius traversal depth', '2')
|
|
160
|
+
.option('--max-nodes <n>', 'Maximum components in blast radius output', '50')
|
|
161
|
+
.option('--risk-threshold <level>', 'Risk threshold: none, low, medium, high', 'none')
|
|
162
|
+
.option('--fail-on-risk', 'Exit with code 1 if risk exceeds threshold', false)
|
|
163
|
+
.option('--risk-override-reason <string>', 'Override risk gate with documented reason (requires --fail-on-risk)')
|
|
164
|
+
.option('--confidence-report', 'Write confidence report artifact', false)
|
|
165
|
+
.option('--strict-confidence', 'Fail with exit code 1 when confidence checks degrade', false)
|
|
166
|
+
.option('--capability-check-only', 'Run only capability checks and confidence evaluation', false)
|
|
167
|
+
.option('-f, --format <type>', 'Output format (text, json)', 'text')
|
|
168
|
+
.option('-m, --max-files <n>', 'Max files to analyze at each ref (CLI > .diagramrc > built-in)')
|
|
169
|
+
.option('-p, --patterns <list>', 'File patterns (comma-separated)')
|
|
170
|
+
.option('-e, --exclude <list>', 'Exclude patterns (comma-separated)')
|
|
171
|
+
.option('--deterministic', 'Use deterministic machine output', false)
|
|
172
|
+
.option('--verbose', 'Show detailed output', false)
|
|
173
|
+
.action(async (targetPath, rawOptions) => {
|
|
174
|
+
const diagramRc = getDiagramRc() || {};
|
|
175
|
+
const options = applyDiagramRcDefaults(
|
|
176
|
+
rawOptions,
|
|
177
|
+
diagramRc,
|
|
178
|
+
['patterns', 'exclude', 'maxFiles'],
|
|
179
|
+
{ maxFiles: '10000' }
|
|
180
|
+
);
|
|
181
|
+
const formatStr = (options.format || 'text').toLowerCase();
|
|
182
|
+
const isJson = formatStr === 'json';
|
|
183
|
+
const verboseOutput = !isJson && Boolean(options.verbose);
|
|
184
|
+
const logVerbose = createVerboseLogger(verboseOutput);
|
|
185
|
+
if (!VALID_OUTPUT_FORMATS.includes(formatStr)) {
|
|
186
|
+
console.error(chalk.red('❌ Invalid format:'), options.format);
|
|
187
|
+
console.error(chalk.gray('Valid values:', VALID_OUTPUT_FORMATS.join(', ')));
|
|
188
|
+
process.exit(2);
|
|
189
|
+
}
|
|
190
|
+
const root = resolveRootPathOrExit(targetPath);
|
|
191
|
+
const startTime = Date.now();
|
|
192
|
+
const confidenceEnabled = Boolean(
|
|
193
|
+
options.confidenceReport || options.strictConfidence || options.capabilityCheckOnly
|
|
194
|
+
);
|
|
195
|
+
let capabilities = null;
|
|
196
|
+
|
|
197
|
+
if (confidenceEnabled) {
|
|
198
|
+
capabilities = probeCapabilities('workflow-pr', {});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (options.capabilityCheckOnly) {
|
|
202
|
+
const quickReport = buildConfidenceReport({
|
|
203
|
+
command: 'workflow-pr',
|
|
204
|
+
rootPath: root,
|
|
205
|
+
capabilities,
|
|
206
|
+
validation: { enabled: false, valid: true, errors: [] },
|
|
207
|
+
fallback: { used: false, reasons: [] },
|
|
208
|
+
notes: ['capability_check_only'],
|
|
209
|
+
});
|
|
210
|
+
if (options.confidenceReport || options.strictConfidence) {
|
|
211
|
+
const confidencePath = writeConfidenceReport(root, quickReport);
|
|
212
|
+
if (isJson) {
|
|
213
|
+
quickReport.artifactPath = confidencePath;
|
|
214
|
+
} else {
|
|
215
|
+
console.log(chalk.gray('Confidence report:'), confidencePath);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (options.strictConfidence && shouldFailStrictConfidence(quickReport)) {
|
|
219
|
+
if (!isJson) {
|
|
220
|
+
console.error(chalk.red('❌ Strict confidence check failed'));
|
|
221
|
+
} else {
|
|
222
|
+
const payload = buildMachineEnvelope({
|
|
223
|
+
schemaVersion: '1.0',
|
|
224
|
+
command: 'workflow-pr',
|
|
225
|
+
rootPath: root,
|
|
226
|
+
status: 'failure',
|
|
227
|
+
deterministic: Boolean(options.deterministic),
|
|
228
|
+
data: { confidenceReport: quickReport },
|
|
229
|
+
errors: [{
|
|
230
|
+
code: 'strict_confidence_failed',
|
|
231
|
+
message: 'Strict confidence check failed',
|
|
232
|
+
}],
|
|
233
|
+
agentSummary: {
|
|
234
|
+
changedComponents: 0,
|
|
235
|
+
riskReasons: ['strict_confidence_failed'],
|
|
236
|
+
suggestedReviewerChecks: ['Inspect confidence report before relying on PR impact output.'],
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
240
|
+
}
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
if (isJson) {
|
|
244
|
+
const payload = buildMachineEnvelope({
|
|
245
|
+
schemaVersion: '1.0',
|
|
246
|
+
command: 'workflow-pr',
|
|
247
|
+
rootPath: root,
|
|
248
|
+
deterministic: Boolean(options.deterministic),
|
|
249
|
+
data: { confidenceReport: quickReport },
|
|
250
|
+
agentSummary: {
|
|
251
|
+
changedComponents: 0,
|
|
252
|
+
riskReasons: [],
|
|
253
|
+
suggestedReviewerChecks: ['Capability check completed without architecture analysis.'],
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
257
|
+
} else {
|
|
258
|
+
console.log(chalk.green('✅ Capability check complete'));
|
|
259
|
+
}
|
|
260
|
+
process.exit(0);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Validate and resolve refs
|
|
264
|
+
let baseRef = options.base;
|
|
265
|
+
let headRef = options.head || 'HEAD';
|
|
266
|
+
|
|
267
|
+
// Auto-detect PR refs if not provided
|
|
268
|
+
if (!baseRef) {
|
|
269
|
+
const envRefs = detectPrRefsFromEnv();
|
|
270
|
+
if (envRefs.base) {
|
|
271
|
+
baseRef = envRefs.base;
|
|
272
|
+
logVerbose(chalk.gray('Auto-detected base ref from environment:', baseRef));
|
|
273
|
+
} else {
|
|
274
|
+
// Prefer remote-tracking default branch refs for CI clones.
|
|
275
|
+
try {
|
|
276
|
+
const remoteCandidates = ['origin/main', 'origin/master'];
|
|
277
|
+
for (const candidate of remoteCandidates) {
|
|
278
|
+
try {
|
|
279
|
+
validateGitRef(candidate, root);
|
|
280
|
+
baseRef = candidate;
|
|
281
|
+
break;
|
|
282
|
+
} catch (_candidateError) {
|
|
283
|
+
// Try next candidate.
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!baseRef) {
|
|
287
|
+
const originHead = runGitCommand(['rev-parse', '--abbrev-ref', 'origin/HEAD'], root).trim();
|
|
288
|
+
if (originHead && originHead !== 'origin/HEAD') {
|
|
289
|
+
validateGitRef(originHead, root);
|
|
290
|
+
baseRef = originHead;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (!baseRef) {
|
|
294
|
+
throw new Error('No remote base branch available');
|
|
295
|
+
}
|
|
296
|
+
logVerbose(chalk.gray(`Using default base ref: ${baseRef}`));
|
|
297
|
+
} catch {
|
|
298
|
+
console.error(chalk.red('❌ No base ref provided and could not auto-detect.'));
|
|
299
|
+
console.error(chalk.gray('Specify --base <ref> or run from a PR context.'));
|
|
300
|
+
process.exit(2);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Check for shallow clone warning
|
|
306
|
+
if (isShallowClone(root)) {
|
|
307
|
+
console.warn(chalk.yellow('⚠️ Shallow clone detected. Base refs may be unavailable.'));
|
|
308
|
+
const shallowHint = chalk.gray(' Use fetch-depth: 0 in CI or run: git fetch --unshallow');
|
|
309
|
+
if (isJson) {
|
|
310
|
+
console.error(shallowHint);
|
|
311
|
+
} else {
|
|
312
|
+
console.log(shallowHint);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Validate refs
|
|
317
|
+
let baseSha, headSha;
|
|
318
|
+
try {
|
|
319
|
+
baseSha = validateGitRef(baseRef, root);
|
|
320
|
+
headSha = validateGitRef(headRef, root);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
console.error(chalk.red('❌ Git ref error:'), error.message);
|
|
323
|
+
process.exit(2);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (verboseOutput) {
|
|
327
|
+
console.log(chalk.blue('📊 PR Impact Analysis'));
|
|
328
|
+
console.log(chalk.gray(' Base:'), baseRef, '→', baseSha);
|
|
329
|
+
console.log(chalk.gray(' Head:'), headRef, '→', headSha);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Validate risk threshold
|
|
333
|
+
const threshold = (options.riskThreshold || 'none').toLowerCase();
|
|
334
|
+
if (!VALID_RISK_THRESHOLDS.includes(threshold)) {
|
|
335
|
+
console.error(chalk.red('❌ Invalid risk threshold:'), options.riskThreshold);
|
|
336
|
+
console.error(chalk.gray('Valid values:', VALID_RISK_THRESHOLDS.join(', ')));
|
|
337
|
+
process.exit(2);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Validate override reason
|
|
341
|
+
if (options.riskOverrideReason && !options.failOnRisk) {
|
|
342
|
+
console.error(chalk.red('❌ --risk-override-reason requires --fail-on-risk'));
|
|
343
|
+
process.exit(2);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (options.riskOverrideReason && typeof options.riskOverrideReason !== 'string') {
|
|
347
|
+
console.error(chalk.red('❌ --risk-override-reason must be a non-empty string'));
|
|
348
|
+
process.exit(2);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Validate numeric options
|
|
352
|
+
const maxDepth = parseInt(options.maxDepth, 10);
|
|
353
|
+
const maxNodes = parseInt(options.maxNodes, 10);
|
|
354
|
+
if (isNaN(maxDepth) || maxDepth < 1) {
|
|
355
|
+
console.error(chalk.red('❌ --max-depth must be a positive integer'));
|
|
356
|
+
process.exit(2);
|
|
357
|
+
}
|
|
358
|
+
if (isNaN(maxNodes) || maxNodes < 1) {
|
|
359
|
+
console.error(chalk.red('❌ --max-nodes must be a positive integer'));
|
|
360
|
+
process.exit(2);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Validate output directory
|
|
364
|
+
let outputDir;
|
|
365
|
+
try {
|
|
366
|
+
outputDir = validateOutputPath(options.outputDir, root);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
console.error(chalk.red('❌ Output path error:'), err.message);
|
|
369
|
+
process.exit(2);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Phase 2: Git diff ingestion + snapshot preparation
|
|
373
|
+
logVerbose(chalk.blue('\n📋 Step 1: Extracting changed files...'));
|
|
374
|
+
|
|
375
|
+
let changedFiles;
|
|
376
|
+
try {
|
|
377
|
+
changedFiles = getChangedFiles(baseSha, headSha, root);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
console.error(chalk.red('❌ Git diff error:'), error.message);
|
|
380
|
+
process.exit(2);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
logVerbose(chalk.gray(' Changed:'), changedFiles.changed.length);
|
|
384
|
+
logVerbose(chalk.gray(' Renamed:'), changedFiles.renamed.length);
|
|
385
|
+
logVerbose(chalk.gray(' Added:'), changedFiles.added.length);
|
|
386
|
+
logVerbose(chalk.gray(' Deleted:'), changedFiles.deleted.length);
|
|
387
|
+
|
|
388
|
+
// Handle empty diff case
|
|
389
|
+
if (hasNoChangedFiles(changedFiles)) {
|
|
390
|
+
const emptyResult = {
|
|
391
|
+
schemaVersion: '1.0',
|
|
392
|
+
generatedAt: options.deterministic ? '1970-01-01T00:00:00.000Z' : new Date().toISOString(),
|
|
393
|
+
base: baseSha,
|
|
394
|
+
head: headSha,
|
|
395
|
+
changedFiles: [],
|
|
396
|
+
renamedFiles: [],
|
|
397
|
+
addedFiles: [],
|
|
398
|
+
deletedFiles: [],
|
|
399
|
+
unmodeledChanges: [],
|
|
400
|
+
changedComponents: [],
|
|
401
|
+
dependencyEdgeDelta: { added: [], removed: [], count: 0 },
|
|
402
|
+
blastRadius: {
|
|
403
|
+
depth: maxDepth,
|
|
404
|
+
truncated: false,
|
|
405
|
+
omittedCount: 0,
|
|
406
|
+
impactedComponents: []
|
|
407
|
+
},
|
|
408
|
+
risk: {
|
|
409
|
+
score: 0,
|
|
410
|
+
level: 'none',
|
|
411
|
+
flags: [],
|
|
412
|
+
factors: {
|
|
413
|
+
authTouch: false,
|
|
414
|
+
securityBoundaryTouch: false,
|
|
415
|
+
databasePathTouch: false,
|
|
416
|
+
blastRadiusSize: 0,
|
|
417
|
+
blastRadiusDepth: 0,
|
|
418
|
+
edgeDeltaCount: 0
|
|
419
|
+
},
|
|
420
|
+
override: {
|
|
421
|
+
applied: false,
|
|
422
|
+
reason: options.riskOverrideReason || null
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
_meta: {
|
|
426
|
+
status: 'no_changes',
|
|
427
|
+
message: 'No changes detected between base and head refs',
|
|
428
|
+
durationMs: options.deterministic ? 0 : Date.now() - startTime
|
|
429
|
+
},
|
|
430
|
+
agentSummary: {
|
|
431
|
+
changedComponents: 0,
|
|
432
|
+
riskReasons: [],
|
|
433
|
+
suggestedReviewerChecks: [
|
|
434
|
+
'No architecture-impacting files detected.',
|
|
435
|
+
'Skip PR risk override unless code changes are introduced.',
|
|
436
|
+
],
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
if (isJson) {
|
|
441
|
+
const payload = buildWorkflowPrEnvelope({
|
|
442
|
+
result: emptyResult,
|
|
443
|
+
rootPath: root,
|
|
444
|
+
deterministic: Boolean(options.deterministic),
|
|
445
|
+
});
|
|
446
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
447
|
+
} else {
|
|
448
|
+
console.log(chalk.green('\n✅ No architecture changes detected'));
|
|
449
|
+
console.log(chalk.cyan('\nNext steps:'));
|
|
450
|
+
console.log(' 1) Skip architecture-risk override for this PR.');
|
|
451
|
+
console.log(' 2) Re-run `archscope workflow pr` after new code changes.');
|
|
452
|
+
}
|
|
453
|
+
process.exit(0);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Phase 2: Analyze snapshots at base and head refs
|
|
457
|
+
logVerbose(chalk.blue('\n📊 Step 2: Analyzing codebase snapshots...'));
|
|
458
|
+
|
|
459
|
+
let baseAnalysis, headAnalysis;
|
|
460
|
+
try {
|
|
461
|
+
const maxFilesAtRef = parseInt(options.maxFiles, 10) || 10000;
|
|
462
|
+
const patterns = normalizeListOption(options.patterns, splitList);
|
|
463
|
+
const exclude = normalizeListOption(options.exclude, splitList);
|
|
464
|
+
const analysisOptions = {
|
|
465
|
+
maxFiles: maxFilesAtRef,
|
|
466
|
+
patterns,
|
|
467
|
+
exclude,
|
|
468
|
+
deterministic: Boolean(options.deterministic),
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
baseAnalysis = await analyzeAtRef(baseSha, root, analysisOptions);
|
|
472
|
+
logVerbose(chalk.gray(' Base components:'), baseAnalysis.components.length);
|
|
473
|
+
|
|
474
|
+
headAnalysis = await analyzeAtRef(headSha, root, analysisOptions);
|
|
475
|
+
logVerbose(chalk.gray(' Head components:'), headAnalysis.components.length);
|
|
476
|
+
} catch (error) {
|
|
477
|
+
console.error(chalk.red('❌ Analysis error:'), error.message);
|
|
478
|
+
process.exit(2);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Compute delta between snapshots
|
|
482
|
+
logVerbose(chalk.blue('\n🔄 Step 3: Computing delta...'));
|
|
483
|
+
|
|
484
|
+
const delta = computeDelta(baseAnalysis, headAnalysis, changedFiles);
|
|
485
|
+
|
|
486
|
+
logVerbose(chalk.gray(' Changed components:'), delta.changedComponents.length);
|
|
487
|
+
logVerbose(chalk.gray(' Unmodeled changes:'), delta.unmodeledChanges.length);
|
|
488
|
+
logVerbose(chalk.gray(' Edge delta:'), delta.dependencyEdgeDelta.count);
|
|
489
|
+
|
|
490
|
+
// Compute blast radius (Phase 3 - basic implementation)
|
|
491
|
+
logVerbose(chalk.blue('\n💥 Step 4: Computing blast radius...'));
|
|
492
|
+
|
|
493
|
+
const blastRadius = computeBlastRadiusFromDelta(delta, headAnalysis, maxDepth, maxNodes);
|
|
494
|
+
|
|
495
|
+
logVerbose(chalk.gray(' Impacted components:'), blastRadius.impactedComponents.length);
|
|
496
|
+
logVerbose(chalk.gray(' Truncated:'), blastRadius.truncated);
|
|
497
|
+
|
|
498
|
+
// Compute risk score (Phase 4 - basic implementation)
|
|
499
|
+
logVerbose(chalk.blue('\n⚠️ Step 5: Computing risk score...'));
|
|
500
|
+
|
|
501
|
+
const risk = computeRiskFromDelta(delta, blastRadius);
|
|
502
|
+
|
|
503
|
+
logVerbose(chalk.gray(' Risk score:'), risk.score);
|
|
504
|
+
logVerbose(chalk.gray(' Risk level:'), risk.level);
|
|
505
|
+
logVerbose(chalk.gray(' Risk flags:'), risk.flags.join(', ') || 'none');
|
|
506
|
+
|
|
507
|
+
// Build final result
|
|
508
|
+
const result = {
|
|
509
|
+
schemaVersion: '1.0',
|
|
510
|
+
generatedAt: options.deterministic ? '1970-01-01T00:00:00.000Z' : new Date().toISOString(),
|
|
511
|
+
base: baseSha,
|
|
512
|
+
head: headSha,
|
|
513
|
+
changedFiles: changedFiles.changed,
|
|
514
|
+
renamedFiles: changedFiles.renamed,
|
|
515
|
+
deletedFiles: delta.deletedFiles,
|
|
516
|
+
addedFiles: delta.addedFiles,
|
|
517
|
+
unmodeledChanges: delta.unmodeledChanges,
|
|
518
|
+
changedComponents: delta.changedComponents,
|
|
519
|
+
dependencyEdgeDelta: delta.dependencyEdgeDelta,
|
|
520
|
+
blastRadius: {
|
|
521
|
+
depth: maxDepth,
|
|
522
|
+
truncated: blastRadius.truncated,
|
|
523
|
+
omittedCount: blastRadius.omittedCount,
|
|
524
|
+
impactedComponents: blastRadius.impactedComponents
|
|
525
|
+
},
|
|
526
|
+
risk: {
|
|
527
|
+
score: risk.score,
|
|
528
|
+
level: risk.level,
|
|
529
|
+
flags: risk.flags,
|
|
530
|
+
factors: risk.factors,
|
|
531
|
+
override: {
|
|
532
|
+
applied: false,
|
|
533
|
+
reason: options.riskOverrideReason || null
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
_meta: {
|
|
537
|
+
status: 'complete',
|
|
538
|
+
durationMs: Date.now() - startTime,
|
|
539
|
+
baseComponents: baseAnalysis.components.length,
|
|
540
|
+
headComponents: headAnalysis.components.length
|
|
541
|
+
},
|
|
542
|
+
agentSummary: {
|
|
543
|
+
changedComponents: delta.changedComponents.length,
|
|
544
|
+
riskReasons: risk.flags,
|
|
545
|
+
suggestedReviewerChecks: [
|
|
546
|
+
'Validate touched auth/security/database paths with domain owners.',
|
|
547
|
+
'Review blast-radius components for transitive side effects.',
|
|
548
|
+
'Use risk override only with an explicit mitigation plan.',
|
|
549
|
+
],
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
if (options.deterministic) {
|
|
554
|
+
sortPrImpactResultDeterministically(result);
|
|
555
|
+
if (result._meta && typeof result._meta === 'object') {
|
|
556
|
+
result._meta.durationMs = 0;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Exit code logic
|
|
561
|
+
// 0 = success, below threshold
|
|
562
|
+
// 1 = risk threshold exceeded (no override)
|
|
563
|
+
// 2 = config/git error (already handled above)
|
|
564
|
+
|
|
565
|
+
// Check risk threshold gate BEFORE writing artifacts
|
|
566
|
+
// so the JSON reflects the override state correctly
|
|
567
|
+
let exitCode = 0;
|
|
568
|
+
if (options.failOnRisk && threshold !== 'none') {
|
|
569
|
+
const thresholdNum = RISK_LEVEL_SCORE[threshold];
|
|
570
|
+
const riskNum = RISK_LEVEL_SCORE[result.risk.level];
|
|
571
|
+
|
|
572
|
+
if (typeof riskNum !== 'number') {
|
|
573
|
+
console.error(chalk.red('\n❌ Unknown computed risk level:'), result.risk.level);
|
|
574
|
+
process.exit(2);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (riskNum >= thresholdNum) {
|
|
578
|
+
// Check for override
|
|
579
|
+
if (isNonEmptyString(options.riskOverrideReason)) {
|
|
580
|
+
result.risk.override.applied = true;
|
|
581
|
+
const overrideMessage = chalk.yellow('\n⚠️ Risk threshold exceeded, but override applied');
|
|
582
|
+
const overrideReason = `${chalk.gray(' Reason:')} ${options.riskOverrideReason}`;
|
|
583
|
+
if (isJson) {
|
|
584
|
+
console.error(overrideMessage);
|
|
585
|
+
console.error(overrideReason);
|
|
586
|
+
} else {
|
|
587
|
+
console.log(overrideMessage);
|
|
588
|
+
console.log(overrideReason);
|
|
589
|
+
}
|
|
590
|
+
exitCode = 0;
|
|
591
|
+
} else {
|
|
592
|
+
console.error(chalk.red('\n❌ Risk threshold exceeded'));
|
|
593
|
+
console.error(chalk.gray(' Threshold:'), threshold);
|
|
594
|
+
console.error(chalk.gray(' Actual:'), result.risk.level);
|
|
595
|
+
console.error(chalk.gray(' Score:'), result.risk.score);
|
|
596
|
+
if (!isJson) {
|
|
597
|
+
console.log(chalk.gray('\n Use --risk-override-reason to bypass'));
|
|
598
|
+
}
|
|
599
|
+
exitCode = 1;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (confidenceEnabled) {
|
|
605
|
+
const report = buildConfidenceReport({
|
|
606
|
+
command: 'workflow-pr',
|
|
607
|
+
rootPath: root,
|
|
608
|
+
capabilities,
|
|
609
|
+
validation: { enabled: false, valid: true, errors: [] },
|
|
610
|
+
fallback: { used: false, reasons: [] },
|
|
611
|
+
notes: [`risk:${result.risk.level}`, `changed_components:${result.changedComponents.length}`],
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
if (options.confidenceReport || options.strictConfidence) {
|
|
615
|
+
const confidencePath = writeConfidenceReport(root, report);
|
|
616
|
+
if (!isJson) {
|
|
617
|
+
console.log(chalk.gray(' Confidence:'), confidencePath);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (options.strictConfidence && shouldFailStrictConfidence(report)) {
|
|
622
|
+
exitCode = 1;
|
|
623
|
+
result.agentSummary.riskReasons = [
|
|
624
|
+
...new Set([...(result.agentSummary.riskReasons || []), 'strict_confidence_failed']),
|
|
625
|
+
];
|
|
626
|
+
if (!isJson) {
|
|
627
|
+
console.error(chalk.red('\n❌ Strict confidence check failed'));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Write artifacts to disk (after risk check so override.applied is correct)
|
|
633
|
+
let artifactPaths;
|
|
634
|
+
try {
|
|
635
|
+
artifactPaths = writePrImpactArtifacts(outputDir, result, /* skipHtml */ isJson);
|
|
636
|
+
if (!isJson && exitCode === 0) {
|
|
637
|
+
console.log(chalk.gray(' Output:'), artifactPaths.jsonPath);
|
|
638
|
+
if (artifactPaths.htmlPath) {
|
|
639
|
+
console.log(chalk.gray(' HTML:'), artifactPaths.htmlPath);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
} catch (err) {
|
|
643
|
+
console.error(chalk.red('❌ Failed to write artifacts:'), err.message);
|
|
644
|
+
process.exit(2);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (isJson) {
|
|
648
|
+
const payload = buildWorkflowPrEnvelope({
|
|
649
|
+
result,
|
|
650
|
+
rootPath: root,
|
|
651
|
+
deterministic: Boolean(options.deterministic),
|
|
652
|
+
status: exitCode === 0 ? 'success' : 'failure',
|
|
653
|
+
artifactPaths,
|
|
654
|
+
errors: exitCode === 0
|
|
655
|
+
? []
|
|
656
|
+
: result.agentSummary.riskReasons.includes('strict_confidence_failed')
|
|
657
|
+
? [{
|
|
658
|
+
code: 'strict_confidence_failed',
|
|
659
|
+
message: 'Strict confidence check failed',
|
|
660
|
+
}]
|
|
661
|
+
: [{
|
|
662
|
+
code: 'risk_threshold_exceeded',
|
|
663
|
+
message: 'PR impact risk threshold exceeded',
|
|
664
|
+
}],
|
|
665
|
+
});
|
|
666
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
667
|
+
} else {
|
|
668
|
+
console.log(chalk.green('\n✅ PR Impact Analysis Complete'));
|
|
669
|
+
console.log(chalk.gray(' Duration:'), `${result._meta.durationMs}ms`);
|
|
670
|
+
console.log(chalk.gray(' Changed components:'), result.changedComponents.length);
|
|
671
|
+
console.log(chalk.gray(' Blast radius:'), result.blastRadius.impactedComponents.length);
|
|
672
|
+
console.log(chalk.gray(' Risk level:'), result.risk.level);
|
|
673
|
+
console.log(chalk.gray(' Risk score:'), result.risk.score);
|
|
674
|
+
if (result.risk.flags.length > 0) {
|
|
675
|
+
console.log(chalk.yellow(' Risk flags:'), result.risk.flags.join(', '));
|
|
676
|
+
}
|
|
677
|
+
console.log(chalk.cyan('\nNext steps:'));
|
|
678
|
+
console.log(' 1) Review `pr-impact.html` with architecture owners for high/medium risk PRs.');
|
|
679
|
+
console.log(' 2) Use `--risk-override-reason` only when mitigations are documented in the PR.');
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
process.exit(exitCode);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
return workflowCommand;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
module.exports = {
|
|
689
|
+
registerWorkflowCommands,
|
|
690
|
+
normalizeListOption,
|
|
691
|
+
compareStringsDeterministically,
|
|
692
|
+
sortPrImpactResultDeterministically,
|
|
693
|
+
buildWorkflowPrEnvelope,
|
|
694
|
+
};
|