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.
- package/README.md +14 -3
- package/dist/review/call-flow-html.d.ts +1 -1
- package/dist/review/call-flow-html.js +115 -21
- package/dist/review/connected-analysis.d.ts +19 -0
- package/dist/review/connected-analysis.js +23 -0
- package/dist/review/connected-html.js +79 -4
- package/dist/review/connected.d.ts +5 -3
- package/dist/review/connected.js +2 -1
- package/dist/review/explanation.d.ts +143 -0
- package/dist/review/explanation.js +310 -0
- package/dist/review/html.d.ts +7 -0
- package/dist/review/html.js +98 -4
- package/dist/review/mcp.js +77 -11
- package/dist/review/process-html.d.ts +93 -0
- package/dist/review/process-html.js +525 -0
- package/dist/review/report-pages.d.ts +16 -0
- package/dist/review/report-pages.js +22 -1
- package/dist/review/service.js +3 -1
- package/dist/review/setup.d.ts +24 -6
- package/dist/review/setup.js +98 -30
- package/dist/review/types.d.ts +15 -0
- package/dist/review/version.d.ts +14 -0
- package/dist/review/version.js +44 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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);
|
package/dist/review/service.js
CHANGED
|
@@ -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;
|