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.
@@ -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
- /** Everything the reviewing agent owes a review before its pages are handed out. */
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, and the line comments
73
- * it suggests (an empty list says it has none). Everything is checked before
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, and the line comments
183
- * it suggests (an empty list says it has none). Everything is checked before
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
- return {
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);
@@ -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;
@@ -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.1.1",
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",