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
|
@@ -41,6 +41,16 @@ export function renderConnectedPage(csrf) {
|
|
|
41
41
|
'<p id="pr-goal-text" class="pr-goal-text"></p>',
|
|
42
42
|
'<p id="pr-goal-by" class="pr-goal-by" hidden></p>',
|
|
43
43
|
"</section>",
|
|
44
|
+
'<section id="pr-how" class="pr-how" aria-labelledby="pr-how-heading" hidden>',
|
|
45
|
+
'<details class="pr-how-fold" open>',
|
|
46
|
+
'<summary class="pr-how-head"><h2 id="pr-how-heading" class="pr-goal-heading">How it works</h2></summary>',
|
|
47
|
+
'<div class="pr-how-tools">',
|
|
48
|
+
'<p id="pr-how-by" class="pr-goal-by"></p>',
|
|
49
|
+
'<button type="button" id="pr-how-open" class="btn btn-quiet btn-sm" data-action="open-how" aria-label="Open how it works beside the diff">Open beside the diff</button>',
|
|
50
|
+
"</div>",
|
|
51
|
+
'<iframe id="pr-how-frame" class="pr-how-frame" title="How it works: the business processes and rules this change touches"></iframe>',
|
|
52
|
+
"</details>",
|
|
53
|
+
"</section>",
|
|
44
54
|
'<details id="pr-description" class="pr-description" hidden>',
|
|
45
55
|
'<summary>Original PR description</summary>',
|
|
46
56
|
'<p class="note">The author’s own words, as written, including any generated notes. Raw HTML is shown as text, images as links, and nothing here is loaded from another host. It is the author’s claim, not evidence that the changes achieve it.</p>',
|
|
@@ -1120,6 +1130,26 @@ function script(csrf) {
|
|
|
1120
1130
|
setText(el.prGoalText, goalAbsentReason(snap));
|
|
1121
1131
|
}
|
|
1122
1132
|
|
|
1133
|
+
/**
|
|
1134
|
+
* The agent's business view under the goal: the processes and rules this
|
|
1135
|
+
* change touches, drawn by the server into a same-origin frame that reports
|
|
1136
|
+
* its own height. Shown only once the agent's explanation was accepted for the
|
|
1137
|
+
* revision on screen; the frame reloads only when that revision changes.
|
|
1138
|
+
*/
|
|
1139
|
+
function renderHow() {
|
|
1140
|
+
var snap = snapshot();
|
|
1141
|
+
var current = currentAnalysis();
|
|
1142
|
+
var explanation = current && current.explanation && typeof current.explanation === 'object' ? current.explanation : null;
|
|
1143
|
+
var ready = Boolean(snap) && explanation !== null && current.snapshotId === snap.id;
|
|
1144
|
+
show(el.prHow, ready);
|
|
1145
|
+
if (!ready) { if (el.prHowFrame.getAttribute('src')) el.prHowFrame.removeAttribute('src'); return; }
|
|
1146
|
+
var by = typeof explanation.explainedBy === 'string' && explanation.explainedBy.trim() !== '' ? explanation.explainedBy.trim() : 'your review agent';
|
|
1147
|
+
var processes = Array.isArray(explanation.processes) ? explanation.processes.length : 0;
|
|
1148
|
+
setText(el.prHowBy, 'Explained by ' + by + ' from the code: ' + processes + (processes === 1 ? ' process' : ' processes') + ' and ' + (explanation.rules || 0) + ((explanation.rules || 0) === 1 ? ' business rule' : ' business rules') + '. Its reading, not a verdict; the diff below is the ground truth.');
|
|
1149
|
+
var src = '/flow?snapshot=' + encodeURIComponent(current.snapshotId) + '&view=business';
|
|
1150
|
+
if (el.prHowFrame.getAttribute('src') !== src) el.prHowFrame.setAttribute('src', src);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1123
1153
|
/** Why no goal is on screen: the analysis is still coming, is of another revision, or recorded none. */
|
|
1124
1154
|
function goalAbsentReason(snap) {
|
|
1125
1155
|
if (!analysis || analysisFor !== snap.id) return 'Waiting for the local analysis of this revision\u2026';
|
|
@@ -1134,6 +1164,7 @@ function script(csrf) {
|
|
|
1134
1164
|
/* ------------------------------------------------------------ analysis -- */
|
|
1135
1165
|
|
|
1136
1166
|
var STATUS_ORDER = ['attention', 'uncertain', 'low', 'passed'];
|
|
1167
|
+
var CHANGE_WORD = { added: 'New', changed: 'Changed', removed: 'Removed' };
|
|
1137
1168
|
|
|
1138
1169
|
function currentAnalysis() {
|
|
1139
1170
|
var snap = snapshot();
|
|
@@ -1267,7 +1298,8 @@ function script(csrf) {
|
|
|
1267
1298
|
var verdicts = answeredVerdicts(hunk);
|
|
1268
1299
|
var facts = Array.isArray(hunk.facts) ? hunk.facts : [];
|
|
1269
1300
|
var note = typeof hunk.note === 'string' ? hunk.note : '';
|
|
1270
|
-
|
|
1301
|
+
var business = Array.isArray(hunk.business) ? hunk.business : [];
|
|
1302
|
+
if (rank === 0 && verdicts.length === 0 && facts.length === 0 && note === '' && business.length === 0) return null;
|
|
1271
1303
|
var why = make('div', 'stop-why' + (rank > 0 ? ' has-rank' : ''));
|
|
1272
1304
|
if (rank > 0) {
|
|
1273
1305
|
var badge = make('span', 'why-rank', String(rank));
|
|
@@ -1275,6 +1307,19 @@ function script(csrf) {
|
|
|
1275
1307
|
why.appendChild(badge);
|
|
1276
1308
|
}
|
|
1277
1309
|
var main = make('div', 'why-main');
|
|
1310
|
+
if (business.length > 0) {
|
|
1311
|
+
var steps = make('div', 'tag-row business-row');
|
|
1312
|
+
steps.appendChild(make('span', 'tag-lead', 'In the business'));
|
|
1313
|
+
for (var b = 0; b < business.length; b += 1) {
|
|
1314
|
+
var link = business[b];
|
|
1315
|
+
var change = typeof link.change === 'string' ? link.change : 'unchanged';
|
|
1316
|
+
var step = make('span', 'business-step change-' + change);
|
|
1317
|
+
if (change !== 'unchanged') step.appendChild(make('span', 'business-change', CHANGE_WORD[change] || change));
|
|
1318
|
+
step.appendChild(make('span', 'business-text', (typeof link.process === 'string' ? link.process + ': ' : 'Rule: ') + String(link.text)));
|
|
1319
|
+
steps.appendChild(step);
|
|
1320
|
+
}
|
|
1321
|
+
main.appendChild(steps);
|
|
1322
|
+
}
|
|
1278
1323
|
if (verdicts.length > 0) {
|
|
1279
1324
|
var said = make('div', 'tag-row');
|
|
1280
1325
|
said.appendChild(make('span', 'sr-only', 'Your agent says: '));
|
|
@@ -1337,6 +1382,7 @@ function script(csrf) {
|
|
|
1337
1382
|
var snap = snapshot();
|
|
1338
1383
|
// The goal rides on the analysis, so it re-renders wherever the analysis does — every poll included.
|
|
1339
1384
|
renderGoal();
|
|
1385
|
+
renderHow();
|
|
1340
1386
|
show(el.analysisSection, Boolean(snap) && !(typeof snap.unavailableReason === 'string' && snap.unavailableReason !== ''));
|
|
1341
1387
|
el.analysisBody.textContent = '';
|
|
1342
1388
|
el.analysisActions.textContent = '';
|
|
@@ -1726,8 +1772,9 @@ function script(csrf) {
|
|
|
1726
1772
|
var src = '/flow?snapshot=' + encodeURIComponent(current.snapshotId) + (path === '' ? '' : '&file=' + encodeURIComponent(path));
|
|
1727
1773
|
if (el.flowFrame.getAttribute('src') !== src) el.flowFrame.setAttribute('src', src);
|
|
1728
1774
|
flowSnapshot = current.snapshotId;
|
|
1729
|
-
|
|
1730
|
-
el.
|
|
1775
|
+
var explained = Boolean(current.explanation);
|
|
1776
|
+
setText(el.flowTitle, path === '' ? (explained ? 'How it works and call flows' : 'Call flows') : 'Call flow: ' + path);
|
|
1777
|
+
el.flowFrame.title = path === '' ? (explained ? 'How it works, and the call flows of every changed file' : 'Call flows of every changed file') : 'Call flow of ' + path;
|
|
1731
1778
|
if (el.flowDrawer.hidden) flowReturn = opener || document.activeElement;
|
|
1732
1779
|
show(el.flowDrawer, true);
|
|
1733
1780
|
document.body.classList.add('flow-open');
|
|
@@ -1751,7 +1798,16 @@ function script(csrf) {
|
|
|
1751
1798
|
|
|
1752
1799
|
/** The diagram in the drawer opened or closed: the drawer takes the whole window while it is open. */
|
|
1753
1800
|
function onFlowMessage(event_) {
|
|
1754
|
-
if (event_.origin !== window.location.origin
|
|
1801
|
+
if (event_.origin !== window.location.origin) return;
|
|
1802
|
+
// The inline business view reports its height; nothing else it sends is read.
|
|
1803
|
+
if (event_.source === el.prHowFrame.contentWindow) {
|
|
1804
|
+
var sized = event_.data;
|
|
1805
|
+
if (sized && sized.type === 'diffninja-business-height' && typeof sized.height === 'number' && isFinite(sized.height)) {
|
|
1806
|
+
el.prHowFrame.style.height = Math.max(120, Math.min(20000, Math.ceil(sized.height))) + 'px';
|
|
1807
|
+
}
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
if (event_.source !== el.flowFrame.contentWindow) return;
|
|
1755
1811
|
var data = event_.data;
|
|
1756
1812
|
if (!data || data.type !== 'diffninja-diagram') return;
|
|
1757
1813
|
document.body.classList.toggle('flow-full', data.open === true);
|
|
@@ -2499,6 +2555,7 @@ function script(csrf) {
|
|
|
2499
2555
|
placeAnchor = action === 'preview' || action === 'submit' || action === 'refresh' ? node : null;
|
|
2500
2556
|
if (action === 'comment') { event_.preventDefault(); addComment(node); return; }
|
|
2501
2557
|
if (action === 'open-flow') { event_.preventDefault(); openFlow(node.getAttribute('data-path') || '', node); return; }
|
|
2558
|
+
if (action === 'open-how') { event_.preventDefault(); openFlow('', node); return; }
|
|
2502
2559
|
if (action === 'close-flow') { event_.preventDefault(); closeFlow(); return; }
|
|
2503
2560
|
if (action === 'add-suggestion') { event_.preventDefault(); addSuggestion(node); return; }
|
|
2504
2561
|
if (action === 'add-all-suggestions') { event_.preventDefault(); addAllSuggestions(); return; }
|
|
@@ -2556,6 +2613,9 @@ function script(csrf) {
|
|
|
2556
2613
|
el.prGoal = byId('pr-goal');
|
|
2557
2614
|
el.prGoalText = byId('pr-goal-text');
|
|
2558
2615
|
el.prGoalBy = byId('pr-goal-by');
|
|
2616
|
+
el.prHow = byId('pr-how');
|
|
2617
|
+
el.prHowBy = byId('pr-how-by');
|
|
2618
|
+
el.prHowFrame = byId('pr-how-frame');
|
|
2559
2619
|
el.prDescription = byId('pr-description');
|
|
2560
2620
|
el.prDescriptionBody = byId('pr-description-body');
|
|
2561
2621
|
el.lede = byId('lede');
|
|
@@ -2682,6 +2742,21 @@ ${BRAND_MARK_STYLES}
|
|
|
2682
2742
|
.pr-goal-text { margin: 6px 0 0; font-size: 15.5px; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; max-width: 90ch; }
|
|
2683
2743
|
.pr-goal-note { margin: 6px 0 0; font-size: 13px; color: var(--ink-soft); overflow-wrap: anywhere; max-width: 90ch; }
|
|
2684
2744
|
.pr-goal-by { margin: 6px 0 0; font-size: 12.5px; color: var(--ink-faint); overflow-wrap: anywhere; max-width: 90ch; }
|
|
2745
|
+
.pr-how { margin-top: 10px; padding: 14px 16px 10px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: var(--radius); background: var(--panel); }
|
|
2746
|
+
.pr-how-head { cursor: pointer; width: fit-content; }
|
|
2747
|
+
.pr-how-head h2 { display: inline; }
|
|
2748
|
+
.pr-how-tools { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
2749
|
+
.pr-how-tools .pr-goal-by { flex: 1 1 320px; }
|
|
2750
|
+
.pr-how-frame { display: block; width: 100%; height: 480px; margin-top: 10px; border: 0; background: transparent; color-scheme: normal; }
|
|
2751
|
+
.business-row { align-items: flex-start; }
|
|
2752
|
+
.business-step { display: inline-flex; align-items: baseline; gap: 6px; max-width: 100%; padding: 2px 8px; border: 1px solid var(--line); border-radius: 6px; font-size: 12.5px; color: var(--ink); background: var(--panel); overflow-wrap: anywhere; }
|
|
2753
|
+
.business-step.change-added { border-color: var(--ok); background: var(--ok-soft); }
|
|
2754
|
+
.business-step.change-changed { border-color: var(--warn); background: var(--warn-soft); }
|
|
2755
|
+
.business-step.change-removed { border-color: var(--alarm); background: var(--alarm-bg); }
|
|
2756
|
+
.business-change { flex: none; font-size: 10px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--ink-soft); }
|
|
2757
|
+
.change-added .business-change { color: var(--ok); }
|
|
2758
|
+
.change-changed .business-change { color: var(--warn); }
|
|
2759
|
+
.change-removed .business-change { color: var(--alarm); }
|
|
2685
2760
|
.pr-description { margin-top: 6px; padding: 12px 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
|
|
2686
2761
|
.pr-description > summary { cursor: pointer; font-size: 14px; font-weight: 600; }
|
|
2687
2762
|
.pr-description > .note { margin-top: 10px; }
|
|
@@ -26,10 +26,12 @@ export interface ConnectedOptions {
|
|
|
26
26
|
readonly analysis?: () => Promise<ConnectedAnalysisView>;
|
|
27
27
|
/**
|
|
28
28
|
* The call-flow page of the analysis of snapshot `snapshotId`, for one changed
|
|
29
|
-
* file or all of them, for `GET /flow
|
|
30
|
-
*
|
|
29
|
+
* file or all of them, for `GET /flow`; with `view` "business", the business
|
|
30
|
+
* view alone (`GET /flow?snapshot=&view=business`), which the page shows under
|
|
31
|
+
* its goal. Undefined when that snapshot is not the one analyzed or it has
|
|
32
|
+
* nothing to show there.
|
|
31
33
|
*/
|
|
32
|
-
readonly flow?: (snapshotId: string, file: string | undefined) => Promise<string | undefined>;
|
|
34
|
+
readonly flow?: (snapshotId: string, file: string | undefined, view: "flow" | "business") => Promise<string | undefined>;
|
|
33
35
|
}
|
|
34
36
|
/** A single ephemeral session; never exposes a general GitHub API proxy. */
|
|
35
37
|
export declare function serveConnected(review?: ConnectedReview, options?: ConnectedOptions): Promise<ConnectedSession>;
|
package/dist/review/connected.js
CHANGED
|
@@ -99,9 +99,10 @@ export async function serveConnected(review = new ConnectedReview(), options = {
|
|
|
99
99
|
const query = new URL(req.url ?? "", origin).searchParams;
|
|
100
100
|
const snapshotId = query.get("snapshot") ?? "";
|
|
101
101
|
const file = query.get("file") ?? undefined;
|
|
102
|
+
const view = query.get("view") === "business" ? "business" : "flow";
|
|
102
103
|
let html;
|
|
103
104
|
try {
|
|
104
|
-
html = options.flow === undefined ? undefined : await options.flow(snapshotId, file);
|
|
105
|
+
html = options.flow === undefined ? undefined : await options.flow(snapshotId, file, view);
|
|
105
106
|
}
|
|
106
107
|
catch {
|
|
107
108
|
html = undefined;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reviewing agent's business explanation of a change: what each function
|
|
3
|
+
* does for the product in plain words, the business processes the change
|
|
4
|
+
* touches drawn as steps and decisions, and the business rules it adds, changes,
|
|
5
|
+
* or removes.
|
|
6
|
+
*
|
|
7
|
+
* diffninja never writes any of this. It lists the functions a reader meets in
|
|
8
|
+
* the call flows and around each hunk (`functionsOf`), and the host agent's
|
|
9
|
+
* model, which read the code, explains them. Everything here is mechanical:
|
|
10
|
+
* the list is a pure function of the report, and the checks bound shape and
|
|
11
|
+
* size, require plain one-line prose instead of code names, and make every
|
|
12
|
+
* reference (a function, a hunk, a next step) point at something that exists.
|
|
13
|
+
* Nothing here judges whether the explanation is right; the pages show it
|
|
14
|
+
* attributed to the client that wrote it, as its reading.
|
|
15
|
+
*/
|
|
16
|
+
import type { CallFlowNode, ReviewReport } from "./types.js";
|
|
17
|
+
/** Most functions one review asks the agent to explain; changed code first. */
|
|
18
|
+
export declare const MAX_EXPLAINED_FUNCTIONS = 40;
|
|
19
|
+
/** Most processes one explanation draws: the flows a pull request actually touches. */
|
|
20
|
+
export declare const MAX_PROCESSES = 4;
|
|
21
|
+
/** Most steps one process draws; past this a diagram stops being readable. */
|
|
22
|
+
export declare const MAX_PROCESS_STEPS = 16;
|
|
23
|
+
export declare const MIN_PROCESS_STEPS = 2;
|
|
24
|
+
/** Most business rules one explanation lists. */
|
|
25
|
+
export declare const MAX_RULES = 12;
|
|
26
|
+
/** Most outgoing arrows one step has: a decision's branches, not a switch table. */
|
|
27
|
+
export declare const MAX_STEP_EXITS = 4;
|
|
28
|
+
export declare const MAX_PURPOSE_CHARS = 200;
|
|
29
|
+
export declare const MAX_TITLE_CHARS = 80;
|
|
30
|
+
export declare const MAX_STEP_CHARS = 90;
|
|
31
|
+
export declare const MAX_DETAIL_CHARS = 200;
|
|
32
|
+
export declare const MAX_RULE_CHARS = 200;
|
|
33
|
+
export declare const MAX_BRANCH_CHARS = 24;
|
|
34
|
+
export type ExplanationChange = "unchanged" | "added" | "changed" | "removed";
|
|
35
|
+
export type StepKind = "start" | "action" | "decision" | "end";
|
|
36
|
+
export declare const EXPLANATION_CHANGES: readonly ExplanationChange[];
|
|
37
|
+
export declare const STEP_KINDS: readonly StepKind[];
|
|
38
|
+
/**
|
|
39
|
+
* One function the agent explains. Its id is readable and deterministic,
|
|
40
|
+
* `<defining file>#<name>`, so an agent can name it from the code alone.
|
|
41
|
+
*/
|
|
42
|
+
export interface ExplainedFunction {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
/** The function's own name, as the code spells it. */
|
|
45
|
+
readonly name: string;
|
|
46
|
+
/** The file that defines it. */
|
|
47
|
+
readonly file: string;
|
|
48
|
+
/** 1-based definition line at the revision the report read it from. */
|
|
49
|
+
readonly line: number;
|
|
50
|
+
/** True when the definition lives in a test file (by path convention). */
|
|
51
|
+
readonly inTests: boolean;
|
|
52
|
+
}
|
|
53
|
+
export interface StepExit {
|
|
54
|
+
readonly to: string;
|
|
55
|
+
/** The branch's condition in a word or two, such as "yes", "paid", or "out of stock". */
|
|
56
|
+
readonly when?: string;
|
|
57
|
+
}
|
|
58
|
+
export interface ProcessStep {
|
|
59
|
+
readonly id: string;
|
|
60
|
+
readonly kind: StepKind;
|
|
61
|
+
/** What happens, as a person would say it. */
|
|
62
|
+
readonly text: string;
|
|
63
|
+
readonly change: ExplanationChange;
|
|
64
|
+
/** Why, or the rule this step applies. */
|
|
65
|
+
detail?: string;
|
|
66
|
+
/** For a changed step: how it worked before this change. */
|
|
67
|
+
before?: string;
|
|
68
|
+
/** Function ids from `report.functions` that carry this step out. */
|
|
69
|
+
functions?: readonly string[];
|
|
70
|
+
/** Hunk ids from `report.items` that change this step. */
|
|
71
|
+
hunks?: readonly string[];
|
|
72
|
+
/** Where the process goes next; omitted on an action or start, it continues to the next step listed. */
|
|
73
|
+
next?: readonly StepExit[];
|
|
74
|
+
}
|
|
75
|
+
export interface BusinessProcess {
|
|
76
|
+
readonly title: string;
|
|
77
|
+
readonly steps: readonly ProcessStep[];
|
|
78
|
+
}
|
|
79
|
+
export interface BusinessRule {
|
|
80
|
+
readonly text: string;
|
|
81
|
+
readonly change: ExplanationChange;
|
|
82
|
+
/** For a changed rule: what the rule was before. */
|
|
83
|
+
before?: string;
|
|
84
|
+
hunks?: readonly string[];
|
|
85
|
+
}
|
|
86
|
+
export interface FunctionPurpose {
|
|
87
|
+
readonly id: string;
|
|
88
|
+
readonly purpose: string;
|
|
89
|
+
}
|
|
90
|
+
/** The explanation as the agent sends it. */
|
|
91
|
+
export interface ExplanationInput {
|
|
92
|
+
readonly functions: readonly FunctionPurpose[];
|
|
93
|
+
readonly processes: readonly BusinessProcess[];
|
|
94
|
+
readonly rules: readonly BusinessRule[];
|
|
95
|
+
}
|
|
96
|
+
/** The accepted explanation, attributed to the MCP client that wrote it. */
|
|
97
|
+
export interface AgentExplanation extends ExplanationInput {
|
|
98
|
+
readonly explainedBy: string;
|
|
99
|
+
readonly explainedAt: string;
|
|
100
|
+
}
|
|
101
|
+
/** The explanation's counts, as finish_review and record_explanation report them. */
|
|
102
|
+
export interface ExplanationCounts {
|
|
103
|
+
readonly functions: number;
|
|
104
|
+
readonly processes: number;
|
|
105
|
+
readonly steps: number;
|
|
106
|
+
readonly rules: number;
|
|
107
|
+
}
|
|
108
|
+
export declare function functionId(file: string, name: string): string;
|
|
109
|
+
/** The function id a call-flow node's resolved definition has, or undefined when it resolved none. */
|
|
110
|
+
export declare function nodeFunctionId(node: CallFlowNode): string | undefined;
|
|
111
|
+
/**
|
|
112
|
+
* Every function a reader meets in this report, once, capped at
|
|
113
|
+
* {@link MAX_EXPLAINED_FUNCTIONS}: the definitions around each hunk in report
|
|
114
|
+
* order (the changed code, its callers and callees), then the resolved
|
|
115
|
+
* definitions in the call flows. Calls with no definition in the repository
|
|
116
|
+
* (library and framework calls) are not listed: there is nothing of the
|
|
117
|
+
* project's own to explain. Functions outside test files come first, so a cap
|
|
118
|
+
* cuts test helpers before product code.
|
|
119
|
+
*/
|
|
120
|
+
export declare function functionsOf(report: Pick<ReviewReport, "items" | "callFlows">): ExplainedFunction[];
|
|
121
|
+
/**
|
|
122
|
+
* Check a whole explanation against the review it explains. It must explain
|
|
123
|
+
* every function the review lists, each once; draw one to
|
|
124
|
+
* {@link MAX_PROCESSES} processes whose steps and exits all resolve; and list at
|
|
125
|
+
* most {@link MAX_RULES} rules, a changed one saying what it was before. The
|
|
126
|
+
* first problem refuses the whole explanation.
|
|
127
|
+
*/
|
|
128
|
+
export declare function checkExplanation(report: ReviewReport, input: ExplanationInput): void;
|
|
129
|
+
/** Trim every text field and drop empty optional lists, so the pages render exactly what was checked. */
|
|
130
|
+
export declare function normalizeExplanation(input: ExplanationInput, explainedBy: string): AgentExplanation;
|
|
131
|
+
export declare function explanationCounts(input: ExplanationInput): ExplanationCounts;
|
|
132
|
+
/** The agent's purpose for each function id, empty when there is no explanation. */
|
|
133
|
+
export declare function purposesOf(report: Pick<ReviewReport, "agentExplanation">): Map<string, string>;
|
|
134
|
+
/**
|
|
135
|
+
* Where each step goes, with the implicit exits made explicit: a start or action
|
|
136
|
+
* step with no `next` continues to the step listed after it. A decision always
|
|
137
|
+
* lists its own exits, and an end has none.
|
|
138
|
+
*/
|
|
139
|
+
export declare function exitsOf(process: BusinessProcess): Map<string, readonly StepExit[]>;
|
|
140
|
+
/** Greedy word wrap; a word longer than the line gets a line of its own. */
|
|
141
|
+
export declare function wrapWords(text: string, width: number): string[];
|
|
142
|
+
/** At most `maxLines` wrapped lines; the last one ends in an ellipsis when text was cut. */
|
|
143
|
+
export declare function wrapPurpose(text: string, width: number, maxLines: number): string[];
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reviewing agent's business explanation of a change: what each function
|
|
3
|
+
* does for the product in plain words, the business processes the change
|
|
4
|
+
* touches drawn as steps and decisions, and the business rules it adds, changes,
|
|
5
|
+
* or removes.
|
|
6
|
+
*
|
|
7
|
+
* diffninja never writes any of this. It lists the functions a reader meets in
|
|
8
|
+
* the call flows and around each hunk (`functionsOf`), and the host agent's
|
|
9
|
+
* model, which read the code, explains them. Everything here is mechanical:
|
|
10
|
+
* the list is a pure function of the report, and the checks bound shape and
|
|
11
|
+
* size, require plain one-line prose instead of code names, and make every
|
|
12
|
+
* reference (a function, a hunk, a next step) point at something that exists.
|
|
13
|
+
* Nothing here judges whether the explanation is right; the pages show it
|
|
14
|
+
* attributed to the client that wrote it, as its reading.
|
|
15
|
+
*/
|
|
16
|
+
import { testLikeFile } from "./file-role.js";
|
|
17
|
+
/** Most functions one review asks the agent to explain; changed code first. */
|
|
18
|
+
export const MAX_EXPLAINED_FUNCTIONS = 40;
|
|
19
|
+
/** Most processes one explanation draws: the flows a pull request actually touches. */
|
|
20
|
+
export const MAX_PROCESSES = 4;
|
|
21
|
+
/** Most steps one process draws; past this a diagram stops being readable. */
|
|
22
|
+
export const MAX_PROCESS_STEPS = 16;
|
|
23
|
+
export const MIN_PROCESS_STEPS = 2;
|
|
24
|
+
/** Most business rules one explanation lists. */
|
|
25
|
+
export const MAX_RULES = 12;
|
|
26
|
+
/** Most outgoing arrows one step has: a decision's branches, not a switch table. */
|
|
27
|
+
export const MAX_STEP_EXITS = 4;
|
|
28
|
+
export const MAX_PURPOSE_CHARS = 200;
|
|
29
|
+
export const MAX_TITLE_CHARS = 80;
|
|
30
|
+
export const MAX_STEP_CHARS = 90;
|
|
31
|
+
export const MAX_DETAIL_CHARS = 200;
|
|
32
|
+
export const MAX_RULE_CHARS = 200;
|
|
33
|
+
export const MAX_BRANCH_CHARS = 24;
|
|
34
|
+
export const EXPLANATION_CHANGES = ["unchanged", "added", "changed", "removed"];
|
|
35
|
+
export const STEP_KINDS = ["start", "action", "decision", "end"];
|
|
36
|
+
/** A function's name without its receiver: `self.save` and `Order.save` are the same `save`. */
|
|
37
|
+
function shortName(name) {
|
|
38
|
+
const bare = name.replace(/^(?:before|after):/, "");
|
|
39
|
+
const segments = bare.split(/[.:#]+/).filter((segment) => segment !== "");
|
|
40
|
+
return segments.length === 0 ? bare : segments[segments.length - 1];
|
|
41
|
+
}
|
|
42
|
+
export function functionId(file, name) {
|
|
43
|
+
return `${file}#${shortName(name)}`;
|
|
44
|
+
}
|
|
45
|
+
/** The function id a call-flow node's resolved definition has, or undefined when it resolved none. */
|
|
46
|
+
export function nodeFunctionId(node) {
|
|
47
|
+
return node.source === undefined ? undefined : functionId(node.source.file, node.key);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every function a reader meets in this report, once, capped at
|
|
51
|
+
* {@link MAX_EXPLAINED_FUNCTIONS}: the definitions around each hunk in report
|
|
52
|
+
* order (the changed code, its callers and callees), then the resolved
|
|
53
|
+
* definitions in the call flows. Calls with no definition in the repository
|
|
54
|
+
* (library and framework calls) are not listed: there is nothing of the
|
|
55
|
+
* project's own to explain. Functions outside test files come first, so a cap
|
|
56
|
+
* cuts test helpers before product code.
|
|
57
|
+
*/
|
|
58
|
+
export function functionsOf(report) {
|
|
59
|
+
const found = new Map();
|
|
60
|
+
const add = (file, name, line) => {
|
|
61
|
+
const id = functionId(file, name);
|
|
62
|
+
if (found.has(id) || shortName(name) === "")
|
|
63
|
+
return;
|
|
64
|
+
found.set(id, { id, name: shortName(name), file, line, inTests: testLikeFile(file) });
|
|
65
|
+
};
|
|
66
|
+
for (const item of report.items) {
|
|
67
|
+
for (const node of item.contextNodes ?? [])
|
|
68
|
+
add(node.file, node.key, node.line);
|
|
69
|
+
}
|
|
70
|
+
const walk = (node) => {
|
|
71
|
+
if (node.source !== undefined)
|
|
72
|
+
add(node.source.file, node.key, node.source.line);
|
|
73
|
+
node.children.forEach(walk);
|
|
74
|
+
};
|
|
75
|
+
for (const entry of report.callFlows)
|
|
76
|
+
entry.trees.forEach(walk);
|
|
77
|
+
const all = [...found.values()];
|
|
78
|
+
return [...all.filter((fn) => !fn.inTests), ...all.filter((fn) => fn.inTests)].slice(0, MAX_EXPLAINED_FUNCTIONS);
|
|
79
|
+
}
|
|
80
|
+
const CONTROL_CHARACTERS = /[^\P{Cc}]/u;
|
|
81
|
+
/** Markdown or HTML a person would not type into one line of plain explanation. */
|
|
82
|
+
const SCAFFOLDING = /^\s*(?:#{1,6}\s|>|[-*+]\s|\d+[.)]\s|\|)|(?:\*\*|__|```|~~~|<\/?[a-z][^>]*>|!?\[[^\]]*\]\()/i;
|
|
83
|
+
/** Code a reader would have to decode: a call, a backtick span, a snake_case name, or a source path. */
|
|
84
|
+
const CODE_LIKE = [
|
|
85
|
+
{ pattern: /`/, what: "a code span" },
|
|
86
|
+
// Glued to its parenthesis, as code writes it; prose puts a space before one.
|
|
87
|
+
{ pattern: /[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\(/, what: "a function call" },
|
|
88
|
+
{ pattern: /\b[a-z][a-z0-9]*_[a-z0-9_]*[a-z0-9]\b/, what: "a snake_case name" },
|
|
89
|
+
// A path needs a directory, so product names such as Node.js stay prose.
|
|
90
|
+
{ pattern: /\b[\w.-]+\/[\w./-]*\.(?:py|pyi|ts|tsx|js|jsx|mjs|cjs|go|rb|java|kt|kts|rs|cs|php|swift|scala|c|cc|cpp|h|hpp|m|ex|exs|lua|pl|sol|zig|hs|ml)\b/, what: "a source file path" },
|
|
91
|
+
];
|
|
92
|
+
/** Why one field is not plain prose, or undefined when it is. */
|
|
93
|
+
function proseProblem(text, max) {
|
|
94
|
+
if (text.trim() === "")
|
|
95
|
+
return "is empty";
|
|
96
|
+
if (CONTROL_CHARACTERS.test(text))
|
|
97
|
+
return "must be one line of plain text, with no line breaks, tabs, or control characters";
|
|
98
|
+
if (text.trim().length > max)
|
|
99
|
+
return `is longer than ${max} characters`;
|
|
100
|
+
if (SCAFFOLDING.test(text))
|
|
101
|
+
return "uses Markdown or HTML formatting; write plain prose";
|
|
102
|
+
for (const { pattern, what } of CODE_LIKE) {
|
|
103
|
+
const match = pattern.exec(text);
|
|
104
|
+
if (match !== null)
|
|
105
|
+
return `reads like code (${what}: "${match[0]}"); say what it does for the business or the user in plain words, not the code's names`;
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
function checkProse(where, text, max, required) {
|
|
110
|
+
if (text === undefined) {
|
|
111
|
+
if (required)
|
|
112
|
+
throw new Error(`${where} is missing.`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const problem = proseProblem(text, max);
|
|
116
|
+
if (problem !== undefined)
|
|
117
|
+
throw new Error(`${where} ${problem}.`);
|
|
118
|
+
}
|
|
119
|
+
function checkHunks(where, hunks, items) {
|
|
120
|
+
hunks?.forEach((id, index) => {
|
|
121
|
+
if (!items.has(id))
|
|
122
|
+
throw new Error(`${where}.hunks[${index}] names a hunk this review does not have; use an items[].id.`);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function checkChange(where, change, before) {
|
|
126
|
+
if (!EXPLANATION_CHANGES.some((known) => known === change)) {
|
|
127
|
+
throw new Error(`${where}.change must be one of ${EXPLANATION_CHANGES.join(", ")}.`);
|
|
128
|
+
}
|
|
129
|
+
if (before !== undefined && change !== "changed")
|
|
130
|
+
throw new Error(`${where}.before is only for a changed ${where.includes("rules") ? "rule" : "step"}.`);
|
|
131
|
+
}
|
|
132
|
+
function checkProcess(process, at, functions, items) {
|
|
133
|
+
const where = `processes[${at}]`;
|
|
134
|
+
checkProse(`${where}.title`, process.title, MAX_TITLE_CHARS, true);
|
|
135
|
+
const steps = process.steps;
|
|
136
|
+
if (steps.length < MIN_PROCESS_STEPS || steps.length > MAX_PROCESS_STEPS) {
|
|
137
|
+
throw new Error(`${where} has ${steps.length} steps; draw ${MIN_PROCESS_STEPS} to ${MAX_PROCESS_STEPS}.`);
|
|
138
|
+
}
|
|
139
|
+
const ids = new Set();
|
|
140
|
+
steps.forEach((step, index) => {
|
|
141
|
+
if (ids.has(step.id))
|
|
142
|
+
throw new Error(`${where}.steps[${index}] repeats the step id ${step.id}.`);
|
|
143
|
+
ids.add(step.id);
|
|
144
|
+
});
|
|
145
|
+
steps.forEach((step, index) => {
|
|
146
|
+
const at = `${where}.steps[${index}]`;
|
|
147
|
+
if (!STEP_KINDS.includes(step.kind))
|
|
148
|
+
throw new Error(`${at}.kind must be one of ${STEP_KINDS.join(", ")}.`);
|
|
149
|
+
checkProse(`${at}.text`, step.text, MAX_STEP_CHARS, true);
|
|
150
|
+
checkProse(`${at}.detail`, step.detail, MAX_DETAIL_CHARS, false);
|
|
151
|
+
checkChange(at, step.change, step.before);
|
|
152
|
+
checkProse(`${at}.before`, step.before, MAX_DETAIL_CHARS, false);
|
|
153
|
+
step.functions?.forEach((id, fn) => {
|
|
154
|
+
if (!functions.has(id))
|
|
155
|
+
throw new Error(`${at}.functions[${fn}] is not a function this review lists; use an id from functions.`);
|
|
156
|
+
});
|
|
157
|
+
checkHunks(at, step.hunks, items);
|
|
158
|
+
const exits = step.next ?? [];
|
|
159
|
+
if (exits.length > MAX_STEP_EXITS)
|
|
160
|
+
throw new Error(`${at} has ${exits.length} exits; a step has at most ${MAX_STEP_EXITS}.`);
|
|
161
|
+
const targets = new Set();
|
|
162
|
+
exits.forEach((exit, index) => {
|
|
163
|
+
if (!ids.has(exit.to))
|
|
164
|
+
throw new Error(`${at}.next[${index}] goes to ${exit.to}, which is not a step of this process.`);
|
|
165
|
+
if (targets.has(exit.to))
|
|
166
|
+
throw new Error(`${at}.next[${index}] repeats an exit to ${exit.to}.`);
|
|
167
|
+
targets.add(exit.to);
|
|
168
|
+
checkProse(`${at}.next[${index}].when`, exit.when, MAX_BRANCH_CHARS, step.kind === "decision");
|
|
169
|
+
});
|
|
170
|
+
if (step.kind === "decision" && exits.length < 2)
|
|
171
|
+
throw new Error(`${at} is a decision, so it needs at least two next steps, each with when.`);
|
|
172
|
+
if (step.kind === "end" && exits.length > 0)
|
|
173
|
+
throw new Error(`${at} is an end, so it has no next steps.`);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Check a whole explanation against the review it explains. It must explain
|
|
178
|
+
* every function the review lists, each once; draw one to
|
|
179
|
+
* {@link MAX_PROCESSES} processes whose steps and exits all resolve; and list at
|
|
180
|
+
* most {@link MAX_RULES} rules, a changed one saying what it was before. The
|
|
181
|
+
* first problem refuses the whole explanation.
|
|
182
|
+
*/
|
|
183
|
+
export function checkExplanation(report, input) {
|
|
184
|
+
const listedFunctions = report.functions ?? [];
|
|
185
|
+
const listed = new Map(listedFunctions.map((fn) => [fn.id, fn]));
|
|
186
|
+
const items = new Set(report.items.map((item) => item.id));
|
|
187
|
+
const explained = new Set();
|
|
188
|
+
input.functions.forEach((entry, index) => {
|
|
189
|
+
if (!listed.has(entry.id))
|
|
190
|
+
throw new Error(`explanation.functions[${index}] names ${JSON.stringify(entry.id)}, which is not in this review's functions list.`);
|
|
191
|
+
if (explained.has(entry.id))
|
|
192
|
+
throw new Error(`explanation.functions[${index}] explains ${entry.id} a second time.`);
|
|
193
|
+
explained.add(entry.id);
|
|
194
|
+
checkProse(`explanation.functions[${index}].purpose`, entry.purpose, MAX_PURPOSE_CHARS, true);
|
|
195
|
+
});
|
|
196
|
+
const missing = listedFunctions.filter((fn) => !explained.has(fn.id));
|
|
197
|
+
if (missing.length > 0) {
|
|
198
|
+
throw new Error(`explanation.functions leaves out ${missing.length} of ${listedFunctions.length} functions, starting with ${missing[0].id}; explain every function the review lists, in one plain sentence each.`);
|
|
199
|
+
}
|
|
200
|
+
if (input.processes.length < 1 || input.processes.length > MAX_PROCESSES) {
|
|
201
|
+
throw new Error(`explanation.processes has ${input.processes.length} processes; draw 1 to ${MAX_PROCESSES}: the business flows this change touches.`);
|
|
202
|
+
}
|
|
203
|
+
const functions = new Set(listed.keys());
|
|
204
|
+
input.processes.forEach((process, index) => checkProcess(process, index, functions, items));
|
|
205
|
+
if (input.rules.length > MAX_RULES)
|
|
206
|
+
throw new Error(`explanation.rules has ${input.rules.length} rules; list at most ${MAX_RULES}.`);
|
|
207
|
+
input.rules.forEach((rule, index) => {
|
|
208
|
+
const where = `explanation.rules[${index}]`;
|
|
209
|
+
checkProse(`${where}.text`, rule.text, MAX_RULE_CHARS, true);
|
|
210
|
+
checkChange(where, rule.change, rule.before);
|
|
211
|
+
if (rule.change === "changed")
|
|
212
|
+
checkProse(`${where}.before`, rule.before, MAX_RULE_CHARS, true);
|
|
213
|
+
checkHunks(where, rule.hunks, items);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/** Trim every text field and drop empty optional lists, so the pages render exactly what was checked. */
|
|
217
|
+
export function normalizeExplanation(input, explainedBy) {
|
|
218
|
+
const processes = input.processes.map((process) => ({
|
|
219
|
+
title: process.title.trim(),
|
|
220
|
+
steps: process.steps.map(normalizeStep),
|
|
221
|
+
}));
|
|
222
|
+
const rules = input.rules.map((rule) => {
|
|
223
|
+
const kept = { text: rule.text.trim(), change: rule.change };
|
|
224
|
+
if (rule.before !== undefined)
|
|
225
|
+
kept.before = rule.before.trim();
|
|
226
|
+
if (rule.hunks !== undefined && rule.hunks.length > 0)
|
|
227
|
+
kept.hunks = [...rule.hunks];
|
|
228
|
+
return kept;
|
|
229
|
+
});
|
|
230
|
+
return {
|
|
231
|
+
functions: input.functions.map(({ id, purpose }) => ({ id, purpose: purpose.trim() })),
|
|
232
|
+
processes,
|
|
233
|
+
rules,
|
|
234
|
+
explainedBy,
|
|
235
|
+
explainedAt: new Date().toISOString(),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function normalizeStep(step) {
|
|
239
|
+
const kept = { id: step.id, kind: step.kind, text: step.text.trim(), change: step.change };
|
|
240
|
+
if (step.detail !== undefined)
|
|
241
|
+
kept.detail = step.detail.trim();
|
|
242
|
+
if (step.before !== undefined)
|
|
243
|
+
kept.before = step.before.trim();
|
|
244
|
+
if (step.functions !== undefined && step.functions.length > 0)
|
|
245
|
+
kept.functions = [...step.functions];
|
|
246
|
+
if (step.hunks !== undefined && step.hunks.length > 0)
|
|
247
|
+
kept.hunks = [...step.hunks];
|
|
248
|
+
if (step.next !== undefined && step.next.length > 0) {
|
|
249
|
+
kept.next = step.next.map((exit) => (exit.when === undefined ? { to: exit.to } : { to: exit.to, when: exit.when.trim() }));
|
|
250
|
+
}
|
|
251
|
+
return kept;
|
|
252
|
+
}
|
|
253
|
+
export function explanationCounts(input) {
|
|
254
|
+
return {
|
|
255
|
+
functions: input.functions.length,
|
|
256
|
+
processes: input.processes.length,
|
|
257
|
+
steps: input.processes.reduce((total, process) => total + process.steps.length, 0),
|
|
258
|
+
rules: input.rules.length,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/** The agent's purpose for each function id, empty when there is no explanation. */
|
|
262
|
+
export function purposesOf(report) {
|
|
263
|
+
return new Map((report.agentExplanation?.functions ?? []).map((entry) => [entry.id, entry.purpose]));
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Where each step goes, with the implicit exits made explicit: a start or action
|
|
267
|
+
* step with no `next` continues to the step listed after it. A decision always
|
|
268
|
+
* lists its own exits, and an end has none.
|
|
269
|
+
*/
|
|
270
|
+
export function exitsOf(process) {
|
|
271
|
+
const exits = new Map();
|
|
272
|
+
process.steps.forEach((step, index) => {
|
|
273
|
+
const following = process.steps[index + 1];
|
|
274
|
+
if (step.next !== undefined && step.next.length > 0)
|
|
275
|
+
exits.set(step.id, step.next);
|
|
276
|
+
else if (step.kind !== "end" && step.kind !== "decision" && following !== undefined)
|
|
277
|
+
exits.set(step.id, [{ to: following.id }]);
|
|
278
|
+
else
|
|
279
|
+
exits.set(step.id, []);
|
|
280
|
+
});
|
|
281
|
+
return exits;
|
|
282
|
+
}
|
|
283
|
+
/** Greedy word wrap; a word longer than the line gets a line of its own. */
|
|
284
|
+
export function wrapWords(text, width) {
|
|
285
|
+
const lines = [];
|
|
286
|
+
let line = "";
|
|
287
|
+
for (const word of text.split(/\s+/).filter((part) => part !== "")) {
|
|
288
|
+
if (line === "")
|
|
289
|
+
line = word;
|
|
290
|
+
else if (line.length + 1 + word.length <= width)
|
|
291
|
+
line = `${line} ${word}`;
|
|
292
|
+
else {
|
|
293
|
+
lines.push(line);
|
|
294
|
+
line = word;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (line !== "")
|
|
298
|
+
lines.push(line);
|
|
299
|
+
return lines.length === 0 ? [""] : lines;
|
|
300
|
+
}
|
|
301
|
+
/** At most `maxLines` wrapped lines; the last one ends in an ellipsis when text was cut. */
|
|
302
|
+
export function wrapPurpose(text, width, maxLines) {
|
|
303
|
+
const lines = wrapWords(text, Math.max(8, width));
|
|
304
|
+
if (lines.length <= maxLines)
|
|
305
|
+
return lines;
|
|
306
|
+
const kept = lines.slice(0, maxLines);
|
|
307
|
+
const last = kept[maxLines - 1];
|
|
308
|
+
kept[maxLines - 1] = last.length + 1 > width ? `${last.slice(0, Math.max(1, width - 1))}\u2026` : `${last}\u2026`;
|
|
309
|
+
return kept;
|
|
310
|
+
}
|
package/dist/review/html.d.ts
CHANGED
|
@@ -25,6 +25,13 @@ import type { ReviewReport } from "./types.js";
|
|
|
25
25
|
* request body or report field is HTML-escaped.
|
|
26
26
|
*/
|
|
27
27
|
export declare function renderReview(report: ReviewReport): string;
|
|
28
|
+
/**
|
|
29
|
+
* The business view alone, for the pull request page to frame under its goal:
|
|
30
|
+
* the processes, the rules, and the glossary, with no links into a diff (the
|
|
31
|
+
* page around it has its own). Server-rendered; its one script only reports
|
|
32
|
+
* the document's height to the framing page.
|
|
33
|
+
*/
|
|
34
|
+
export declare function renderBusinessPage(report: ReviewReport): string;
|
|
28
35
|
/**
|
|
29
36
|
* The call flows of one review as a page of their own, for the connected pull
|
|
30
37
|
* request page to show beside its diff: every changed file with call flows, or
|