diffninja 0.1.1 → 0.3.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.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The business view of a review: the reviewing agent's explanation drawn for
3
+ * a person who does not know this part of the product.
4
+ *
5
+ * Each process becomes a flowchart: steps in the order they happen, decisions
6
+ * with their labelled exits, and the steps this change adds, alters, or takes
7
+ * away highlighted, like a diff laid over the process instead of over the code.
8
+ * Under each chart the same steps are a numbered list carrying what the chart
9
+ * has no room for: the rule behind a step, how a changed step worked before,
10
+ * the functions that carry it out (their purpose first, their name second), and
11
+ * links to the hunks that change it. The business rules follow as before and
12
+ * after, and a glossary gives every listed function's purpose.
13
+ *
14
+ * Everything is server-rendered SVG and HTML with no script, so it reads the
15
+ * same without JavaScript and inside the pull request page's drawer. Every
16
+ * string is the agent's text or a path from the report, and all of it is
17
+ * HTML-escaped; the layout is a pure function of the explanation, so the same
18
+ * explanation always draws the same chart.
19
+ */
20
+ import { type BusinessProcess, type ProcessStep } from "./explanation.js";
21
+ import type { ReviewReport } from "./types.js";
22
+ export interface BusinessViewOptions {
23
+ /** Link for a hunk id, or undefined when this page has no diff to link to. */
24
+ readonly hunkHref?: (itemId: string) => string | undefined;
25
+ /** Draw only the processes and rules that touch this changed file. */
26
+ readonly file?: string;
27
+ /** Leave out the function glossary (the call-flow view shows the purposes). */
28
+ readonly glossary?: boolean;
29
+ /** Leave out the attribution line when the page around it already says who explained it. */
30
+ readonly attribution?: boolean;
31
+ /** Whether each chart's step-by-step list starts open; a compact embed starts it folded. */
32
+ readonly stepsOpen?: boolean;
33
+ }
34
+ /**
35
+ * The whole business view, or a note saying why there is none. `options.file`
36
+ * scopes it to one changed file: the processes whose steps name a hunk or a
37
+ * function of that file, and the rules that name one of its hunks.
38
+ */
39
+ export declare function renderBusinessView(report: ReviewReport, options?: BusinessViewOptions): string;
40
+ interface StepBox {
41
+ readonly step: ProcessStep;
42
+ readonly index: number;
43
+ readonly lines: string[];
44
+ readonly layer: number;
45
+ x: number;
46
+ y: number;
47
+ readonly width: number;
48
+ readonly height: number;
49
+ }
50
+ interface ChartEdge {
51
+ readonly from: StepBox;
52
+ readonly to: StepBox;
53
+ when?: string;
54
+ /** adjacent: next layer down; lane-right: a skip down the right side; lane-left: a loop back up the left side. */
55
+ readonly route: "adjacent" | "lane-right" | "lane-left";
56
+ /** Lane number within its side, 0 nearest the chart. */
57
+ readonly lane: number;
58
+ /** This exit's place along its step's bottom edge, left to right by where it heads. */
59
+ slot: number;
60
+ slots: number;
61
+ }
62
+ export interface ProcessLayout {
63
+ readonly boxes: StepBox[];
64
+ readonly edges: ChartEdge[];
65
+ /** Top and bottom of each layer's row, so edges can run in the gaps between rows. */
66
+ readonly rows: ReadonlyMap<number, {
67
+ readonly top: number;
68
+ readonly bottom: number;
69
+ }>;
70
+ /** Where the right and left lanes start: just outside the widest row. */
71
+ readonly rightEdge: number;
72
+ readonly leftEdge: number;
73
+ readonly width: number;
74
+ readonly height: number;
75
+ }
76
+ /**
77
+ * Layered top-to-bottom layout. A step sits one layer below the lowest step
78
+ * that leads forward into it (forward means later in the list), so branches of
79
+ * a decision sit side by side and rejoin below, in the order the decision lists
80
+ * them. Every arrow leaves a box through its bottom and enters one through its
81
+ * top, and between them it only runs through the gaps between rows and the
82
+ * lanes beside the chart: an exit to the next row is an elbow in the gap, one
83
+ * that skips rows runs down a lane on the right, and one that goes back to an
84
+ * earlier step (a retry) runs up a lane on the left. No arrow crosses a box.
85
+ */
86
+ export declare function layoutProcess(process: BusinessProcess): ProcessLayout;
87
+ /**
88
+ * Styles for the business view. They use the host page's color tokens with
89
+ * fallbacks, so the report page and the pull request drawer both draw it in
90
+ * their own palette, light and dark.
91
+ */
92
+ export declare const BUSINESS_STYLES = "\n.bp { --bp-add: var(--add-ink, var(--add, #1f7a3e)); --bp-add-bg: var(--add-bg, #e3f7e8);\n --bp-chg: var(--warn, #945f00); --bp-chg-bg: var(--warn-bg, #fcf1d6);\n --bp-del: var(--alarm, #c42032); --bp-del-bg: var(--alarm-bg, #fde8ea);\n --bp-edge: var(--line-strong, #a9b0c8);\n display: flex; flex-direction: column; gap: 22px; margin-top: 14px; font-family: var(--sans); }\n.bp-head { display: flex; flex-direction: column; gap: 8px; }\n.bp-by, .bp-note { font-size: 13.5px; color: var(--ink-soft); max-width: 90ch; }\n.bp-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12.5px; color: var(--ink-soft); }\n.bp-legend-item { display: inline-flex; align-items: center; gap: 6px; }\n.bp-swatch { width: 14px; height: 10px; border-radius: 3px; border: 1.5px solid var(--bp-edge); background: var(--panel, transparent); }\n.bp-swatch-added { border-color: var(--bp-add); background: var(--bp-add-bg); }\n.bp-swatch-changed { border-color: var(--bp-chg); background: var(--bp-chg-bg); }\n.bp-swatch-removed { border-color: var(--bp-del); border-style: dashed; background: var(--bp-del-bg); }\n.bp-process { display: flex; flex-direction: column; gap: 12px; padding: 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel, transparent); }\n.bp-process-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; }\n.bp-process-title { font-size: 18px; font-weight: 650; }\n.bp-process-tally { font-size: 13px; color: var(--ink-soft); }\n.bp-chart-wrap { overflow-x: auto; overscroll-behavior-x: contain; padding: 4px 0; }\n.bp-chart { display: block; margin: 0 auto; max-width: none; font-family: var(--sans); }\n.bp-node { cursor: pointer; }\n.bp-node .bp-shape { fill: var(--panel, #fff); stroke: var(--bp-edge); stroke-width: 1.5; }\n.bp-node:hover .bp-shape, .bp-node:focus .bp-shape { stroke: var(--cursor, var(--accent)); }\n.bp-node:focus { outline: none; }\n.bp-node:focus-visible .bp-shape { stroke-width: 3; }\n.bp-kind-start .bp-shape, .bp-kind-end .bp-shape { fill: var(--sunken, #f6f8fa); }\n.bp-node.bp-added .bp-shape { fill: var(--bp-add-bg); stroke: var(--bp-add); stroke-width: 2.2; }\n.bp-node.bp-changed .bp-shape { fill: var(--bp-chg-bg); stroke: var(--bp-chg); stroke-width: 2.2; }\n.bp-node.bp-removed .bp-shape { fill: var(--bp-del-bg); stroke: var(--bp-del); stroke-width: 2; stroke-dasharray: 6 4; }\n.bp-text { fill: var(--ink); font-size: 13.5px; font-weight: 550; }\n.bp-node.bp-removed .bp-text { text-decoration: line-through; fill: var(--ink-soft); }\n.bp-tag { fill: var(--ink-soft); font-size: 10px; font-weight: 700; letter-spacing: 0.06em; }\n.bp-node.bp-added .bp-tag { fill: var(--bp-add); }\n.bp-node.bp-changed .bp-tag { fill: var(--bp-chg); }\n.bp-node.bp-removed .bp-tag { fill: var(--bp-del); }\n.bp-edge { fill: none; stroke: var(--bp-edge); stroke-width: 1.6; }\n.bp-edge-changed { stroke: var(--ink-soft); }\n.bp-edge-back { stroke-dasharray: 5 4; }\n.bp-arrow { fill: var(--ink-soft); }\n.bp-when-bg { fill: var(--bg, #fff); stroke: var(--line); }\n.bp-when { fill: var(--ink); font-size: 11px; font-weight: 650; }\n.bp-steps-sum { cursor: pointer; font-size: 13.5px; font-weight: 600; color: var(--ink-soft); width: fit-content; }\n.bp-steps-box[open] > .bp-steps-sum { margin-bottom: 10px; }\n.bp-steps { margin: 0; padding-left: 28px; display: flex; flex-direction: column; gap: 10px; }\n.bp-step { padding: 2px 0 2px 4px; scroll-margin-top: 80px; }\n.bp-step::marker { color: var(--ink-soft); font-weight: 650; }\n.bp-step:target { background: var(--sunken); border-radius: 6px; }\n.bp-step-line, .bp-rule-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; font-size: 14.5px; font-weight: 550; }\n.bp-step.bp-removed .bp-step-text, .bp-rule.bp-removed .bp-rule-text { text-decoration: line-through; color: var(--ink-soft); }\n.bp-detail, .bp-next { margin-top: 3px; font-size: 13.5px; color: var(--ink-soft); max-width: 90ch; }\n.bp-before { margin-top: 3px; font-size: 13.5px; color: var(--ink-soft); display: flex; gap: 8px; align-items: baseline; }\n.bp-label { flex: none; font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-soft); margin-right: 8px; }\n.bp-chip { flex: none; font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line); color: var(--ink-soft); }\n.bp-chip-added { color: var(--bp-add); border-color: var(--bp-add); background: var(--bp-add-bg); }\n.bp-chip-changed { color: var(--bp-chg); border-color: var(--bp-chg); background: var(--bp-chg-bg); }\n.bp-chip-removed { color: var(--bp-del); border-color: var(--bp-del); background: var(--bp-del-bg); }\n.bp-done-by { margin-top: 6px; display: flex; gap: 8px; align-items: baseline; }\n.bp-fns, .bp-gloss { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }\n.bp-fn, .bp-gloss-item { display: flex; flex-direction: column; gap: 1px; }\n.bp-fn-purpose { font-size: 13.5px; color: var(--ink); }\n.bp-fn-name { font-size: 11.5px; color: var(--ink-soft); overflow-wrap: anywhere; }\n.bp-hunks { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; font-size: 12px; }\n.bp-hunk { padding: 1px 7px; border: 1px solid var(--line); border-radius: 6px; color: var(--ink-soft); text-decoration: none; overflow-wrap: anywhere; }\na.bp-hunk:hover { border-color: var(--cursor, var(--accent)); color: var(--ink); }\n.bp-rules { display: flex; flex-direction: column; gap: 10px; }\n.bp-section-title { font-size: 15px; font-weight: 650; }\n.bp-rule-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 10px; }\n.bp-rule { padding: 10px 12px; border: 1px solid var(--line); border-left-width: 3px; border-radius: 8px; }\n.bp-rule.bp-added { border-left-color: var(--bp-add); }\n.bp-rule.bp-changed { border-left-color: var(--bp-chg); }\n.bp-rule.bp-removed { border-left-color: var(--bp-del); }\n.bp-glossary > summary { cursor: pointer; }\n.bp-glossary[open] > summary { margin-bottom: 10px; }\n.bp-gloss-head { margin: 14px 0 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-soft); }\n@media (max-width: 640px) {\n .bp-process { padding: 12px; }\n .bp-steps { padding-left: 22px; }\n .bp-done-by, .bp-before { flex-direction: column; gap: 2px; }\n}\n";
93
+ export {};
@@ -0,0 +1,525 @@
1
+ /**
2
+ * The business view of a review: the reviewing agent's explanation drawn for
3
+ * a person who does not know this part of the product.
4
+ *
5
+ * Each process becomes a flowchart: steps in the order they happen, decisions
6
+ * with their labelled exits, and the steps this change adds, alters, or takes
7
+ * away highlighted, like a diff laid over the process instead of over the code.
8
+ * Under each chart the same steps are a numbered list carrying what the chart
9
+ * has no room for: the rule behind a step, how a changed step worked before,
10
+ * the functions that carry it out (their purpose first, their name second), and
11
+ * links to the hunks that change it. The business rules follow as before and
12
+ * after, and a glossary gives every listed function's purpose.
13
+ *
14
+ * Everything is server-rendered SVG and HTML with no script, so it reads the
15
+ * same without JavaScript and inside the pull request page's drawer. Every
16
+ * string is the agent's text or a path from the report, and all of it is
17
+ * HTML-escaped; the layout is a pure function of the explanation, so the same
18
+ * explanation always draws the same chart.
19
+ */
20
+ import { escapeHtml } from "./escape-html.js";
21
+ import { exitsOf, purposesOf, wrapWords, } from "./explanation.js";
22
+ const CHANGE_WORD = {
23
+ added: "New",
24
+ changed: "Changed",
25
+ removed: "Removed",
26
+ unchanged: "",
27
+ };
28
+ const CHANGE_SENTENCE = {
29
+ added: "added by this change",
30
+ changed: "changed by this change",
31
+ removed: "removed by this change",
32
+ unchanged: "unchanged",
33
+ };
34
+ /** Chart geometry, in SVG user units (pixels at 100%). */
35
+ const NODE_WIDTH = 264;
36
+ const WRAP_CHARS = 30;
37
+ /** A decision's slanted sides leave less room for its question. */
38
+ const DECISION_WRAP_CHARS = 25;
39
+ const LINE_HEIGHT = 18;
40
+ const TAG_ROW = 16;
41
+ const NODE_PAD_Y = 11;
42
+ const LAYER_GAP = 60;
43
+ /** Heights inside the gap between two rows: where an elbow turns, where lane edges leave and arrive. */
44
+ const GAP_TURN = 26;
45
+ const GAP_DEPART = 34;
46
+ const GAP_ARRIVE = 12;
47
+ const COLUMN_GAP = 36;
48
+ const LANE_GAP = 18;
49
+ const CHART_PAD = 14;
50
+ const DECISION_INSET = 16;
51
+ /**
52
+ * The whole business view, or a note saying why there is none. `options.file`
53
+ * scopes it to one changed file: the processes whose steps name a hunk or a
54
+ * function of that file, and the rules that name one of its hunks.
55
+ */
56
+ export function renderBusinessView(report, options = {}) {
57
+ const explanation = report.agentExplanation;
58
+ if (explanation === undefined) {
59
+ return `<p class="bp-note bp-absent">No business explanation yet. The reviewing agent writes one with finish_review: what each function does, the processes this change touches, and its business rules. diffninja never writes it itself.</p>`;
60
+ }
61
+ const functions = new Map((report.functions ?? []).map((fn) => [fn.id, fn]));
62
+ const purposes = purposesOf(report);
63
+ const ranks = new Map(report.items.map((item, index) => [item.id, index + 1]));
64
+ const itemFiles = new Map(report.items.map((item) => [item.id, item.file]));
65
+ const touches = (hunks, fns) => options.file === undefined ||
66
+ (hunks ?? []).some((id) => itemFiles.get(id) === options.file) ||
67
+ (fns ?? []).some((id) => functions.get(id)?.file === options.file);
68
+ const processes = explanation.processes.filter((process) => process.steps.some((step) => touches(step.hunks, step.functions)));
69
+ const rules = explanation.rules.filter((rule) => touches(rule.hunks, undefined));
70
+ const context = { functions, purposes, ranks, itemFiles, hunkHref: options.hunkHref, stepsOpen: options.stepsOpen !== false };
71
+ const parts = [
72
+ '<div class="bp">',
73
+ '<div class="bp-head">',
74
+ options.attribution === false
75
+ ? ""
76
+ : `<p class="bp-by">Explained by ${escapeHtml(explanation.explainedBy)} from the code: its reading of what the change does, not a verdict.</p>`,
77
+ renderLegend(),
78
+ "</div>",
79
+ ].filter((part) => part !== "");
80
+ if (processes.length === 0 && rules.length === 0) {
81
+ parts.push(`<p class="bp-note">No process or rule in the explanation names this file's hunks or functions.</p>`);
82
+ }
83
+ processes.forEach((process, index) => parts.push(renderProcess(process, index + 1, context)));
84
+ if (rules.length > 0)
85
+ parts.push(renderRules(rules, context));
86
+ if (options.glossary !== false && options.file === undefined)
87
+ parts.push(renderGlossary(report.functions ?? [], purposes));
88
+ parts.push("</div>");
89
+ return parts.join("\n");
90
+ }
91
+ function renderLegend() {
92
+ const chip = (change, word) => `<span class="bp-legend-item"><span class="bp-swatch bp-swatch-${change}" aria-hidden="true"></span>${word}</span>`;
93
+ return [
94
+ '<p class="bp-legend" aria-label="Legend">',
95
+ chip("added", "New in this change"),
96
+ chip("changed", "Changed"),
97
+ chip("removed", "Removed"),
98
+ chip("unchanged", "Unchanged, for context"),
99
+ "</p>",
100
+ ].join("");
101
+ }
102
+ function plural(count, word) {
103
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
104
+ }
105
+ function processTally(process) {
106
+ const counts = { added: 0, changed: 0, removed: 0 };
107
+ for (const step of process.steps)
108
+ if (step.change !== "unchanged")
109
+ counts[step.change] += 1;
110
+ const parts = [
111
+ counts.added > 0 ? `${counts.added} new` : "",
112
+ counts.changed > 0 ? `${counts.changed} changed` : "",
113
+ counts.removed > 0 ? `${counts.removed} removed` : "",
114
+ ].filter((part) => part !== "");
115
+ return `${plural(process.steps.length, "step")}${parts.length > 0 ? ` · ${parts.join(", ")}` : " · none changed"}`;
116
+ }
117
+ function renderProcess(process, at, context) {
118
+ const layout = layoutProcess(process);
119
+ return [
120
+ `<section class="bp-process" id="bp-process-${at}" aria-labelledby="bp-process-${at}-title">`,
121
+ '<header class="bp-process-head">',
122
+ `<h3 class="bp-process-title" id="bp-process-${at}-title">${escapeHtml(process.title)}</h3>`,
123
+ `<p class="bp-process-tally">${escapeHtml(processTally(process))}</p>`,
124
+ "</header>",
125
+ '<div class="bp-chart-wrap">',
126
+ renderChart(process, layout, at),
127
+ "</div>",
128
+ renderStepList(process, at, context),
129
+ "</section>",
130
+ ].join("\n");
131
+ }
132
+ /**
133
+ * Layered top-to-bottom layout. A step sits one layer below the lowest step
134
+ * that leads forward into it (forward means later in the list), so branches of
135
+ * a decision sit side by side and rejoin below, in the order the decision lists
136
+ * them. Every arrow leaves a box through its bottom and enters one through its
137
+ * top, and between them it only runs through the gaps between rows and the
138
+ * lanes beside the chart: an exit to the next row is an elbow in the gap, one
139
+ * that skips rows runs down a lane on the right, and one that goes back to an
140
+ * earlier step (a retry) runs up a lane on the left. No arrow crosses a box.
141
+ */
142
+ export function layoutProcess(process) {
143
+ const steps = process.steps;
144
+ const index = new Map(steps.map((step, at) => [step.id, at]));
145
+ const exits = exitsOf(process);
146
+ const layers = steps.map(() => -1);
147
+ steps.forEach((step, at) => {
148
+ if (layers[at] < 0)
149
+ layers[at] = at === 0 ? 0 : layers[at - 1] + 1;
150
+ for (const exit of exits.get(step.id) ?? []) {
151
+ const target = index.get(exit.to);
152
+ if (target > at)
153
+ layers[target] = Math.max(layers[target], layers[at] + 1);
154
+ }
155
+ });
156
+ const boxes = steps.map((step, at) => {
157
+ const lines = wrapWords(step.text, step.kind === "decision" ? DECISION_WRAP_CHARS : WRAP_CHARS);
158
+ return {
159
+ step,
160
+ index: at,
161
+ lines,
162
+ layer: layers[at],
163
+ x: 0,
164
+ y: 0,
165
+ width: NODE_WIDTH,
166
+ height: NODE_PAD_Y * 2 + TAG_ROW + lines.length * LINE_HEIGHT,
167
+ };
168
+ });
169
+ const byLayer = new Map();
170
+ for (const box of boxes) {
171
+ const row = byLayer.get(box.layer) ?? [];
172
+ row.push(box);
173
+ byLayer.set(box.layer, row);
174
+ }
175
+ const layerIds = [...byLayer.keys()].sort((a, b) => a - b);
176
+ // Within a row, a step follows the step above that leads into it, in that
177
+ // step's own exit order, so a decision's first exit sits on the left.
178
+ const place = new Map();
179
+ for (const layer of layerIds) {
180
+ const row = byLayer.get(layer);
181
+ const key = (box) => {
182
+ let best = Number.POSITIVE_INFINITY;
183
+ for (const parent of boxes) {
184
+ if (parent.layer !== layer - 1)
185
+ continue;
186
+ (exits.get(parent.step.id) ?? []).forEach((exit, at) => {
187
+ if (exit.to === box.step.id)
188
+ best = Math.min(best, (place.get(parent) ?? 0) * 100 + at);
189
+ });
190
+ }
191
+ return best;
192
+ };
193
+ const keys = new Map(row.map((box) => [box, key(box)]));
194
+ row.sort((a, b) => keys.get(a) - keys.get(b) || a.index - b.index);
195
+ row.forEach((box, at) => place.set(box, at));
196
+ }
197
+ const edges = [];
198
+ let rightLanes = 0;
199
+ let leftLanes = 0;
200
+ for (const box of boxes) {
201
+ for (const exit of exits.get(box.step.id) ?? []) {
202
+ const target = boxes[index.get(exit.to)];
203
+ const route = target.index <= box.index || target.layer <= box.layer
204
+ ? "lane-left"
205
+ : target.layer === box.layer + 1 ? "adjacent" : "lane-right";
206
+ const lane = route === "lane-right" ? rightLanes++ : route === "lane-left" ? leftLanes++ : 0;
207
+ const edge = { from: box, to: target, route, lane, slot: 0, slots: 1 };
208
+ if (exit.when !== undefined)
209
+ edge.when = exit.when;
210
+ edges.push(edge);
211
+ }
212
+ }
213
+ const rowWidth = (row) => row.length * NODE_WIDTH + (row.length - 1) * COLUMN_GAP;
214
+ const core = Math.max(...layerIds.map((layer) => rowWidth(byLayer.get(layer))));
215
+ const leftSpace = leftLanes === 0 ? 0 : leftLanes * LANE_GAP + 14;
216
+ const rightSpace = rightLanes === 0 ? 0 : rightLanes * LANE_GAP + 14;
217
+ const rows = new Map();
218
+ let top = CHART_PAD;
219
+ for (const layer of layerIds) {
220
+ const row = byLayer.get(layer);
221
+ const height = Math.max(...row.map((box) => box.height));
222
+ let x = CHART_PAD + leftSpace + (core - rowWidth(row)) / 2;
223
+ for (const box of row) {
224
+ box.x = Math.round(x);
225
+ box.y = Math.round(top + (height - box.height) / 2);
226
+ x += NODE_WIDTH + COLUMN_GAP;
227
+ }
228
+ rows.set(layer, { top: Math.round(top), bottom: Math.round(top + height) });
229
+ top += height + LAYER_GAP;
230
+ }
231
+ // Exits leave a box's bottom left to right in the direction each one heads,
232
+ // so no two of them cross on the way out.
233
+ const heading = (edge) => edge.route === "lane-left" ? -1e6 - edge.lane : edge.route === "lane-right" ? 1e6 + edge.lane : edge.to.x;
234
+ for (const box of boxes) {
235
+ const out = edges.filter((edge) => edge.from === box).sort((a, b) => heading(a) - heading(b));
236
+ out.forEach((edge, at) => {
237
+ edge.slot = at;
238
+ edge.slots = out.length;
239
+ });
240
+ }
241
+ return {
242
+ boxes,
243
+ edges,
244
+ rows,
245
+ leftEdge: CHART_PAD + leftSpace,
246
+ rightEdge: CHART_PAD + leftSpace + core,
247
+ width: Math.round(CHART_PAD * 2 + leftSpace + core + rightSpace),
248
+ height: Math.round(top - LAYER_GAP + CHART_PAD),
249
+ };
250
+ }
251
+ function stepOutline(box) {
252
+ const { x, y, width, height } = box;
253
+ if (box.step.kind === "decision") {
254
+ const inset = DECISION_INSET;
255
+ const mid = y + height / 2;
256
+ return `<path class="bp-shape" d="M${x + inset} ${y} H${x + width - inset} L${x + width} ${mid} L${x + width - inset} ${y + height} H${x + inset} L${x} ${mid} Z"></path>`;
257
+ }
258
+ const radius = box.step.kind === "start" || box.step.kind === "end" ? Math.min(height / 2, 22) : 8;
259
+ return `<rect class="bp-shape" x="${x}" y="${y}" width="${width}" height="${height}" rx="${radius}"></rect>`;
260
+ }
261
+ function kindWord(step) {
262
+ if (step.kind === "start")
263
+ return "Starts";
264
+ if (step.kind === "decision")
265
+ return "Decision";
266
+ if (step.kind === "end")
267
+ return "Outcome";
268
+ return "Step";
269
+ }
270
+ function renderNode(box, at) {
271
+ const step = box.step;
272
+ const number = box.index + 1;
273
+ const tag = CHANGE_WORD[step.change];
274
+ const center = box.x + box.width / 2;
275
+ const head = `${number} · ${kindWord(step)}${tag === "" ? "" : ` · ${tag}`}`;
276
+ const lines = box.lines
277
+ .map((line, index) => `<text class="bp-text" x="${center}" y="${box.y + NODE_PAD_Y + TAG_ROW + 13 + index * LINE_HEIGHT}" text-anchor="middle">${escapeHtml(line)}</text>`)
278
+ .join("");
279
+ const title = [
280
+ `${number}. ${step.text}`,
281
+ CHANGE_SENTENCE[step.change],
282
+ step.detail ?? "",
283
+ step.before === undefined ? "" : `Before: ${step.before}`,
284
+ ].filter((part) => part !== "").join(" · ");
285
+ return [
286
+ `<a class="bp-node bp-kind-${step.kind} bp-${step.change}" href="#bp-p${at}-s${number}">`,
287
+ `<title>${escapeHtml(title)}</title>`,
288
+ stepOutline(box),
289
+ `<text class="bp-tag" x="${center}" y="${box.y + NODE_PAD_Y + 10}" text-anchor="middle">${escapeHtml(head.toUpperCase())}</text>`,
290
+ lines,
291
+ "</a>",
292
+ ].join("");
293
+ }
294
+ function edgeLabel(x, y, text, anchor) {
295
+ const width = Math.round(text.length * 6.6 + 10);
296
+ const left = anchor === "start" ? x - 4 : anchor === "end" ? x - width + 4 : x - width / 2;
297
+ return [
298
+ `<rect class="bp-when-bg" x="${Math.round(left)}" y="${y - 11}" width="${width}" height="16" rx="8"></rect>`,
299
+ `<text class="bp-when" x="${Math.round(left + width / 2)}" y="${y + 1}" text-anchor="middle">${escapeHtml(text)}</text>`,
300
+ ].join("");
301
+ }
302
+ function renderEdge(edge, layout, marker) {
303
+ const { from, to } = edge;
304
+ const classes = `bp-edge${to.step.change === "unchanged" && from.step.change === "unchanged" ? "" : " bp-edge-changed"}`;
305
+ const end = ` marker-end="url(#${marker})"`;
306
+ const spread = edge.slots <= 1 ? 0 : (edge.slot - (edge.slots - 1) / 2) * Math.min(70, (from.width - 60) / (edge.slots - 1));
307
+ const x1 = Math.round(from.x + from.width / 2 + spread);
308
+ const y1 = from.y + from.height;
309
+ const below = layout.rows.get(from.layer).bottom;
310
+ const above = layout.rows.get(to.layer).top;
311
+ const y2 = to.y - 2;
312
+ const label = edge.when === undefined ? "" : edgeLabel(x1 + 5, below + 15, edge.when, "start");
313
+ if (edge.route === "adjacent") {
314
+ const x2 = Math.round(to.x + to.width / 2);
315
+ const mid = below + GAP_TURN;
316
+ const path = x1 === x2 ? `M${x1} ${y1} V${y2}` : `M${x1} ${y1} V${mid} H${x2} V${y2}`;
317
+ return `<path class="${classes}" d="${path}"${end}></path>${label}`;
318
+ }
319
+ // A lane edge crosses the gap under its source, runs down (or back up) its
320
+ // lane, and crosses the gap over its target, each at its own height so two
321
+ // lane edges in one gap stay apart.
322
+ const depart = below + GAP_DEPART + (edge.lane % 2) * 7;
323
+ const arrive = above - GAP_ARRIVE - (edge.lane % 2) * 7;
324
+ const right = edge.route === "lane-right";
325
+ const lane = right ? layout.rightEdge + 14 + edge.lane * LANE_GAP : layout.leftEdge - 14 - edge.lane * LANE_GAP;
326
+ const x2 = Math.round(to.x + to.width / 2 + (right ? 22 : -22));
327
+ const back = right ? "" : " bp-edge-back";
328
+ return `<path class="${classes}${back}" d="M${x1} ${y1} V${depart} H${lane} V${arrive} H${x2} V${y2}"${end}></path>${label}`;
329
+ }
330
+ function renderChart(process, layout, at) {
331
+ const marker = `bp-arrow-${at}`;
332
+ return [
333
+ `<svg class="bp-chart" width="${layout.width}" height="${layout.height}" viewBox="0 0 ${layout.width} ${layout.height}" role="img" aria-label="${escapeHtml(`Flowchart of ${process.title}; the numbered list below describes each step.`)}">`,
334
+ "<defs>",
335
+ `<marker id="${marker}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="bp-arrow" d="M0 0 L10 5 L0 10 z"></path></marker>`,
336
+ "</defs>",
337
+ layout.edges.map((edge) => renderEdge(edge, layout, marker)).join(""),
338
+ layout.boxes.map((box) => renderNode(box, at)).join(""),
339
+ "</svg>",
340
+ ].join("");
341
+ }
342
+ /** The functions that carry a step out: purpose first, the code's own name under it. */
343
+ function renderDoneBy(ids, context) {
344
+ const entries = ids.map((id) => {
345
+ const fn = context.functions.get(id);
346
+ const purpose = context.purposes.get(id);
347
+ const name = fn === undefined ? id : `${fn.name} · ${fn.file}:${fn.line}`;
348
+ return [
349
+ '<li class="bp-fn">',
350
+ purpose === undefined ? "" : `<span class="bp-fn-purpose">${escapeHtml(purpose)}</span>`,
351
+ `<span class="bp-fn-name mono">${escapeHtml(name)}</span>`,
352
+ "</li>",
353
+ ].join("");
354
+ });
355
+ return `<div class="bp-done-by"><span class="bp-label">Done by</span><ul class="bp-fns">${entries.join("")}</ul></div>`;
356
+ }
357
+ function renderHunkLinks(hunks, context) {
358
+ const links = hunks.map((id) => {
359
+ const rank = context.ranks.get(id);
360
+ const file = context.itemFiles.get(id) ?? "";
361
+ const label = rank === undefined ? id : `#${rank} ${file}`;
362
+ const href = context.hunkHref?.(id);
363
+ return href === undefined
364
+ ? `<span class="bp-hunk mono">${escapeHtml(label)}</span>`
365
+ : `<a class="bp-hunk mono" data-open-hunk href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
366
+ });
367
+ return `<p class="bp-hunks"><span class="bp-label">In the diff</span>${links.join("")}</p>`;
368
+ }
369
+ function renderStepList(process, at, context) {
370
+ const index = new Map(process.steps.map((step, position) => [step.id, position + 1]));
371
+ const exits = exitsOf(process);
372
+ const items = process.steps.map((step, position) => {
373
+ const number = position + 1;
374
+ const tag = CHANGE_WORD[step.change];
375
+ const out = exits.get(step.id) ?? [];
376
+ const next = step.kind === "decision" || out.length > 1
377
+ ? out.map((exit) => `${exit.when === undefined ? "" : `${exit.when}: `}go to ${index.get(exit.to)}`).join("; ")
378
+ : out.length === 1 && index.get(out[0].to) !== number + 1 ? `Then go to ${index.get(out[0].to)}` : "";
379
+ return [
380
+ `<li class="bp-step bp-${step.change}" id="bp-p${at}-s${number}" value="${number}">`,
381
+ '<p class="bp-step-line">',
382
+ tag === "" ? "" : `<span class="bp-chip bp-chip-${step.change}">${tag}</span>`,
383
+ step.kind === "decision" ? '<span class="bp-chip bp-chip-kind">Decision</span>' : "",
384
+ `<span class="bp-step-text">${escapeHtml(step.text)}</span>`,
385
+ "</p>",
386
+ step.detail === undefined ? "" : `<p class="bp-detail">${escapeHtml(step.detail)}</p>`,
387
+ step.before === undefined ? "" : `<p class="bp-before"><span class="bp-label">Before</span>${escapeHtml(step.before)}</p>`,
388
+ next === "" ? "" : `<p class="bp-next">${escapeHtml(next)}</p>`,
389
+ step.functions === undefined ? "" : renderDoneBy(step.functions, context),
390
+ step.hunks === undefined ? "" : renderHunkLinks(step.hunks, context),
391
+ "</li>",
392
+ ].filter((part) => part !== "").join("");
393
+ });
394
+ return [
395
+ `<details class="bp-steps-box"${context.stepsOpen ? " open" : ""}>`,
396
+ `<summary class="bp-steps-sum">Step by step: the rules, what changed, and the code behind each step</summary>`,
397
+ `<ol class="bp-steps" aria-label="${escapeHtml(`Steps of ${process.title}`)}">${items.join("")}</ol>`,
398
+ "</details>",
399
+ ].join("");
400
+ }
401
+ function renderRules(rules, context) {
402
+ const items = rules.map((rule) => {
403
+ const tag = CHANGE_WORD[rule.change];
404
+ return [
405
+ `<li class="bp-rule bp-${rule.change}">`,
406
+ '<p class="bp-rule-line">',
407
+ tag === "" ? '<span class="bp-chip bp-chip-unchanged">Kept</span>' : `<span class="bp-chip bp-chip-${rule.change}">${tag}</span>`,
408
+ `<span class="bp-rule-text">${escapeHtml(rule.text)}</span>`,
409
+ "</p>",
410
+ rule.before === undefined ? "" : `<p class="bp-before"><span class="bp-label">Before</span>${escapeHtml(rule.before)}</p>`,
411
+ rule.hunks === undefined ? "" : renderHunkLinks(rule.hunks, context),
412
+ "</li>",
413
+ ].filter((part) => part !== "").join("");
414
+ });
415
+ return [
416
+ '<section class="bp-rules" aria-labelledby="bp-rules-title">',
417
+ '<h3 class="bp-section-title" id="bp-rules-title">Business rules</h3>',
418
+ `<ul class="bp-rule-list">${items.join("")}</ul>`,
419
+ "</section>",
420
+ ].join("\n");
421
+ }
422
+ function renderGlossary(functions, purposes) {
423
+ if (functions.length === 0)
424
+ return "";
425
+ const row = (fn) => [
426
+ "<li class=\"bp-gloss-item\">",
427
+ `<span class="bp-fn-purpose">${escapeHtml(purposes.get(fn.id) ?? "No purpose given.")}</span>`,
428
+ `<span class="bp-fn-name mono">${escapeHtml(`${fn.name} · ${fn.file}:${fn.line}`)}</span>`,
429
+ "</li>",
430
+ ].join("");
431
+ const product = functions.filter((fn) => !fn.inTests);
432
+ const tests = functions.filter((fn) => fn.inTests);
433
+ return [
434
+ '<details class="bp-glossary">',
435
+ `<summary class="bp-section-title">What each function does (${functions.length})</summary>`,
436
+ product.length === 0 ? "" : `<ul class="bp-gloss">${product.map(row).join("")}</ul>`,
437
+ tests.length === 0 ? "" : `<p class="bp-gloss-head">In tests</p><ul class="bp-gloss">${tests.map(row).join("")}</ul>`,
438
+ "</details>",
439
+ ].filter((part) => part !== "").join("\n");
440
+ }
441
+ /**
442
+ * Styles for the business view. They use the host page's color tokens with
443
+ * fallbacks, so the report page and the pull request drawer both draw it in
444
+ * their own palette, light and dark.
445
+ */
446
+ export const BUSINESS_STYLES = `
447
+ .bp { --bp-add: var(--add-ink, var(--add, #1f7a3e)); --bp-add-bg: var(--add-bg, #e3f7e8);
448
+ --bp-chg: var(--warn, #945f00); --bp-chg-bg: var(--warn-bg, #fcf1d6);
449
+ --bp-del: var(--alarm, #c42032); --bp-del-bg: var(--alarm-bg, #fde8ea);
450
+ --bp-edge: var(--line-strong, #a9b0c8);
451
+ display: flex; flex-direction: column; gap: 22px; margin-top: 14px; font-family: var(--sans); }
452
+ .bp-head { display: flex; flex-direction: column; gap: 8px; }
453
+ .bp-by, .bp-note { font-size: 13.5px; color: var(--ink-soft); max-width: 90ch; }
454
+ .bp-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12.5px; color: var(--ink-soft); }
455
+ .bp-legend-item { display: inline-flex; align-items: center; gap: 6px; }
456
+ .bp-swatch { width: 14px; height: 10px; border-radius: 3px; border: 1.5px solid var(--bp-edge); background: var(--panel, transparent); }
457
+ .bp-swatch-added { border-color: var(--bp-add); background: var(--bp-add-bg); }
458
+ .bp-swatch-changed { border-color: var(--bp-chg); background: var(--bp-chg-bg); }
459
+ .bp-swatch-removed { border-color: var(--bp-del); border-style: dashed; background: var(--bp-del-bg); }
460
+ .bp-process { display: flex; flex-direction: column; gap: 12px; padding: 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel, transparent); }
461
+ .bp-process-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; }
462
+ .bp-process-title { font-size: 18px; font-weight: 650; }
463
+ .bp-process-tally { font-size: 13px; color: var(--ink-soft); }
464
+ .bp-chart-wrap { overflow-x: auto; overscroll-behavior-x: contain; padding: 4px 0; }
465
+ .bp-chart { display: block; margin: 0 auto; max-width: none; font-family: var(--sans); }
466
+ .bp-node { cursor: pointer; }
467
+ .bp-node .bp-shape { fill: var(--panel, #fff); stroke: var(--bp-edge); stroke-width: 1.5; }
468
+ .bp-node:hover .bp-shape, .bp-node:focus .bp-shape { stroke: var(--cursor, var(--accent)); }
469
+ .bp-node:focus { outline: none; }
470
+ .bp-node:focus-visible .bp-shape { stroke-width: 3; }
471
+ .bp-kind-start .bp-shape, .bp-kind-end .bp-shape { fill: var(--sunken, #f6f8fa); }
472
+ .bp-node.bp-added .bp-shape { fill: var(--bp-add-bg); stroke: var(--bp-add); stroke-width: 2.2; }
473
+ .bp-node.bp-changed .bp-shape { fill: var(--bp-chg-bg); stroke: var(--bp-chg); stroke-width: 2.2; }
474
+ .bp-node.bp-removed .bp-shape { fill: var(--bp-del-bg); stroke: var(--bp-del); stroke-width: 2; stroke-dasharray: 6 4; }
475
+ .bp-text { fill: var(--ink); font-size: 13.5px; font-weight: 550; }
476
+ .bp-node.bp-removed .bp-text { text-decoration: line-through; fill: var(--ink-soft); }
477
+ .bp-tag { fill: var(--ink-soft); font-size: 10px; font-weight: 700; letter-spacing: 0.06em; }
478
+ .bp-node.bp-added .bp-tag { fill: var(--bp-add); }
479
+ .bp-node.bp-changed .bp-tag { fill: var(--bp-chg); }
480
+ .bp-node.bp-removed .bp-tag { fill: var(--bp-del); }
481
+ .bp-edge { fill: none; stroke: var(--bp-edge); stroke-width: 1.6; }
482
+ .bp-edge-changed { stroke: var(--ink-soft); }
483
+ .bp-edge-back { stroke-dasharray: 5 4; }
484
+ .bp-arrow { fill: var(--ink-soft); }
485
+ .bp-when-bg { fill: var(--bg, #fff); stroke: var(--line); }
486
+ .bp-when { fill: var(--ink); font-size: 11px; font-weight: 650; }
487
+ .bp-steps-sum { cursor: pointer; font-size: 13.5px; font-weight: 600; color: var(--ink-soft); width: fit-content; }
488
+ .bp-steps-box[open] > .bp-steps-sum { margin-bottom: 10px; }
489
+ .bp-steps { margin: 0; padding-left: 28px; display: flex; flex-direction: column; gap: 10px; }
490
+ .bp-step { padding: 2px 0 2px 4px; scroll-margin-top: 80px; }
491
+ .bp-step::marker { color: var(--ink-soft); font-weight: 650; }
492
+ .bp-step:target { background: var(--sunken); border-radius: 6px; }
493
+ .bp-step-line, .bp-rule-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; font-size: 14.5px; font-weight: 550; }
494
+ .bp-step.bp-removed .bp-step-text, .bp-rule.bp-removed .bp-rule-text { text-decoration: line-through; color: var(--ink-soft); }
495
+ .bp-detail, .bp-next { margin-top: 3px; font-size: 13.5px; color: var(--ink-soft); max-width: 90ch; }
496
+ .bp-before { margin-top: 3px; font-size: 13.5px; color: var(--ink-soft); display: flex; gap: 8px; align-items: baseline; }
497
+ .bp-label { flex: none; font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-soft); margin-right: 8px; }
498
+ .bp-chip { flex: none; font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line); color: var(--ink-soft); }
499
+ .bp-chip-added { color: var(--bp-add); border-color: var(--bp-add); background: var(--bp-add-bg); }
500
+ .bp-chip-changed { color: var(--bp-chg); border-color: var(--bp-chg); background: var(--bp-chg-bg); }
501
+ .bp-chip-removed { color: var(--bp-del); border-color: var(--bp-del); background: var(--bp-del-bg); }
502
+ .bp-done-by { margin-top: 6px; display: flex; gap: 8px; align-items: baseline; }
503
+ .bp-fns, .bp-gloss { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
504
+ .bp-fn, .bp-gloss-item { display: flex; flex-direction: column; gap: 1px; }
505
+ .bp-fn-purpose { font-size: 13.5px; color: var(--ink); }
506
+ .bp-fn-name { font-size: 11.5px; color: var(--ink-soft); overflow-wrap: anywhere; }
507
+ .bp-hunks { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; font-size: 12px; }
508
+ .bp-hunk { padding: 1px 7px; border: 1px solid var(--line); border-radius: 6px; color: var(--ink-soft); text-decoration: none; overflow-wrap: anywhere; }
509
+ a.bp-hunk:hover { border-color: var(--cursor, var(--accent)); color: var(--ink); }
510
+ .bp-rules { display: flex; flex-direction: column; gap: 10px; }
511
+ .bp-section-title { font-size: 15px; font-weight: 650; }
512
+ .bp-rule-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 10px; }
513
+ .bp-rule { padding: 10px 12px; border: 1px solid var(--line); border-left-width: 3px; border-radius: 8px; }
514
+ .bp-rule.bp-added { border-left-color: var(--bp-add); }
515
+ .bp-rule.bp-changed { border-left-color: var(--bp-chg); }
516
+ .bp-rule.bp-removed { border-left-color: var(--bp-del); }
517
+ .bp-glossary > summary { cursor: pointer; }
518
+ .bp-glossary[open] > summary { margin-bottom: 10px; }
519
+ .bp-gloss-head { margin: 14px 0 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-soft); }
520
+ @media (max-width: 640px) {
521
+ .bp-process { padding: 12px; }
522
+ .bp-steps { padding-left: 22px; }
523
+ .bp-done-by, .bp-before { flex-direction: column; gap: 2px; }
524
+ }
525
+ `;