diffninja 0.2.0 → 0.3.1

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,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
+ `;
@@ -11,6 +11,7 @@
11
11
  * allowed by their SHA-256 hashes, so the report HTML is served byte for byte.
12
12
  */
13
13
  import type { ReviewReport, SuggestedComment } from "./types.js";
14
+ import { type ExplanationCounts, type ExplanationInput } from "./explanation.js";
14
15
  /** Most reports one connection keeps; the oldest page closes first. */
15
16
  export declare const MAX_REPORT_PAGES = 20;
16
17
  /** Most comments one review may carry from the agent: a reviewer's handful, not a lint dump. */
@@ -47,6 +48,10 @@ export interface RecordedComments {
47
48
  readonly reviewId: string;
48
49
  readonly suggested: number;
49
50
  }
51
+ export interface RecordedExplanation {
52
+ readonly reviewId: string;
53
+ readonly explained: ExplanationCounts;
54
+ }
50
55
  /**
51
56
  * Everything the reviewing agent owes a review before its pages are handed out.
52
57
  * `summary` is the agent's own plain-English paragraph on what the pull request
@@ -58,6 +63,8 @@ export interface FinishInput {
58
63
  readonly order: readonly string[];
59
64
  readonly comments: readonly SuggestedComment[];
60
65
  readonly summary?: string;
66
+ /** The business explanation: every listed function's purpose, the processes, and the rules. */
67
+ readonly explanation?: ExplanationInput;
61
68
  }
62
69
  export interface FinishedReview {
63
70
  readonly reviewId: string;
@@ -66,6 +73,8 @@ export interface FinishedReview {
66
73
  readonly suggested: number;
67
74
  /** Characters of the goal summary kept, or 0 when none was sent. */
68
75
  readonly summarized: number;
76
+ /** What the business explanation holds, or absent when none was sent. */
77
+ explained?: ExplanationCounts;
69
78
  readonly reportUrl: string;
70
79
  }
71
80
  export declare class ReportPages {
@@ -118,6 +127,13 @@ export declare class ReportPages {
118
127
  * call and keeps the previous set; an empty list clears it.
119
128
  */
120
129
  suggestComments(reviewId: string, comments: readonly SuggestedComment[], suggestedBy: string): RecordedComments;
130
+ /**
131
+ * Replace the business explanation of a review: a purpose for every function
132
+ * the review lists, the processes the change touches, and its business rules.
133
+ * The whole explanation is checked first; any problem refuses the call and
134
+ * keeps the previous one.
135
+ */
136
+ recordExplanation(reviewId: string, explanation: ExplanationInput, explainedBy: string): RecordedExplanation;
121
137
  private review;
122
138
  private rerender;
123
139
  private origin;
@@ -13,6 +13,7 @@
13
13
  import { createHash, randomBytes } from "node:crypto";
14
14
  import { createServer } from "node:http";
15
15
  import { z } from "zod";
16
+ import { checkExplanation, explanationCounts, normalizeExplanation } from "./explanation.js";
16
17
  /** Most reports one connection keeps; the oldest page closes first. */
17
18
  export const MAX_REPORT_PAGES = 20;
18
19
  /** Most comments one review may carry from the agent: a reviewer's handful, not a lint dump. */
@@ -237,14 +238,18 @@ export class ReportPages {
237
238
  checkOrder(report, input.order);
238
239
  checkComments(report, input.comments);
239
240
  const summary = checkSummary(input.summary);
241
+ if (input.explanation !== undefined)
242
+ checkExplanation(report, input.explanation);
240
243
  applyAnswers(report, input.answers, by);
241
244
  applyOrder(report, input.order, by);
242
245
  applyComments(report, input.comments, by);
243
246
  if (summary !== undefined)
244
247
  report.agentSummary = { text: summary, summarizedBy: by };
248
+ if (input.explanation !== undefined)
249
+ report.agentExplanation = normalizeExplanation(input.explanation, by);
245
250
  page.finished = true;
246
251
  this.rerender(page, report);
247
- return {
252
+ const finished = {
248
253
  reviewId,
249
254
  answered: input.answers.length,
250
255
  ordered: input.order.length,
@@ -252,6 +257,9 @@ export class ReportPages {
252
257
  summarized: summary?.length ?? 0,
253
258
  reportUrl: `${this.origin}/report/${token}`,
254
259
  };
260
+ if (input.explanation !== undefined)
261
+ finished.explained = explanationCounts(input.explanation);
262
+ return finished;
255
263
  }
256
264
  /** Whether finish_review accepted this review, so its addresses may be handed out again. */
257
265
  isFinished(reviewId) {
@@ -298,6 +306,19 @@ export class ReportPages {
298
306
  this.rerender(page, report);
299
307
  return { reviewId, suggested: comments.length };
300
308
  }
309
+ /**
310
+ * Replace the business explanation of a review: a purpose for every function
311
+ * the review lists, the processes the change touches, and its business rules.
312
+ * The whole explanation is checked first; any problem refuses the call and
313
+ * keeps the previous one.
314
+ */
315
+ recordExplanation(reviewId, explanation, explainedBy) {
316
+ const { page, report } = this.review(reviewId);
317
+ checkExplanation(report, explanation);
318
+ report.agentExplanation = normalizeExplanation(explanation, explainedBy);
319
+ this.rerender(page, report);
320
+ return { reviewId, explained: explanationCounts(explanation) };
321
+ }
301
322
  review(reviewId) {
302
323
  const token = this.tokens.get(reviewId);
303
324
  const page = token === undefined ? undefined : this.pages.get(token);
@@ -12,6 +12,7 @@ import { checkReferences } from "./reference-check.js";
12
12
  import { moduleResolver } from "./module-resolution.js";
13
13
  import { reviewQuestions } from "./questions.js";
14
14
  import { readProjectContext } from "./history.js";
15
+ import { functionsOf } from "./explanation.js";
15
16
  /** Context width may differ, but source-enriched patches must describe the same changed lines. */
16
17
  function changedLineIdentity(units) {
17
18
  const changes = [];
@@ -191,7 +192,8 @@ export async function reviewDiff(input, options = {}) {
191
192
  pr: options.pr,
192
193
  evidence: { ...evidence, intent: crossCheckIntent(options.pr, units, evidence.agenda, evidence.findings) },
193
194
  ...result, callFlow, callFlows, callFlowAvailability, warnings: [...warnings, ...result.warnings],
194
- questions: reviewQuestions(result.items, options.pr, project) };
195
+ questions: reviewQuestions(result.items, options.pr, project),
196
+ functions: functionsOf({ items: result.items, callFlows }) };
195
197
  if (project !== undefined)
196
198
  report.project = project;
197
199
  return report;