@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.
Files changed (91) hide show
  1. package/.diagram/contracts/machine-command-coverage.json +73 -0
  2. package/.diagram/migration/finalization-policy.json +20 -0
  3. package/LICENSE +202 -21
  4. package/README.md +132 -339
  5. package/package.json +46 -13
  6. package/scripts/refresh-diagram-context.sh +274 -182
  7. package/src/analyzers/default-analyzer.js +11 -0
  8. package/src/analyzers/index.js +34 -0
  9. package/src/artifacts/agent-context.js +105 -0
  10. package/src/artifacts/artifact-budget.js +224 -0
  11. package/src/artifacts/brief.js +153 -0
  12. package/src/artifacts/evidence-manifest.js +206 -0
  13. package/src/artifacts/evidence-summary.js +29 -0
  14. package/src/commands/analyze.js +125 -0
  15. package/src/commands/changed.js +185 -0
  16. package/src/commands/context.js +110 -0
  17. package/src/commands/diff.js +142 -0
  18. package/src/commands/doctor.js +335 -0
  19. package/src/commands/explain.js +273 -0
  20. package/src/commands/generate-all.js +170 -0
  21. package/src/commands/generate-animated.js +50 -0
  22. package/src/commands/generate-video.js +65 -0
  23. package/src/commands/generate.js +522 -0
  24. package/src/commands/init.js +123 -0
  25. package/src/commands/output.js +76 -0
  26. package/src/commands/scan.js +624 -0
  27. package/src/commands/shared.js +396 -0
  28. package/src/commands/validate.js +328 -0
  29. package/src/commands/video-shared.js +105 -0
  30. package/src/commands/workflow-pr.js +26 -0
  31. package/src/confidence/pipeline.js +186 -0
  32. package/src/config/diagramrc.js +79 -0
  33. package/src/context/build-context-pack.js +291 -0
  34. package/src/context/normalize-diagram-manifest.js +282 -0
  35. package/src/core/analysis-generation-analyze-components.js +102 -0
  36. package/src/core/analysis-generation-analyze-dependencies.js +33 -0
  37. package/src/core/analysis-generation-analyze-files.js +48 -0
  38. package/src/core/analysis-generation-analyze-options.js +73 -0
  39. package/src/core/analysis-generation-analyze.js +63 -0
  40. package/src/core/analysis-generation-constants.js +53 -0
  41. package/src/core/analysis-generation-diagrams-core-architecture.js +105 -0
  42. package/src/core/analysis-generation-diagrams-core-dependency.js +68 -0
  43. package/src/core/analysis-generation-diagrams-core-sequence.js +142 -0
  44. package/src/core/analysis-generation-diagrams-core-shapes.js +104 -0
  45. package/src/core/analysis-generation-diagrams-core.js +12 -0
  46. package/src/core/analysis-generation-diagrams-empty.js +68 -0
  47. package/src/core/analysis-generation-diagrams-erd.js +59 -0
  48. package/src/core/analysis-generation-diagrams-limit.js +27 -0
  49. package/src/core/analysis-generation-diagrams-role-ai-agent.js +103 -0
  50. package/src/core/analysis-generation-diagrams-role-ai-context.js +186 -0
  51. package/src/core/analysis-generation-diagrams-role-ai.js +11 -0
  52. package/src/core/analysis-generation-diagrams-role-data.js +182 -0
  53. package/src/core/analysis-generation-diagrams-role-helpers.js +129 -0
  54. package/src/core/analysis-generation-diagrams-role-security.js +129 -0
  55. package/src/core/analysis-generation-diagrams-role.js +25 -0
  56. package/src/core/analysis-generation-diagrams.js +182 -0
  57. package/src/core/analysis-generation-role-tags-constants.js +55 -0
  58. package/src/core/analysis-generation-role-tags-imports.js +32 -0
  59. package/src/core/analysis-generation-role-tags-infer.js +49 -0
  60. package/src/core/analysis-generation-role-tags-match.js +19 -0
  61. package/src/core/analysis-generation-role-tags.js +7 -0
  62. package/src/core/analysis-generation-utils-core.js +308 -0
  63. package/src/core/analysis-generation-utils-graph.js +321 -0
  64. package/src/core/analysis-generation-utils-resolution.js +76 -0
  65. package/src/core/analysis-generation-utils.js +9 -0
  66. package/src/core/analysis-generation.js +44 -0
  67. package/src/diagram.js +178 -1761
  68. package/src/formatters/console.js +198 -0
  69. package/src/formatters/index.js +41 -0
  70. package/src/formatters/json.js +113 -0
  71. package/src/formatters/junit.js +123 -0
  72. package/src/graph.js +159 -0
  73. package/src/incremental/cache.js +210 -0
  74. package/src/ir/architecture-ir.js +48 -0
  75. package/src/migration/evidence.js +262 -0
  76. package/src/migration/finalization-policy.js +35 -0
  77. package/src/renderers/report-html.js +265 -0
  78. package/src/rules/factory.js +108 -0
  79. package/src/rules/types/base.js +54 -0
  80. package/src/rules/types/import-rule.js +286 -0
  81. package/src/rules.js +380 -0
  82. package/src/schema/erd-confidence.js +56 -0
  83. package/src/schema/erd-extractor.js +504 -0
  84. package/src/schema/erd-model.js +176 -0
  85. package/src/schema/rules-schema.js +170 -0
  86. package/src/utils/suggestions.js +67 -0
  87. package/src/video.js +4 -5
  88. package/src/workflow/git-helpers.js +576 -0
  89. package/src/workflow/pr-command.js +694 -0
  90. package/src/workflow/pr-impact.js +848 -0
  91. package/src/workflow/sort-utils.js +16 -0
@@ -0,0 +1,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, '&amp;')
479
+ .replace(/</g, '&lt;')
480
+ .replace(/>/g, '&gt;')
481
+ .replace(/"/g, '&quot;')
482
+ .replace(/'/g, '&#039;');
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
+ };