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
|
@@ -11,12 +11,17 @@
|
|
|
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. */
|
|
17
18
|
export declare const MAX_SUGGESTED_COMMENTS = 30;
|
|
18
19
|
/** Longest suggested comment: a sentence or two, the way a reviewer writes one. */
|
|
19
20
|
export declare const MAX_SUGGESTED_CHARS = 280;
|
|
21
|
+
/** Longest goal summary: one short paragraph a maintainer reads before the diff. */
|
|
22
|
+
export declare const MAX_SUMMARY_CHARS = 600;
|
|
23
|
+
/** Longest goal summary by words: the same paragraph, kept short on purpose. */
|
|
24
|
+
export declare const MAX_SUMMARY_WORDS = 80;
|
|
20
25
|
/** CSP naming exactly the inline script and style blocks of one page. */
|
|
21
26
|
export declare function reportPolicy(html: string): string;
|
|
22
27
|
/** A static review published on this connection. */
|
|
@@ -43,17 +48,33 @@ export interface RecordedComments {
|
|
|
43
48
|
readonly reviewId: string;
|
|
44
49
|
readonly suggested: number;
|
|
45
50
|
}
|
|
46
|
-
|
|
51
|
+
export interface RecordedExplanation {
|
|
52
|
+
readonly reviewId: string;
|
|
53
|
+
readonly explained: ExplanationCounts;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Everything the reviewing agent owes a review before its pages are handed out.
|
|
57
|
+
* `summary` is the agent's own plain-English paragraph on what the pull request
|
|
58
|
+
* does and why, taken from the pull request's own title and description; a
|
|
59
|
+
* connected review must send it, a static report need not.
|
|
60
|
+
*/
|
|
47
61
|
export interface FinishInput {
|
|
48
62
|
readonly answers: readonly AnswerInput[];
|
|
49
63
|
readonly order: readonly string[];
|
|
50
64
|
readonly comments: readonly SuggestedComment[];
|
|
65
|
+
readonly summary?: string;
|
|
66
|
+
/** The business explanation: every listed function's purpose, the processes, and the rules. */
|
|
67
|
+
readonly explanation?: ExplanationInput;
|
|
51
68
|
}
|
|
52
69
|
export interface FinishedReview {
|
|
53
70
|
readonly reviewId: string;
|
|
54
71
|
readonly answered: number;
|
|
55
72
|
readonly ordered: number;
|
|
56
73
|
readonly suggested: number;
|
|
74
|
+
/** Characters of the goal summary kept, or 0 when none was sent. */
|
|
75
|
+
readonly summarized: number;
|
|
76
|
+
/** What the business explanation holds, or absent when none was sent. */
|
|
77
|
+
explained?: ExplanationCounts;
|
|
57
78
|
readonly reportUrl: string;
|
|
58
79
|
}
|
|
59
80
|
export declare class ReportPages {
|
|
@@ -69,11 +90,17 @@ export declare class ReportPages {
|
|
|
69
90
|
publish(report: ReviewReport): Promise<PublishedReview>;
|
|
70
91
|
/**
|
|
71
92
|
* Accept the reviewing agent's whole reading of a review at once: an answer
|
|
72
|
-
* to every question, the reading order of every hunk,
|
|
73
|
-
*
|
|
93
|
+
* to every question, the reading order of every hunk, the line comments it
|
|
94
|
+
* suggests (an empty list says it has none), and, for a connected pull
|
|
95
|
+
* request, one short paragraph on the goal. Everything is checked before
|
|
74
96
|
* anything is kept, so one gap or bad entry refuses the call and changes
|
|
75
97
|
* nothing. Only a finished review's page addresses are handed out: an agent
|
|
76
98
|
* cannot give the human a page it has not finished reading.
|
|
99
|
+
*
|
|
100
|
+
* `summary` is checked here like any other field, so a bad paragraph refuses
|
|
101
|
+
* the answers and the order with it; whether a connected review *owes* one is
|
|
102
|
+
* the caller's decision (see mcp.ts), because only it knows which reviews are
|
|
103
|
+
* pull request reviews.
|
|
77
104
|
*/
|
|
78
105
|
finish(reviewId: string, input: FinishInput, by: string): FinishedReview;
|
|
79
106
|
/** Whether finish_review accepted this review, so its addresses may be handed out again. */
|
|
@@ -100,6 +127,13 @@ export declare class ReportPages {
|
|
|
100
127
|
* call and keeps the previous set; an empty list clears it.
|
|
101
128
|
*/
|
|
102
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;
|
|
103
137
|
private review;
|
|
104
138
|
private rerender;
|
|
105
139
|
private origin;
|
|
@@ -13,13 +13,26 @@
|
|
|
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. */
|
|
19
20
|
export const MAX_SUGGESTED_COMMENTS = 30;
|
|
20
21
|
/** Longest suggested comment: a sentence or two, the way a reviewer writes one. */
|
|
21
22
|
export const MAX_SUGGESTED_CHARS = 280;
|
|
23
|
+
/** Longest goal summary: one short paragraph a maintainer reads before the diff. */
|
|
24
|
+
export const MAX_SUMMARY_CHARS = 600;
|
|
25
|
+
/** Longest goal summary by words: the same paragraph, kept short on purpose. */
|
|
26
|
+
export const MAX_SUMMARY_WORDS = 80;
|
|
22
27
|
const CONTROL_CHARACTERS = /[^\P{Cc}]/u;
|
|
28
|
+
/**
|
|
29
|
+
* Report scaffolding a person would not write as a goal summary. The summary is
|
|
30
|
+
* one paragraph with no line breaks, so a heading, quote, or list marker can
|
|
31
|
+
* only open it; bold, code spans, links, tables, and tags are caught anywhere.
|
|
32
|
+
* Ordinary prose stays untouched: a sentence that ends in a number ("a fixed
|
|
33
|
+
* 10.") or a hyphen inside a phrase is not scaffolding.
|
|
34
|
+
*/
|
|
35
|
+
const SUMMARY_SCAFFOLDING = /^\s*(?:#{1,6}\s|>|```|~~~|[-*+]\s|\d+[.)]\s|\|)|(?:\*\*|__|\||```|~~~|`|<\/?[a-z][^>]*>|!?\[[^\]]*\]\()/i;
|
|
23
36
|
/** Report scaffolding a person would not write in a review comment: "Finding 1:", "Attention -", "**Error**", "## Bug". */
|
|
24
37
|
const REPORT_LABEL = /^\s*(?:#|>|[-*+]\s|\d+[.)]\s|\*\*|\[)|^\s*(?:findings?|issues?|attention|errors?|warnings?|bugs?|problems?|severity|critical|major|minor|high|medium|low|concerns?|risks?|suggestions?|observations?|summary)\b\s*#?\d*\s*[:\-\u2013\u2014.]|\*\*/i;
|
|
25
38
|
const INLINE_BLOCK = /<(script|style)>([\s\S]*?)<\/\1>/g;
|
|
@@ -109,6 +122,29 @@ function applyComments(report, comments, suggestedBy) {
|
|
|
109
122
|
suggestedAt: new Date().toISOString(),
|
|
110
123
|
};
|
|
111
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* The goal paragraph the agent sent, trimmed and validated, or undefined when it
|
|
127
|
+
* sent none. The bounds are mechanical — one non-empty paragraph of plain prose
|
|
128
|
+
* within {@link MAX_SUMMARY_CHARS} characters and {@link MAX_SUMMARY_WORDS}
|
|
129
|
+
* words, with no control characters and no Markdown scaffolding. Nothing here
|
|
130
|
+
* judges the writing itself, and a bad paragraph refuses the whole call.
|
|
131
|
+
*/
|
|
132
|
+
function checkSummary(summary) {
|
|
133
|
+
if (summary === undefined)
|
|
134
|
+
return undefined;
|
|
135
|
+
const text = summary.trim();
|
|
136
|
+
if (text === "")
|
|
137
|
+
throw new Error("summary is empty; write one short paragraph on what the pull request does and why, from its own title and description.");
|
|
138
|
+
if (CONTROL_CHARACTERS.test(text))
|
|
139
|
+
throw new Error("summary must be one paragraph of plain text: no line breaks, tabs, or other control characters.");
|
|
140
|
+
if (text.length > MAX_SUMMARY_CHARS)
|
|
141
|
+
throw new Error(`summary is longer than ${MAX_SUMMARY_CHARS} characters; write one short paragraph.`);
|
|
142
|
+
if (text.split(/\s+/).length > MAX_SUMMARY_WORDS)
|
|
143
|
+
throw new Error(`summary is longer than ${MAX_SUMMARY_WORDS} words; write one short paragraph.`);
|
|
144
|
+
if (SUMMARY_SCAFFOLDING.test(text))
|
|
145
|
+
throw new Error("summary uses Markdown or HTML formatting (a heading, list, bold, code, quote, link, table, or tag); write plain prose.");
|
|
146
|
+
return text;
|
|
147
|
+
}
|
|
112
148
|
/** Map key of one commentable line. */
|
|
113
149
|
function anchorKey(path, side, line) {
|
|
114
150
|
return JSON.stringify([path, side, line]);
|
|
@@ -179,11 +215,17 @@ export class ReportPages {
|
|
|
179
215
|
}
|
|
180
216
|
/**
|
|
181
217
|
* Accept the reviewing agent's whole reading of a review at once: an answer
|
|
182
|
-
* to every question, the reading order of every hunk,
|
|
183
|
-
*
|
|
218
|
+
* to every question, the reading order of every hunk, the line comments it
|
|
219
|
+
* suggests (an empty list says it has none), and, for a connected pull
|
|
220
|
+
* request, one short paragraph on the goal. Everything is checked before
|
|
184
221
|
* anything is kept, so one gap or bad entry refuses the call and changes
|
|
185
222
|
* nothing. Only a finished review's page addresses are handed out: an agent
|
|
186
223
|
* cannot give the human a page it has not finished reading.
|
|
224
|
+
*
|
|
225
|
+
* `summary` is checked here like any other field, so a bad paragraph refuses
|
|
226
|
+
* the answers and the order with it; whether a connected review *owes* one is
|
|
227
|
+
* the caller's decision (see mcp.ts), because only it knows which reviews are
|
|
228
|
+
* pull request reviews.
|
|
187
229
|
*/
|
|
188
230
|
finish(reviewId, input, by) {
|
|
189
231
|
const { token, page, report } = this.review(reviewId);
|
|
@@ -195,18 +237,29 @@ export class ReportPages {
|
|
|
195
237
|
}
|
|
196
238
|
checkOrder(report, input.order);
|
|
197
239
|
checkComments(report, input.comments);
|
|
240
|
+
const summary = checkSummary(input.summary);
|
|
241
|
+
if (input.explanation !== undefined)
|
|
242
|
+
checkExplanation(report, input.explanation);
|
|
198
243
|
applyAnswers(report, input.answers, by);
|
|
199
244
|
applyOrder(report, input.order, by);
|
|
200
245
|
applyComments(report, input.comments, by);
|
|
246
|
+
if (summary !== undefined)
|
|
247
|
+
report.agentSummary = { text: summary, summarizedBy: by };
|
|
248
|
+
if (input.explanation !== undefined)
|
|
249
|
+
report.agentExplanation = normalizeExplanation(input.explanation, by);
|
|
201
250
|
page.finished = true;
|
|
202
251
|
this.rerender(page, report);
|
|
203
|
-
|
|
252
|
+
const finished = {
|
|
204
253
|
reviewId,
|
|
205
254
|
answered: input.answers.length,
|
|
206
255
|
ordered: input.order.length,
|
|
207
256
|
suggested: input.comments.length,
|
|
257
|
+
summarized: summary?.length ?? 0,
|
|
208
258
|
reportUrl: `${this.origin}/report/${token}`,
|
|
209
259
|
};
|
|
260
|
+
if (input.explanation !== undefined)
|
|
261
|
+
finished.explained = explanationCounts(input.explanation);
|
|
262
|
+
return finished;
|
|
210
263
|
}
|
|
211
264
|
/** Whether finish_review accepted this review, so its addresses may be handed out again. */
|
|
212
265
|
isFinished(reviewId) {
|
|
@@ -253,6 +306,19 @@ export class ReportPages {
|
|
|
253
306
|
this.rerender(page, report);
|
|
254
307
|
return { reviewId, suggested: comments.length };
|
|
255
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
|
+
}
|
|
256
322
|
review(reviewId) {
|
|
257
323
|
const token = this.tokens.get(reviewId);
|
|
258
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;
|
package/dist/review/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { PullRequestIntent, ReviewEvidence } from "./evidence-types.js";
|
|
|
2
2
|
import type { ChangeFacts } from "./change-facts.js";
|
|
3
3
|
import type { ReviewQuestion } from "./questions.js";
|
|
4
4
|
import type { HunkHistory, ProjectContext } from "./history.js";
|
|
5
|
+
import type { AgentExplanation, ExplainedFunction } from "./explanation.js";
|
|
5
6
|
export type ReviewStatus = "attention" | "uncertain" | "low" | "passed";
|
|
6
7
|
/**
|
|
7
8
|
* Structural status for a call-flow node. The engine only knows `same`,
|
|
@@ -143,6 +144,18 @@ export interface AgentComments {
|
|
|
143
144
|
suggestedBy: string;
|
|
144
145
|
suggestedAt: string;
|
|
145
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* The reviewing agent's own plain-English description of what the pull request
|
|
149
|
+
* does and why, written for the human before they read the diff. It is the
|
|
150
|
+
* agent's reading, never diffninja's and never a claim that the changes achieve
|
|
151
|
+
* the goal: the page shows it attributed, as a description.
|
|
152
|
+
*/
|
|
153
|
+
export interface AgentSummary {
|
|
154
|
+
/** One short paragraph: the goal, why it matters, and the limits a reviewer should know. */
|
|
155
|
+
text: string;
|
|
156
|
+
/** The MCP client that wrote it, as it names itself. */
|
|
157
|
+
summarizedBy: string;
|
|
158
|
+
}
|
|
146
159
|
export interface ReviewReport {
|
|
147
160
|
title: string;
|
|
148
161
|
source: string;
|
|
@@ -165,6 +178,27 @@ export interface ReviewReport {
|
|
|
165
178
|
agentOrder?: AgentOrder;
|
|
166
179
|
/** Line comments the reviewing agent suggested; nothing is posted until the human submits them. */
|
|
167
180
|
agentComments?: AgentComments;
|
|
181
|
+
/**
|
|
182
|
+
* The reviewing agent's own paragraph on the pull request's goal, written
|
|
183
|
+
* from the author's title and description. Only finish_review's accepted
|
|
184
|
+
* reading stores it, so an unfinished report never carries one, and it is the
|
|
185
|
+
* agent's reading rather than a verified claim.
|
|
186
|
+
*/
|
|
187
|
+
agentSummary?: AgentSummary;
|
|
188
|
+
/**
|
|
189
|
+
* Every function a reader meets in this report (around the hunks and in the
|
|
190
|
+
* call flows), each with a stable `<file>#<name>` id, for the reviewing agent
|
|
191
|
+
* to explain in business terms. Empty for a patch, which resolves none;
|
|
192
|
+
* absent only on a report assembled by hand, which counts as empty.
|
|
193
|
+
*/
|
|
194
|
+
functions?: ExplainedFunction[];
|
|
195
|
+
/**
|
|
196
|
+
* The reviewing agent's business explanation: a plain purpose for every
|
|
197
|
+
* listed function, the processes the change touches as steps and decisions,
|
|
198
|
+
* and the business rules it adds, changes, or removes. Attributed to the
|
|
199
|
+
* client that wrote it; never generated by diffninja.
|
|
200
|
+
*/
|
|
201
|
+
agentExplanation?: AgentExplanation;
|
|
168
202
|
/**
|
|
169
203
|
* Local repository context for a git-range review: prior reverts, contributor
|
|
170
204
|
* guidelines, and sibling-file conventions. Absent for a patch.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "diffninja",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Local, deterministic PR review for coding agents over MCP: call flows, change facts, and a human review workspace",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
63
63
|
"incur": "^0.4.26",
|
|
64
|
+
"marked": "^18.0.14",
|
|
64
65
|
"picocolors": "^1.1.1",
|
|
65
66
|
"smol-toml": "^1.9.0",
|
|
66
67
|
"tree-sitter": "^0.25.1",
|