@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,848 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const {
|
|
4
|
+
compareStringsDeterministically,
|
|
5
|
+
sortStringsDeterministically,
|
|
6
|
+
} = require('./sort-utils');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compare two arrays for strict element-wise equality.
|
|
10
|
+
*
|
|
11
|
+
* Returns `false` if either argument is not an array or their lengths differ.
|
|
12
|
+
* Elements are compared using strict equality (`===`) at the same indices.
|
|
13
|
+
*
|
|
14
|
+
* @param {Array} a - First array to compare.
|
|
15
|
+
* @param {Array} b - Second array to compare.
|
|
16
|
+
* @returns {boolean} `true` if both arrays have the same length and every element at each index is strictly equal, `false` otherwise.
|
|
17
|
+
*/
|
|
18
|
+
function arraysEqual(a, b) {
|
|
19
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
20
|
+
if (a.length !== b.length) return false;
|
|
21
|
+
return a.every((val, idx) => val === b[idx]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Convert a dependency reference to a canonical string identity when possible.
|
|
26
|
+
*
|
|
27
|
+
* Accepts a string identity or an object-shaped dependency. For object inputs,
|
|
28
|
+
* the function prefers `filePath`, falls back to `name`, and returns `null` if
|
|
29
|
+
* neither field is present or the input is not a recognised form.
|
|
30
|
+
*
|
|
31
|
+
* @param {*} dep - A dependency reference; either a string identity or an object with `filePath`/`name`.
|
|
32
|
+
* @returns {string|null} The resolved dependency identity (`filePath` preferred, then `name`), or `null` if not resolvable.
|
|
33
|
+
*/
|
|
34
|
+
function normalizeDependency(dep) {
|
|
35
|
+
if (typeof dep === 'string') return dep;
|
|
36
|
+
if (dep && typeof dep === 'object') {
|
|
37
|
+
return dep.filePath || dep.name || null;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extracts a canonical identity string from a component object.
|
|
44
|
+
*
|
|
45
|
+
* Prefers `filePath` then `name`; returns `null` when no identity can be derived.
|
|
46
|
+
* @param {Object|null} component - Component object which may contain `filePath` or `name`.
|
|
47
|
+
* @returns {string|null} The canonical identity (the `filePath` if present, otherwise the `name`), or `null` if none exists.
|
|
48
|
+
*/
|
|
49
|
+
function componentIdentity(component) {
|
|
50
|
+
if (!component || typeof component !== 'object') return null;
|
|
51
|
+
return component.filePath || component.name || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a map of alias keys (filePath and name) to a canonical component identity.
|
|
56
|
+
* @param {Array<Object>} components - List of component objects; each may contain `filePath` and/or `name`.
|
|
57
|
+
* @returns {Map<string,string>} A map where each `filePath` or `name` present on a component is routed to its canonical identity.
|
|
58
|
+
*/
|
|
59
|
+
function buildComponentAliasMap(components) {
|
|
60
|
+
const aliases = new Map();
|
|
61
|
+
for (const component of components || []) {
|
|
62
|
+
const canonical = componentIdentity(component);
|
|
63
|
+
if (!canonical) continue;
|
|
64
|
+
if (component.filePath) {
|
|
65
|
+
aliases.set(component.filePath, canonical);
|
|
66
|
+
}
|
|
67
|
+
if (component.name) {
|
|
68
|
+
aliases.set(component.name, canonical);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return aliases;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function indexComponentsByFilePath(components) {
|
|
75
|
+
const indexed = new Map();
|
|
76
|
+
for (const component of components || []) {
|
|
77
|
+
indexed.set(component.filePath, component);
|
|
78
|
+
}
|
|
79
|
+
return indexed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a component reference to its canonical identity using the provided alias map.
|
|
84
|
+
* @param {Object} component - Object that may contain `filePath` and/or `name` properties.
|
|
85
|
+
* @param {Map<string,string>} aliases - Map of alias → canonical identity.
|
|
86
|
+
* @returns {string|null} The canonical identity if an alias match is found; otherwise the first available candidate (`filePath` or `name`); or `null` if neither exists.
|
|
87
|
+
*/
|
|
88
|
+
function resolveIdentityFromComponent(component, aliases) {
|
|
89
|
+
const candidates = [component?.filePath, component?.name].filter(Boolean);
|
|
90
|
+
for (const candidate of candidates) {
|
|
91
|
+
if (aliases.has(candidate)) {
|
|
92
|
+
return aliases.get(candidate);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return candidates[0] || null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Produce a canonical, sorted list of dependency identities for a component.
|
|
100
|
+
*
|
|
101
|
+
* Normalises each entry in `component.dependencies`, maps aliases to canonical
|
|
102
|
+
* identities when an `aliases` map is provided, filters out unrecognised
|
|
103
|
+
* dependencies and returns the final list sorted lexicographically.
|
|
104
|
+
*
|
|
105
|
+
* @param {Object} component - Component object that may have a `dependencies` array.
|
|
106
|
+
* @param {Map<string,string>|null} [aliases=null] - Optional map of alias → canonical identity to resolve dependency names/paths.
|
|
107
|
+
* @returns {string[]} Sorted array of canonical dependency identities; empty array if there are no normalisable dependencies.
|
|
108
|
+
*/
|
|
109
|
+
function normalizedDependencies(component, aliases = null) {
|
|
110
|
+
const normalized = new Set((component?.dependencies || [])
|
|
111
|
+
.map((dep) => {
|
|
112
|
+
const normalized = normalizeDependency(dep);
|
|
113
|
+
if (!normalized) return null;
|
|
114
|
+
if (aliases?.has(normalized)) {
|
|
115
|
+
return aliases.get(normalized);
|
|
116
|
+
}
|
|
117
|
+
if (dep && typeof dep === 'object' && dep.name && aliases?.has(dep.name)) {
|
|
118
|
+
return aliases.get(dep.name);
|
|
119
|
+
}
|
|
120
|
+
return normalized;
|
|
121
|
+
})
|
|
122
|
+
.filter(Boolean));
|
|
123
|
+
return sortStringsDeterministically([...normalized]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create a Set of canonical dependency identities for a component.
|
|
128
|
+
* @param {Object} component - Component object with a `dependencies` field.
|
|
129
|
+
* @param {Map<string,string>|null} aliases - Optional map of alias → canonical identity used to resolve dependencies.
|
|
130
|
+
* @returns {Set<string>} A set of each dependency's canonical identity.
|
|
131
|
+
*/
|
|
132
|
+
function normalizedDependencySet(component, aliases = null) {
|
|
133
|
+
return new Set(normalizedDependencies(component, aliases));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function addDependencyEdges(edgeSet, component, aliases = null) {
|
|
137
|
+
const fromPath = component?.filePath;
|
|
138
|
+
if (!fromPath) return;
|
|
139
|
+
for (const dep of normalizedDependencies(component, aliases)) {
|
|
140
|
+
edgeSet.add(`${fromPath}→${dep}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildExistingComponentChange(filePath, baseComp, headComp, baseDeps, headDeps) {
|
|
145
|
+
const baseRoleTags = baseComp.roleTags || [];
|
|
146
|
+
const headRoleTags = headComp.roleTags || [];
|
|
147
|
+
const baseDepSet = new Set(baseDeps);
|
|
148
|
+
const headDepSet = new Set(headDeps);
|
|
149
|
+
return {
|
|
150
|
+
filePath,
|
|
151
|
+
name: headComp.name,
|
|
152
|
+
type: headComp.type,
|
|
153
|
+
roleTags: headRoleTags,
|
|
154
|
+
dependenciesAdded: [...headDepSet].filter((dep) => !baseDepSet.has(dep)),
|
|
155
|
+
dependenciesRemoved: [...baseDepSet].filter((dep) => !headDepSet.has(dep)),
|
|
156
|
+
roleTagsAdded: headRoleTags.filter((role) => !baseRoleTags.includes(role)),
|
|
157
|
+
roleTagsRemoved: baseRoleTags.filter((role) => !headRoleTags.includes(role)),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildNewComponentChange(filePath, headComp, headDependencies) {
|
|
162
|
+
const headRoleTags = headComp.roleTags || [];
|
|
163
|
+
return {
|
|
164
|
+
filePath,
|
|
165
|
+
name: headComp.name,
|
|
166
|
+
type: headComp.type,
|
|
167
|
+
roleTags: headRoleTags,
|
|
168
|
+
dependenciesAdded: headDependencies,
|
|
169
|
+
dependenciesRemoved: [],
|
|
170
|
+
roleTagsAdded: headRoleTags,
|
|
171
|
+
roleTagsRemoved: [],
|
|
172
|
+
isNew: true,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildPathListHtml(items, label) {
|
|
177
|
+
if (items.length === 0) return '';
|
|
178
|
+
return `
|
|
179
|
+
<div class="path-group">
|
|
180
|
+
<h4>${label}</h4>
|
|
181
|
+
<ul class="file-list">
|
|
182
|
+
${items.map((p) => `<li><code>${escapeHtml(p)}</code></li>`).join('')}
|
|
183
|
+
</ul>
|
|
184
|
+
</div>
|
|
185
|
+
`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function buildRenamedListHtml(items) {
|
|
189
|
+
if (items.length === 0) return '';
|
|
190
|
+
return `
|
|
191
|
+
<div class="path-group">
|
|
192
|
+
<h4>Renamed Files</h4>
|
|
193
|
+
<ul class="file-list">
|
|
194
|
+
${items.map((r) => `<li><code>${escapeHtml(r.from)}</code> → <code>${escapeHtml(r.to)}</code></li>`).join('')}
|
|
195
|
+
</ul>
|
|
196
|
+
</div>
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildActionItems(summary, riskNarrative) {
|
|
201
|
+
const items = [];
|
|
202
|
+
|
|
203
|
+
if (riskNarrative.level === 'high') {
|
|
204
|
+
items.push('Review all changes carefully - high risk detected');
|
|
205
|
+
}
|
|
206
|
+
if (riskNarrative.reasons.some((r) => r.includes('authentication'))) {
|
|
207
|
+
items.push('Verify authentication flow is not compromised');
|
|
208
|
+
items.push('Test all auth-related endpoints');
|
|
209
|
+
}
|
|
210
|
+
if (riskNarrative.reasons.some((r) => r.includes('security'))) {
|
|
211
|
+
items.push('Review security implications of boundary changes');
|
|
212
|
+
items.push('Check for potential privilege escalation');
|
|
213
|
+
}
|
|
214
|
+
if (riskNarrative.reasons.some((r) => r.includes('database'))) {
|
|
215
|
+
items.push('Review database schema changes');
|
|
216
|
+
items.push('Verify migration safety if applicable');
|
|
217
|
+
}
|
|
218
|
+
if (summary.blastRadiusSize >= 5) {
|
|
219
|
+
items.push('Review impact on downstream components');
|
|
220
|
+
}
|
|
221
|
+
if (summary.unmodeledCount > 0) {
|
|
222
|
+
items.push('Review unmodeled file changes');
|
|
223
|
+
}
|
|
224
|
+
if (riskNarrative.override?.applied) {
|
|
225
|
+
items.push(`Risk gate overridden: ${riskNarrative.override.reason}`);
|
|
226
|
+
}
|
|
227
|
+
if (items.length === 0) {
|
|
228
|
+
items.push('Standard review - no elevated risk factors detected');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return sortStringsDeterministically(items);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Build a concise delta summary describing file, component and dependency changes between two analysis snapshots.
|
|
236
|
+
*
|
|
237
|
+
* Produces a structured summary that lists:
|
|
238
|
+
* - modelled component changes (dependency and role-tag additions/removals, new components),
|
|
239
|
+
* - unmodelled file changes,
|
|
240
|
+
* - renamed, deleted and added files,
|
|
241
|
+
* - dependency-edge additions and removals with a total count.
|
|
242
|
+
*
|
|
243
|
+
* @param {object} baseAnalysis - Snapshot at the base ref; expected to contain a `components` array.
|
|
244
|
+
* @param {object} headAnalysis - Snapshot at the head ref; expected to contain a `components` array.
|
|
245
|
+
* @param {object} changedFiles - Output from getChangedFiles(); expected shape `{ changed: string[], renamed: object[], deleted: string[], added: string[] }`.
|
|
246
|
+
* @returns {object} Delta summary with keys:
|
|
247
|
+
* - `changedComponents` {Array} — list of components with `{ filePath, name, type, roleTags, dependenciesAdded, dependenciesRemoved, roleTagsAdded, roleTagsRemoved, isNew? }`.
|
|
248
|
+
* - `unmodeledChanges` {Array<string>} — changed file paths that have no modelled component.
|
|
249
|
+
* - `renamedFiles` {Array<object>} — as provided by `changedFiles.renamed`.
|
|
250
|
+
* - `deletedFiles` {Array<string>} — deleted file paths.
|
|
251
|
+
* - `addedFiles` {Array<string>} — added file paths.
|
|
252
|
+
* - `dependencyEdgeDelta` {object} — `{ added: string[], removed: string[], count: number }` where edges are represented as `"<filePath>→<dependencyIdentity>"`.
|
|
253
|
+
*/
|
|
254
|
+
function computeDelta(baseAnalysis, headAnalysis, changedFiles) {
|
|
255
|
+
const { changed, renamed, deleted, added } = changedFiles;
|
|
256
|
+
const baseAliases = buildComponentAliasMap(baseAnalysis.components || []);
|
|
257
|
+
const headAliases = buildComponentAliasMap(headAnalysis.components || []);
|
|
258
|
+
|
|
259
|
+
// Build component indexes by filePath
|
|
260
|
+
const baseByPath = indexComponentsByFilePath(baseAnalysis.components || []);
|
|
261
|
+
const headByPath = indexComponentsByFilePath(headAnalysis.components || []);
|
|
262
|
+
|
|
263
|
+
// Find changed components
|
|
264
|
+
const changedComponents = [];
|
|
265
|
+
const unmodeledChanges = [];
|
|
266
|
+
|
|
267
|
+
for (const filePath of changed) {
|
|
268
|
+
const headComp = headByPath.get(filePath);
|
|
269
|
+
if (!headComp) {
|
|
270
|
+
unmodeledChanges.push(filePath);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const baseComp = baseByPath.get(filePath);
|
|
275
|
+
if (!baseComp) {
|
|
276
|
+
changedComponents.push(buildNewComponentChange(
|
|
277
|
+
filePath,
|
|
278
|
+
headComp,
|
|
279
|
+
normalizedDependencies(headComp, headAliases)
|
|
280
|
+
));
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const baseDeps = normalizedDependencies(baseComp, baseAliases);
|
|
285
|
+
const headDeps = normalizedDependencies(headComp, headAliases);
|
|
286
|
+
const depsChanged = !arraysEqual(baseDeps, headDeps);
|
|
287
|
+
const rolesChanged = !arraysEqual(
|
|
288
|
+
sortStringsDeterministically(baseComp.roleTags || []),
|
|
289
|
+
sortStringsDeterministically(headComp.roleTags || [])
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
if (depsChanged || rolesChanged) {
|
|
293
|
+
changedComponents.push(buildExistingComponentChange(filePath, baseComp, headComp, baseDeps, headDeps));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Compute dependency edge deltas
|
|
298
|
+
const baseEdges = new Set();
|
|
299
|
+
for (const c of baseAnalysis.components || []) {
|
|
300
|
+
addDependencyEdges(baseEdges, c, baseAliases);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const headEdges = new Set();
|
|
304
|
+
for (const c of headAnalysis.components || []) {
|
|
305
|
+
addDependencyEdges(headEdges, c, headAliases);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const edgesAdded = sortStringsDeterministically([...headEdges].filter(e => !baseEdges.has(e)));
|
|
309
|
+
const edgesRemoved = sortStringsDeterministically([...baseEdges].filter(e => !headEdges.has(e)));
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
changedComponents: changedComponents.sort((a, b) => compareStringsDeterministically(a.filePath, b.filePath)),
|
|
313
|
+
unmodeledChanges: sortStringsDeterministically(unmodeledChanges),
|
|
314
|
+
renamedFiles: renamed,
|
|
315
|
+
deletedFiles: sortStringsDeterministically(deleted),
|
|
316
|
+
addedFiles: sortStringsDeterministically(added),
|
|
317
|
+
dependencyEdgeDelta: {
|
|
318
|
+
added: edgesAdded,
|
|
319
|
+
removed: edgesRemoved,
|
|
320
|
+
count: edgesAdded.length + edgesRemoved.length
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Compute impacted components by traversing reverse dependency links in the head analysis.
|
|
327
|
+
*
|
|
328
|
+
* @param {Object} delta - Delta summary produced by `computeDelta`, used for source identities (reads `changedComponents` and `addedFiles`).
|
|
329
|
+
* @param {Object} headAnalysis - Snapshot containing `components` to build reverse dependency relationships.
|
|
330
|
+
* @param {number} maxDepth - Maximum traversal depth from each changed/added source (inclusive).
|
|
331
|
+
* @param {number} maxNodes - Maximum number of impacted components to include in the result.
|
|
332
|
+
* @returns {Object} An object describing the blast radius:
|
|
333
|
+
* - impactedComponents {string[]} Sorted list of impacted component names or identities.
|
|
334
|
+
* - truncated {boolean} `true` if traversal discovered more components than `maxNodes`.
|
|
335
|
+
* - omittedCount {number} Number of discovered components omitted because of `maxNodes`.
|
|
336
|
+
*/
|
|
337
|
+
function computeBlastRadiusFromDelta(delta, headAnalysis, maxDepth, maxNodes) {
|
|
338
|
+
const components = headAnalysis.components || [];
|
|
339
|
+
const aliases = buildComponentAliasMap(components);
|
|
340
|
+
const byId = new Map();
|
|
341
|
+
const reverseDependents = new Map();
|
|
342
|
+
for (const component of components) {
|
|
343
|
+
const canonicalId = componentIdentity(component);
|
|
344
|
+
if (!canonicalId) continue;
|
|
345
|
+
byId.set(canonicalId, component);
|
|
346
|
+
const deps = normalizedDependencySet(component, aliases);
|
|
347
|
+
for (const depId of deps) {
|
|
348
|
+
if (!reverseDependents.has(depId)) {
|
|
349
|
+
reverseDependents.set(depId, []);
|
|
350
|
+
}
|
|
351
|
+
reverseDependents.get(depId).push(canonicalId);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const impacted = new Set();
|
|
356
|
+
const visited = new Set();
|
|
357
|
+
const queue = [];
|
|
358
|
+
|
|
359
|
+
// Start from changed components
|
|
360
|
+
for (const comp of delta.changedComponents || []) {
|
|
361
|
+
const identity = resolveIdentityFromComponent(comp, aliases);
|
|
362
|
+
if (!identity || visited.has(identity)) continue;
|
|
363
|
+
queue.push({ identity, depth: 0 });
|
|
364
|
+
visited.add(identity);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Also include components whose files were added
|
|
368
|
+
for (const filePath of delta.addedFiles || []) {
|
|
369
|
+
const identity = aliases.get(filePath) || filePath;
|
|
370
|
+
if (byId.has(identity) && !visited.has(identity)) {
|
|
371
|
+
queue.push({ identity, depth: 0 });
|
|
372
|
+
visited.add(identity);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
while (queue.length > 0 && impacted.size < maxNodes) {
|
|
377
|
+
const { identity, depth } = queue.shift();
|
|
378
|
+
|
|
379
|
+
if (depth > maxDepth) continue;
|
|
380
|
+
|
|
381
|
+
const comp = byId.get(identity);
|
|
382
|
+
if (!comp) continue;
|
|
383
|
+
|
|
384
|
+
const dependentIds = reverseDependents.get(identity) || [];
|
|
385
|
+
for (const dependentId of dependentIds) {
|
|
386
|
+
if (visited.has(dependentId)) {
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
visited.add(dependentId);
|
|
390
|
+
queue.push({ identity: dependentId, depth: depth + 1 });
|
|
391
|
+
if (impacted.size < maxNodes) {
|
|
392
|
+
impacted.add(byId.get(dependentId)?.name || dependentId);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const truncated = visited.size > maxNodes;
|
|
398
|
+
const omittedCount = Math.max(0, visited.size - maxNodes);
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
impactedComponents: sortStringsDeterministically([...impacted]),
|
|
402
|
+
truncated,
|
|
403
|
+
omittedCount
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Compute risk from delta using differentiated weights
|
|
409
|
+
*/
|
|
410
|
+
function computeRiskFromDelta(delta, blastRadius) {
|
|
411
|
+
let score = 0;
|
|
412
|
+
const flags = [];
|
|
413
|
+
const factors = {
|
|
414
|
+
authTouch: false,
|
|
415
|
+
securityBoundaryTouch: false,
|
|
416
|
+
databasePathTouch: false,
|
|
417
|
+
blastRadiusSize: 0,
|
|
418
|
+
blastRadiusDepth: 0,
|
|
419
|
+
edgeDeltaCount: 0
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// Check for role touches (differentiated weights)
|
|
423
|
+
for (const comp of delta.changedComponents) {
|
|
424
|
+
const roles = comp.roleTags || [];
|
|
425
|
+
|
|
426
|
+
if (roles.includes('auth') && !factors.authTouch) {
|
|
427
|
+
score += 3;
|
|
428
|
+
flags.push('auth_touch');
|
|
429
|
+
factors.authTouch = true;
|
|
430
|
+
}
|
|
431
|
+
if (roles.includes('security') && !factors.securityBoundaryTouch) {
|
|
432
|
+
score += 3;
|
|
433
|
+
flags.push('security_boundary_touch');
|
|
434
|
+
factors.securityBoundaryTouch = true;
|
|
435
|
+
}
|
|
436
|
+
if (roles.includes('database') && !factors.databasePathTouch) {
|
|
437
|
+
score += 2;
|
|
438
|
+
flags.push('database_path_touch');
|
|
439
|
+
factors.databasePathTouch = true;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Check blast radius size
|
|
444
|
+
const blastRadiusSize = blastRadius.impactedComponents.length;
|
|
445
|
+
if (blastRadiusSize >= 5) {
|
|
446
|
+
score += 1;
|
|
447
|
+
factors.blastRadiusSize = blastRadiusSize;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Check edge delta count
|
|
451
|
+
const edgeDeltaCount = delta.dependencyEdgeDelta.count || 0;
|
|
452
|
+
if (edgeDeltaCount >= 10) {
|
|
453
|
+
score += 1;
|
|
454
|
+
factors.edgeDeltaCount = edgeDeltaCount;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Determine level (score 0 = none, 1-2 = low, 3-5 = medium, 6+ = high)
|
|
458
|
+
let level = 'none';
|
|
459
|
+
if (score >= 6) {
|
|
460
|
+
level = 'high';
|
|
461
|
+
} else if (score >= 3) {
|
|
462
|
+
level = 'medium';
|
|
463
|
+
} else if (score >= 1) {
|
|
464
|
+
level = 'low';
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return { score, level, flags, factors };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Escape HTML special characters to prevent XSS
|
|
472
|
+
* @param {string} str - String to escape
|
|
473
|
+
* @returns {string} Escaped string
|
|
474
|
+
*/
|
|
475
|
+
function escapeHtml(str) {
|
|
476
|
+
if (typeof str !== 'string') return '';
|
|
477
|
+
return str
|
|
478
|
+
.replace(/&/g, '&')
|
|
479
|
+
.replace(/</g, '<')
|
|
480
|
+
.replace(/>/g, '>')
|
|
481
|
+
.replace(/"/g, '"')
|
|
482
|
+
.replace(/'/g, ''');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Group file paths by change status with stable sorting
|
|
487
|
+
* @param {object} result - PR impact result
|
|
488
|
+
* @param {number} maxPreview - Maximum items to show per group (default: 10)
|
|
489
|
+
* @returns {object} Grouped paths with counts and previews
|
|
490
|
+
*/
|
|
491
|
+
function groupChangePaths(result, maxPreview = 10) {
|
|
492
|
+
const groups = {
|
|
493
|
+
changed: { items: [], count: 0, truncated: false },
|
|
494
|
+
renamed: { items: [], count: 0, truncated: false },
|
|
495
|
+
added: { items: [], count: 0, truncated: false },
|
|
496
|
+
deleted: { items: [], count: 0, truncated: false },
|
|
497
|
+
unmodeled: { items: [], count: 0, truncated: false }
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const buildSortedPreviewGroup = (items) => {
|
|
501
|
+
const sorted = [...(items || [])].sort(compareStringsDeterministically);
|
|
502
|
+
return {
|
|
503
|
+
count: sorted.length,
|
|
504
|
+
items: sorted.slice(0, maxPreview),
|
|
505
|
+
truncated: sorted.length > maxPreview,
|
|
506
|
+
};
|
|
507
|
+
};
|
|
508
|
+
const buildRenamedPreviewGroup = (items) => {
|
|
509
|
+
const sorted = [...(items || [])].sort((a, b) => {
|
|
510
|
+
const fromCmp = compareStringsDeterministically(a?.from, b?.from);
|
|
511
|
+
if (fromCmp !== 0) return fromCmp;
|
|
512
|
+
return compareStringsDeterministically(a?.to, b?.to);
|
|
513
|
+
});
|
|
514
|
+
return {
|
|
515
|
+
count: sorted.length,
|
|
516
|
+
items: sorted.slice(0, maxPreview),
|
|
517
|
+
truncated: sorted.length > maxPreview,
|
|
518
|
+
};
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const renamedTargets = new Set((result.renamedFiles || []).map((item) => item?.to).filter(Boolean));
|
|
522
|
+
const addedPaths = new Set(result.addedFiles || []);
|
|
523
|
+
const modifiedOnly = (result.changedFiles || []).filter(
|
|
524
|
+
(filePath) => !addedPaths.has(filePath) && !renamedTargets.has(filePath)
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
groups.changed = buildSortedPreviewGroup(modifiedOnly);
|
|
528
|
+
groups.renamed = buildRenamedPreviewGroup(result.renamedFiles);
|
|
529
|
+
groups.added = buildSortedPreviewGroup(result.addedFiles);
|
|
530
|
+
groups.deleted = buildSortedPreviewGroup(result.deletedFiles);
|
|
531
|
+
groups.unmodeled = buildSortedPreviewGroup(result.unmodeledChanges);
|
|
532
|
+
|
|
533
|
+
return groups;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Build risk narrative from risk object
|
|
538
|
+
* @param {object} risk - Risk object from result
|
|
539
|
+
* @returns {object} Risk narrative with level, score, reasons, and override info
|
|
540
|
+
*/
|
|
541
|
+
function buildRiskNarrative(risk) {
|
|
542
|
+
const narrative = {
|
|
543
|
+
level: risk?.level || 'none',
|
|
544
|
+
score: risk?.score || 0,
|
|
545
|
+
reasons: [],
|
|
546
|
+
override: null
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// Build human-readable reasons from flags and factors
|
|
550
|
+
const factors = risk?.factors || {};
|
|
551
|
+
const flagDescriptions = {
|
|
552
|
+
'auth_touch': 'Touches authentication components',
|
|
553
|
+
'security_boundary_touch': 'Crosses security boundaries',
|
|
554
|
+
'database_path_touch': 'Modifies database-related code'
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
// Add flag-based reasons
|
|
558
|
+
for (const flag of risk?.flags || []) {
|
|
559
|
+
if (flagDescriptions[flag]) {
|
|
560
|
+
narrative.reasons.push(flagDescriptions[flag]);
|
|
561
|
+
} else {
|
|
562
|
+
narrative.reasons.push(flag.replace(/_/g, ' '));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Add factor-based context
|
|
567
|
+
if (factors.blastRadiusSize >= 5) {
|
|
568
|
+
narrative.reasons.push(`Large blast radius (${factors.blastRadiusSize} components impacted)`);
|
|
569
|
+
}
|
|
570
|
+
if (factors.edgeDeltaCount >= 10) {
|
|
571
|
+
narrative.reasons.push(`Significant dependency changes (${factors.edgeDeltaCount} edges modified)`);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Sort reasons for deterministic output
|
|
575
|
+
narrative.reasons = sortStringsDeterministically(narrative.reasons);
|
|
576
|
+
|
|
577
|
+
// Handle override
|
|
578
|
+
if (risk?.override?.applied) {
|
|
579
|
+
narrative.override = {
|
|
580
|
+
applied: true,
|
|
581
|
+
reason: risk.override.reason || 'No reason provided'
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return narrative;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Build summary metadata for executive summary section
|
|
590
|
+
* @param {object} result - PR impact result
|
|
591
|
+
* @returns {object} Summary metadata
|
|
592
|
+
*/
|
|
593
|
+
function buildSummaryMeta(result) {
|
|
594
|
+
const fileGroups = groupChangePaths(result);
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
totalFilesChanged: fileGroups.changed.count + fileGroups.renamed.count +
|
|
598
|
+
fileGroups.added.count + fileGroups.deleted.count,
|
|
599
|
+
changedComponents: (result.changedComponents || []).length,
|
|
600
|
+
blastRadiusSize: (result.blastRadius?.impactedComponents || []).length,
|
|
601
|
+
blastRadiusTruncated: result.blastRadius?.truncated || false,
|
|
602
|
+
blastRadiusOmitted: result.blastRadius?.omittedCount || 0,
|
|
603
|
+
blastRadiusDepth: result.blastRadius?.depth || 0,
|
|
604
|
+
riskLevel: result.risk?.level || 'none',
|
|
605
|
+
riskScore: result.risk?.score || 0,
|
|
606
|
+
unmodeledCount: fileGroups.unmodeled.count,
|
|
607
|
+
hasOverride: result.risk?.override?.applied || false,
|
|
608
|
+
generatedAt: result.generatedAt || new Date().toISOString(),
|
|
609
|
+
base: result.base || 'unknown',
|
|
610
|
+
head: result.head || 'unknown',
|
|
611
|
+
durationMs: result._meta?.durationMs || 0
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Generate HTML explainer for PR impact
|
|
617
|
+
* @param {object} result - PR impact result
|
|
618
|
+
* @returns {string} HTML content
|
|
619
|
+
*/
|
|
620
|
+
function generateHtmlExplainer(result) {
|
|
621
|
+
// Build content models using helpers
|
|
622
|
+
const summary = buildSummaryMeta(result);
|
|
623
|
+
const pathGroups = groupChangePaths(result);
|
|
624
|
+
const riskNarrative = buildRiskNarrative(result.risk);
|
|
625
|
+
|
|
626
|
+
const riskColors = {
|
|
627
|
+
none: '#6b7280',
|
|
628
|
+
low: '#22c55e',
|
|
629
|
+
medium: '#eab308',
|
|
630
|
+
high: '#ef4444'
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const riskColor = riskColors[riskNarrative.level] || '#6b7280';
|
|
634
|
+
|
|
635
|
+
// Sort changed components deterministically
|
|
636
|
+
const sortedComponents = [...(result.changedComponents || [])].sort((a, b) =>
|
|
637
|
+
compareStringsDeterministically(a?.name, b?.name)
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
// Build changed components HTML
|
|
641
|
+
const changedComponentsHtml = sortedComponents.map(comp => `
|
|
642
|
+
<li class="component">
|
|
643
|
+
<div class="component-name">${escapeHtml(comp.name)}</div>
|
|
644
|
+
<div class="component-path">${escapeHtml(comp.filePath)}</div>
|
|
645
|
+
<div class="component-roles">${sortStringsDeterministically(comp.roleTags || []).map(r => `<span class="role-tag">${escapeHtml(r)}</span>`).join(' ')}</div>
|
|
646
|
+
${comp.isNew ? '<span class="badge new">NEW</span>' : ''}
|
|
647
|
+
</li>
|
|
648
|
+
`).join('');
|
|
649
|
+
|
|
650
|
+
// Build blast radius HTML with sorted components
|
|
651
|
+
const sortedBlastRadius = sortStringsDeterministically(result.blastRadius?.impactedComponents || []);
|
|
652
|
+
const blastRadiusHtml = sortedBlastRadius.map(name => `
|
|
653
|
+
<li>${escapeHtml(name)}</li>
|
|
654
|
+
`).join('');
|
|
655
|
+
const sortedActionItems = buildActionItems(summary, riskNarrative);
|
|
656
|
+
|
|
657
|
+
const actionChecklistHtml = sortedActionItems.map(item => `
|
|
658
|
+
<li>${escapeHtml(item)}</li>
|
|
659
|
+
`).join('');
|
|
660
|
+
|
|
661
|
+
return `<!DOCTYPE html>
|
|
662
|
+
<html lang="en">
|
|
663
|
+
<head>
|
|
664
|
+
<meta charset="UTF-8">
|
|
665
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
666
|
+
<title>PR Impact Analysis</title>
|
|
667
|
+
<style>
|
|
668
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
669
|
+
body {
|
|
670
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
671
|
+
line-height: 1.6;
|
|
672
|
+
color: #1f2937;
|
|
673
|
+
background: #f9fafb;
|
|
674
|
+
padding: 2rem;
|
|
675
|
+
}
|
|
676
|
+
.container { max-width: 900px; margin: 0 auto; }
|
|
677
|
+
h1 { font-size: 1.5rem; margin-bottom: 1rem; color: #111827; }
|
|
678
|
+
h2 { font-size: 1.25rem; margin: 1.5rem 0 0.75rem; color: #374151; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; }
|
|
679
|
+
h3 { font-size: 1.1rem; margin: 1rem 0 0.5rem; color: #374151; }
|
|
680
|
+
h4 { font-size: 0.9rem; margin: 0.75rem 0 0.25rem; color: #6b7280; }
|
|
681
|
+
section { margin-bottom: 1.5rem; }
|
|
682
|
+
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
|
|
683
|
+
.summary-card { background: white; padding: 1rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
684
|
+
.summary-card .label { font-size: 0.75rem; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
685
|
+
.summary-card .value { font-size: 1.5rem; font-weight: 600; margin-top: 0.25rem; }
|
|
686
|
+
.risk-badge { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 9999px; font-weight: 600; font-size: 0.875rem; color: white; }
|
|
687
|
+
.component { background: white; padding: 1rem; border-radius: 0.5rem; margin-bottom: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); list-style: none; }
|
|
688
|
+
.component-name { font-weight: 600; color: #111827; }
|
|
689
|
+
.component-path { font-size: 0.875rem; color: #6b7280; font-family: monospace; }
|
|
690
|
+
.component-roles { margin-top: 0.5rem; }
|
|
691
|
+
.role-tag { display: inline-block; padding: 0.125rem 0.5rem; background: #e5e7eb; border-radius: 0.25rem; font-size: 0.75rem; margin-right: 0.25rem; }
|
|
692
|
+
.badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; font-weight: 600; }
|
|
693
|
+
.badge.new { background: #dbeafe; color: #1d4ed8; }
|
|
694
|
+
.risk-reason { padding: 0.25rem 0; color: #b45309; }
|
|
695
|
+
.override-notice { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 0.75rem 1rem; margin: 0.5rem 0; border-radius: 0.25rem; }
|
|
696
|
+
.override-notice strong { color: #92400e; }
|
|
697
|
+
ul { list-style: disc; margin-left: 1.5rem; }
|
|
698
|
+
ul.file-list { list-style: none; margin-left: 0; }
|
|
699
|
+
ul.file-list li { padding: 0.125rem 0; }
|
|
700
|
+
ul.file-list code { font-size: 0.85rem; background: #f3f4f6; padding: 0.125rem 0.375rem; border-radius: 0.25rem; }
|
|
701
|
+
li { padding: 0.25rem 0; }
|
|
702
|
+
.path-group { margin-bottom: 1rem; }
|
|
703
|
+
.truncation-note { font-size: 0.875rem; color: #6b7280; font-style: italic; margin-top: 0.5rem; }
|
|
704
|
+
.meta { font-size: 0.75rem; color: #9ca3af; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #e5e7eb; }
|
|
705
|
+
.empty { color: #9ca3af; font-style: italic; }
|
|
706
|
+
.checklist { background: #f0fdf4; border: 1px solid #86efac; padding: 1rem; border-radius: 0.5rem; }
|
|
707
|
+
.checklist li { color: #166534; }
|
|
708
|
+
</style>
|
|
709
|
+
</head>
|
|
710
|
+
<body>
|
|
711
|
+
<main class="container">
|
|
712
|
+
<h1>PR Impact Analysis</h1>
|
|
713
|
+
|
|
714
|
+
<section aria-labelledby="executive-summary-heading">
|
|
715
|
+
<h2 id="executive-summary-heading">Executive Summary</h2>
|
|
716
|
+
<p>This PR touches <strong>${summary.totalFilesChanged} file${summary.totalFilesChanged !== 1 ? 's' : ''}</strong>
|
|
717
|
+
across <strong>${summary.changedComponents} component${summary.changedComponents !== 1 ? 's' : ''}</strong>
|
|
718
|
+
with a <strong>Risk Level: ${riskNarrative.level.toUpperCase()}</strong> (score: ${riskNarrative.score}).</p>
|
|
719
|
+
${summary.blastRadiusSize > 0 ? `
|
|
720
|
+
<p>The blast radius includes <strong>${summary.blastRadiusSize} additional component${summary.blastRadiusSize !== 1 ? 's' : ''}</strong>
|
|
721
|
+
that may be affected${summary.blastRadiusTruncated ? ` (${summary.blastRadiusOmitted} more truncated at depth ${summary.blastRadiusDepth})` : ''}.</p>
|
|
722
|
+
` : ''}
|
|
723
|
+
${summary.unmodeledCount > 0 ? `
|
|
724
|
+
<p><strong>${summary.unmodeledCount} file${summary.unmodeledCount !== 1 ? 's' : ''}</strong> changed outside modeled components.</p>
|
|
725
|
+
` : ''}
|
|
726
|
+
</section>
|
|
727
|
+
|
|
728
|
+
<div class="summary" role="region" aria-label="Key metrics">
|
|
729
|
+
<div class="summary-card">
|
|
730
|
+
<div class="label">Changed Components</div>
|
|
731
|
+
<div class="value">${summary.changedComponents}</div>
|
|
732
|
+
</div>
|
|
733
|
+
<div class="summary-card">
|
|
734
|
+
<div class="label">Blast Radius</div>
|
|
735
|
+
<div class="value">${summary.blastRadiusSize}${summary.blastRadiusTruncated ? '+' : ''}</div>
|
|
736
|
+
</div>
|
|
737
|
+
<div class="summary-card">
|
|
738
|
+
<div class="label">Risk Level</div>
|
|
739
|
+
<div class="value"><span class="risk-badge" style="background: ${riskColor}">${riskNarrative.level.toUpperCase()}</span></div>
|
|
740
|
+
</div>
|
|
741
|
+
<div class="summary-card">
|
|
742
|
+
<div class="label">Risk Score</div>
|
|
743
|
+
<div class="value">${riskNarrative.score}</div>
|
|
744
|
+
</div>
|
|
745
|
+
</div>
|
|
746
|
+
|
|
747
|
+
${riskNarrative.override?.applied ? `
|
|
748
|
+
<div class="override-notice" role="alert">
|
|
749
|
+
<strong>Risk Override Applied:</strong> ${escapeHtml(riskNarrative.override.reason)}
|
|
750
|
+
</div>
|
|
751
|
+
` : ''}
|
|
752
|
+
|
|
753
|
+
<section aria-labelledby="change-story-heading">
|
|
754
|
+
<h2 id="change-story-heading">Change Story</h2>
|
|
755
|
+
${pathGroups.changed.count > 0 ? buildPathListHtml(pathGroups.changed.items, `Modified Files (${pathGroups.changed.count})`) + (pathGroups.changed.truncated ? `<p class="truncation-note">+ ${pathGroups.changed.count - pathGroups.changed.items.length} more modified files</p>` : '') : ''}
|
|
756
|
+
${pathGroups.renamed.count > 0 ? buildRenamedListHtml(pathGroups.renamed.items) + (pathGroups.renamed.truncated ? `<p class="truncation-note">+ ${pathGroups.renamed.count - pathGroups.renamed.items.length} more renamed files</p>` : '') : ''}
|
|
757
|
+
${pathGroups.added.count > 0 ? buildPathListHtml(pathGroups.added.items, `Added Files (${pathGroups.added.count})`) + (pathGroups.added.truncated ? `<p class="truncation-note">+ ${pathGroups.added.count - pathGroups.added.items.length} more added files</p>` : '') : ''}
|
|
758
|
+
${pathGroups.deleted.count > 0 ? buildPathListHtml(pathGroups.deleted.items, `Deleted Files (${pathGroups.deleted.count})`) + (pathGroups.deleted.truncated ? `<p class="truncation-note">+ ${pathGroups.deleted.count - pathGroups.deleted.items.length} more deleted files</p>` : '') : ''}
|
|
759
|
+
${pathGroups.unmodeled.count > 0 ? buildPathListHtml(pathGroups.unmodeled.items, `Unmodeled Changes (${pathGroups.unmodeled.count})`) + (pathGroups.unmodeled.truncated ? `<p class="truncation-note">+ ${pathGroups.unmodeled.count - pathGroups.unmodeled.items.length} more unmodeled files</p>` : '') : ''}
|
|
760
|
+
${summary.totalFilesChanged === 0 ? '<p class="empty">No file changes detected</p>' : ''}
|
|
761
|
+
</section>
|
|
762
|
+
|
|
763
|
+
${sortedComponents.length > 0 ? `
|
|
764
|
+
<section aria-labelledby="components-heading">
|
|
765
|
+
<h2 id="components-heading">Changed Components</h2>
|
|
766
|
+
<ul style="list-style: none; margin-left: 0;">
|
|
767
|
+
${changedComponentsHtml}
|
|
768
|
+
</ul>
|
|
769
|
+
</section>
|
|
770
|
+
` : ''}
|
|
771
|
+
|
|
772
|
+
${riskNarrative.reasons.length > 0 ? `
|
|
773
|
+
<section aria-labelledby="risk-heading">
|
|
774
|
+
<h2 id="risk-heading">Risk Reasoning</h2>
|
|
775
|
+
<h3>Why this PR is flagged:</h3>
|
|
776
|
+
<ul>
|
|
777
|
+
${riskNarrative.reasons.map(r => `<li class="risk-reason">${escapeHtml(r)}</li>`).join('')}
|
|
778
|
+
</ul>
|
|
779
|
+
</section>
|
|
780
|
+
` : ''}
|
|
781
|
+
|
|
782
|
+
${summary.blastRadiusSize > 0 ? `
|
|
783
|
+
<section aria-labelledby="blast-radius-heading">
|
|
784
|
+
<h2 id="blast-radius-heading">Blast Radius</h2>
|
|
785
|
+
<p>Components that may be affected by these changes${summary.blastRadiusTruncated ? ` (truncated at depth ${summary.blastRadiusDepth}, ${summary.blastRadiusOmitted} omitted)` : ''}:</p>
|
|
786
|
+
<ul>
|
|
787
|
+
${blastRadiusHtml}
|
|
788
|
+
</ul>
|
|
789
|
+
${summary.blastRadiusTruncated ? `<p class="truncation-note">Output truncated: ${summary.blastRadiusOmitted} additional components not shown (depth limit: ${summary.blastRadiusDepth})</p>` : ''}
|
|
790
|
+
</section>
|
|
791
|
+
` : ''}
|
|
792
|
+
|
|
793
|
+
<section aria-labelledby="actions-heading">
|
|
794
|
+
<h2 id="actions-heading">Action Checklist</h2>
|
|
795
|
+
<div class="checklist">
|
|
796
|
+
<ul>
|
|
797
|
+
${actionChecklistHtml}
|
|
798
|
+
</ul>
|
|
799
|
+
</div>
|
|
800
|
+
</section>
|
|
801
|
+
|
|
802
|
+
<footer class="meta">
|
|
803
|
+
<p>Generated: ${escapeHtml(summary.generatedAt)}</p>
|
|
804
|
+
<p>Base: <code>${escapeHtml(summary.base)}</code> | Head: <code>${escapeHtml(summary.head)}</code></p>
|
|
805
|
+
<p>Analysis duration: ${summary.durationMs}ms</p>
|
|
806
|
+
</footer>
|
|
807
|
+
</main>
|
|
808
|
+
</body>
|
|
809
|
+
</html>`;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Write PR impact artifacts to disk
|
|
814
|
+
* @param {string} outputDir - Output directory path
|
|
815
|
+
* @param {object} result - PR impact result
|
|
816
|
+
* @param {boolean} skipHtml - Skip HTML generation
|
|
817
|
+
*/
|
|
818
|
+
function writePrImpactArtifacts(outputDir, result, skipHtml = false) {
|
|
819
|
+
// Create output directory if needed
|
|
820
|
+
if (!fs.existsSync(outputDir)) {
|
|
821
|
+
fs.mkdirSync(outputDir, { recursive: true, mode: 0o755 });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Write JSON
|
|
825
|
+
const jsonPath = path.join(outputDir, 'pr-impact.json');
|
|
826
|
+
fs.writeFileSync(jsonPath, JSON.stringify(result, null, 2) + '\n');
|
|
827
|
+
|
|
828
|
+
// Write HTML (unless --json flag)
|
|
829
|
+
if (!skipHtml) {
|
|
830
|
+
const htmlPath = path.join(outputDir, 'pr-impact.html');
|
|
831
|
+
const htmlContent = generateHtmlExplainer(result);
|
|
832
|
+
fs.writeFileSync(htmlPath, htmlContent);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return { jsonPath, htmlPath: skipHtml ? null : path.join(outputDir, 'pr-impact.html') };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
module.exports = {
|
|
839
|
+
computeDelta,
|
|
840
|
+
computeBlastRadiusFromDelta,
|
|
841
|
+
computeRiskFromDelta,
|
|
842
|
+
escapeHtml,
|
|
843
|
+
groupChangePaths,
|
|
844
|
+
buildRiskNarrative,
|
|
845
|
+
buildSummaryMeta,
|
|
846
|
+
generateHtmlExplainer,
|
|
847
|
+
writePrImpactArtifacts,
|
|
848
|
+
};
|