diffninja 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -228
- 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 +29 -2
- package/dist/review/connected-analysis.js +28 -0
- package/dist/review/connected-html.js +372 -32
- package/dist/review/connected.d.ts +17 -3
- package/dist/review/connected.js +23 -2
- 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/markdown.d.ts +68 -0
- package/dist/review/markdown.js +339 -0
- package/dist/review/mcp.js +102 -13
- package/dist/review/process-html.d.ts +93 -0
- package/dist/review/process-html.js +525 -0
- package/dist/review/report-pages.d.ts +37 -3
- package/dist/review/report-pages.js +69 -3
- package/dist/review/service.js +3 -1
- package/dist/review/types.d.ts +34 -0
- package/package.json +2 -1
|
@@ -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
|
package/dist/review/html.js
CHANGED
|
@@ -2,6 +2,7 @@ import { verdictOf } from "./questions.js";
|
|
|
2
2
|
import { factQuestionsFor } from "./change-facts.js";
|
|
3
3
|
import { renderCallFlows, CALL_FLOW_STYLES, CALL_FLOW_SCRIPT } from "./call-flow-html.js";
|
|
4
4
|
import { renderBrief, BRIEF_STYLES } from "./evidence-html.js";
|
|
5
|
+
import { renderBusinessView, BUSINESS_STYLES } from "./process-html.js";
|
|
5
6
|
import { BRAND_MARK, BRAND_MARK_STYLES } from "./brand.js";
|
|
6
7
|
import { PALETTE_STYLES } from "./palette.js";
|
|
7
8
|
import { escapeHtml } from "./escape-html.js";
|
|
@@ -52,17 +53,19 @@ export function renderReview(report) {
|
|
|
52
53
|
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
53
54
|
'<meta name="color-scheme" content="light dark">',
|
|
54
55
|
`<title>${escapeHtml(`diffninja review: ${report.title || "untitled diff"}`)}</title>`,
|
|
55
|
-
`<style>${STYLES}\n${BRIEF_STYLES}\n${CALL_FLOW_STYLES}</style>`,
|
|
56
|
+
`<style>${STYLES}\n${BRIEF_STYLES}\n${CALL_FLOW_STYLES}\n${BUSINESS_STYLES}\n${BUSINESS_VIEW_STYLES}</style>`,
|
|
56
57
|
"</head>",
|
|
57
|
-
`<body data-default-view="${report
|
|
58
|
+
`<body data-default-view="${defaultView(report)}">`,
|
|
58
59
|
'<div class="wrap">',
|
|
59
60
|
renderHeader(report),
|
|
60
61
|
renderAgentOrder(report),
|
|
61
62
|
'<nav class="view-switch" aria-label="Report view">',
|
|
63
|
+
'<a href="#view-business" data-view="business">How it works</a>',
|
|
62
64
|
'<a href="#view-brief" data-view="brief">Outcome</a>',
|
|
63
65
|
'<a href="#view-call-flow" data-view="call-flow">Call flow</a>',
|
|
64
66
|
'<a href="#view-diff" data-view="diff">Diff</a>',
|
|
65
67
|
"</nav>",
|
|
68
|
+
`<section class="business" id="view-business" aria-labelledby="view-business-title">${renderBusinessSection(report)}</section>`,
|
|
66
69
|
`<section class="brief" id="view-brief" aria-label="Expected outcome and reading agenda">${renderBrief(report)}</section>`,
|
|
67
70
|
'<section id="view-call-flow" aria-label="Call flow">',
|
|
68
71
|
renderCallFlows(report),
|
|
@@ -78,6 +81,87 @@ export function renderReview(report) {
|
|
|
78
81
|
"",
|
|
79
82
|
].join("\n");
|
|
80
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* The page opens on the business view once the reviewing agent explained the
|
|
86
|
+
* change: that is the first thing a reader who does not know this code needs.
|
|
87
|
+
* Without one it opens on the agenda, or on the call flow when there is none.
|
|
88
|
+
*/
|
|
89
|
+
function defaultView(report) {
|
|
90
|
+
if (report.agentExplanation !== undefined)
|
|
91
|
+
return "business";
|
|
92
|
+
return report.evidence === undefined ? "call-flow" : "brief";
|
|
93
|
+
}
|
|
94
|
+
/** Link target of a hunk on this page: its card in the diff, by rank. */
|
|
95
|
+
function hunkHref(report) {
|
|
96
|
+
const ranks = new Map(report.items.map((item, index) => [item.id, index + 1]));
|
|
97
|
+
return (itemId) => {
|
|
98
|
+
const rank = ranks.get(itemId);
|
|
99
|
+
return rank === undefined ? undefined : `#item-${rank}`;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function renderBusinessSection(report) {
|
|
103
|
+
return [
|
|
104
|
+
'<h2 class="business-title" id="view-business-title">How it works</h2>',
|
|
105
|
+
'<p class="business-lede">What this change does to the product, as processes, rules, and plain descriptions of the code it touches. The reviewing agent wrote it from the code; the diff stays the ground truth.</p>',
|
|
106
|
+
renderBusinessView(report, { hunkHref: hunkHref(report) }),
|
|
107
|
+
].join("\n");
|
|
108
|
+
}
|
|
109
|
+
const BUSINESS_VIEW_STYLES = `
|
|
110
|
+
.business { padding-top: 14px; }
|
|
111
|
+
.business-title { font-size: 20px; }
|
|
112
|
+
.business-lede { margin-top: 4px; color: var(--ink-soft); font-size: 14px; max-width: 90ch; }
|
|
113
|
+
.flow-section-title { margin: 22px 0 0; font-size: 18px; }
|
|
114
|
+
`;
|
|
115
|
+
/**
|
|
116
|
+
* Posts the document's height to the page that frames it, so the pull request
|
|
117
|
+
* page can size its inline "How it works" frame to fit. Only the same origin
|
|
118
|
+
* receives it, and it carries a number, nothing from the report.
|
|
119
|
+
*/
|
|
120
|
+
const BUSINESS_FRAME_SCRIPT = `
|
|
121
|
+
(function () {
|
|
122
|
+
'use strict';
|
|
123
|
+
function post() {
|
|
124
|
+
if (window.parent === window) return;
|
|
125
|
+
window.parent.postMessage({ type: 'diffninja-business-height', height: Math.ceil(document.documentElement.scrollHeight) }, window.location.origin);
|
|
126
|
+
}
|
|
127
|
+
window.addEventListener('load', post);
|
|
128
|
+
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(post).observe(document.body);
|
|
129
|
+
document.addEventListener('toggle', post, true);
|
|
130
|
+
post();
|
|
131
|
+
}());
|
|
132
|
+
`;
|
|
133
|
+
/**
|
|
134
|
+
* The business view alone, for the pull request page to frame under its goal:
|
|
135
|
+
* the processes, the rules, and the glossary, with no links into a diff (the
|
|
136
|
+
* page around it has its own). Server-rendered; its one script only reports
|
|
137
|
+
* the document's height to the framing page.
|
|
138
|
+
*/
|
|
139
|
+
export function renderBusinessPage(report) {
|
|
140
|
+
return [
|
|
141
|
+
"<!doctype html>",
|
|
142
|
+
'<html lang="en">',
|
|
143
|
+
"<head>",
|
|
144
|
+
'<meta charset="utf-8">',
|
|
145
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
146
|
+
'<meta name="color-scheme" content="light dark">',
|
|
147
|
+
"<title>How it works</title>",
|
|
148
|
+
`<style>${STYLES}\n${PALETTE_STYLES}\n${BUSINESS_STYLES}\n${EMBEDDED_BUSINESS_STYLES}</style>`,
|
|
149
|
+
"</head>",
|
|
150
|
+
"<body>",
|
|
151
|
+
'<main class="business-embed" aria-label="How it works">',
|
|
152
|
+
renderBusinessView(report, { attribution: false, glossary: false, stepsOpen: false }),
|
|
153
|
+
"</main>",
|
|
154
|
+
`<script>${BUSINESS_FRAME_SCRIPT}</script>`,
|
|
155
|
+
"</body>",
|
|
156
|
+
"</html>",
|
|
157
|
+
"",
|
|
158
|
+
].join("\n");
|
|
159
|
+
}
|
|
160
|
+
const EMBEDDED_BUSINESS_STYLES = `
|
|
161
|
+
body { background: transparent; font-family: var(--sans); }
|
|
162
|
+
.business-embed { padding: 0 2px 4px; }
|
|
163
|
+
.business-embed .bp { margin-top: 0; }
|
|
164
|
+
`;
|
|
81
165
|
/** Trims the report's call-flow view to live inside the pull request page's drawer. */
|
|
82
166
|
const EMBEDDED_FLOW_STYLES = `
|
|
83
167
|
body { background: var(--bg); font-family: var(--sans); }
|
|
@@ -102,10 +186,19 @@ export function renderCallFlowPage(report, file) {
|
|
|
102
186
|
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
103
187
|
'<meta name="color-scheme" content="light dark">',
|
|
104
188
|
`<title>${escapeHtml(file === undefined ? "Call flows" : `Call flow: ${file}`)}</title>`,
|
|
105
|
-
`<style>${STYLES}\n${CALL_FLOW_STYLES}\n${PALETTE_STYLES}\n${EMBEDDED_FLOW_STYLES}</style>`,
|
|
189
|
+
`<style>${STYLES}\n${CALL_FLOW_STYLES}\n${PALETTE_STYLES}\n${BUSINESS_STYLES}\n${BUSINESS_VIEW_STYLES}\n${EMBEDDED_FLOW_STYLES}</style>`,
|
|
106
190
|
"</head>",
|
|
107
191
|
"<body>",
|
|
192
|
+
report.agentExplanation === undefined
|
|
193
|
+
? ""
|
|
194
|
+
: [
|
|
195
|
+
'<section class="flow-embed business" aria-labelledby="flow-business-title">',
|
|
196
|
+
`<h2 class="business-title" id="flow-business-title">${file === undefined ? "How it works" : "How this file fits the process"}</h2>`,
|
|
197
|
+
renderBusinessView(report, { file, glossary: false }),
|
|
198
|
+
"</section>",
|
|
199
|
+
].join("\n"),
|
|
108
200
|
`<section id="view-call-flow" class="flow-embed${file === undefined ? "" : " flow-single"}" aria-label="Call flow">`,
|
|
201
|
+
report.agentExplanation === undefined ? "" : '<h2 class="flow-section-title">Call flow</h2>',
|
|
109
202
|
renderCallFlows(scoped),
|
|
110
203
|
"</section>",
|
|
111
204
|
`<script>document.documentElement.classList.add('js');\n${CALL_FLOW_SCRIPT}</script>`,
|
|
@@ -491,10 +584,11 @@ const SCRIPT = `
|
|
|
491
584
|
'use strict';
|
|
492
585
|
document.documentElement.classList.add('js');
|
|
493
586
|
var briefView = document.getElementById('view-brief');
|
|
587
|
+
var businessView = document.getElementById('view-business');
|
|
494
588
|
var diffView = document.getElementById('view-diff');
|
|
495
589
|
var flowView = document.getElementById('view-call-flow');
|
|
496
590
|
var viewLinks = Array.prototype.slice.call(document.querySelectorAll('[data-view]'));
|
|
497
|
-
var VIEWS = { brief: briefView, 'call-flow': flowView, diff: diffView };
|
|
591
|
+
var VIEWS = { business: businessView, brief: briefView, 'call-flow': flowView, diff: diffView };
|
|
498
592
|
function showView(name) {
|
|
499
593
|
for (var key in VIEWS) {
|
|
500
594
|
if (VIEWS[key]) VIEWS[key].hidden = key !== name;
|