diffninja 0.1.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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -0
  3. package/dist/calltree.d.ts +47 -0
  4. package/dist/calltree.js +296 -0
  5. package/dist/cli.d.ts +57 -0
  6. package/dist/cli.js +340 -0
  7. package/dist/diff.d.ts +7 -0
  8. package/dist/diff.js +114 -0
  9. package/dist/extract.d.ts +26 -0
  10. package/dist/extract.js +152 -0
  11. package/dist/git.d.ts +40 -0
  12. package/dist/git.js +288 -0
  13. package/dist/index.d.ts +9 -0
  14. package/dist/index.js +8 -0
  15. package/dist/infer.d.ts +21 -0
  16. package/dist/infer.js +189 -0
  17. package/dist/languages/bash.d.ts +2 -0
  18. package/dist/languages/bash.js +208 -0
  19. package/dist/languages/c.d.ts +2 -0
  20. package/dist/languages/c.js +218 -0
  21. package/dist/languages/call-syntax.d.ts +125 -0
  22. package/dist/languages/call-syntax.js +997 -0
  23. package/dist/languages/cpp.d.ts +2 -0
  24. package/dist/languages/cpp.js +321 -0
  25. package/dist/languages/csharp.d.ts +2 -0
  26. package/dist/languages/csharp.js +324 -0
  27. package/dist/languages/elixir.d.ts +2 -0
  28. package/dist/languages/elixir.js +331 -0
  29. package/dist/languages/go.d.ts +2 -0
  30. package/dist/languages/go.js +299 -0
  31. package/dist/languages/grammars.d.ts +50 -0
  32. package/dist/languages/grammars.js +351 -0
  33. package/dist/languages/haskell.d.ts +2 -0
  34. package/dist/languages/haskell.js +250 -0
  35. package/dist/languages/java.d.ts +2 -0
  36. package/dist/languages/java.js +351 -0
  37. package/dist/languages/javascript.d.ts +4 -0
  38. package/dist/languages/javascript.js +648 -0
  39. package/dist/languages/kotlin.d.ts +2 -0
  40. package/dist/languages/kotlin.js +368 -0
  41. package/dist/languages/lua.d.ts +2 -0
  42. package/dist/languages/lua.js +212 -0
  43. package/dist/languages/ocaml.d.ts +2 -0
  44. package/dist/languages/ocaml.js +291 -0
  45. package/dist/languages/perl.d.ts +2 -0
  46. package/dist/languages/perl.js +418 -0
  47. package/dist/languages/php.d.ts +2 -0
  48. package/dist/languages/php.js +397 -0
  49. package/dist/languages/python.d.ts +2 -0
  50. package/dist/languages/python.js +376 -0
  51. package/dist/languages/registry.d.ts +7 -0
  52. package/dist/languages/registry.js +69 -0
  53. package/dist/languages/ruby.d.ts +2 -0
  54. package/dist/languages/ruby.js +391 -0
  55. package/dist/languages/rust.d.ts +2 -0
  56. package/dist/languages/rust.js +261 -0
  57. package/dist/languages/scala.d.ts +2 -0
  58. package/dist/languages/scala.js +307 -0
  59. package/dist/languages/solidity.d.ts +2 -0
  60. package/dist/languages/solidity.js +240 -0
  61. package/dist/languages/swift.d.ts +2 -0
  62. package/dist/languages/swift.js +268 -0
  63. package/dist/languages/types.d.ts +36 -0
  64. package/dist/languages/types.js +74 -0
  65. package/dist/languages/typescript-contracts.d.ts +57 -0
  66. package/dist/languages/typescript-contracts.js +528 -0
  67. package/dist/languages/typescript-dispatch.d.ts +68 -0
  68. package/dist/languages/typescript-dispatch.js +710 -0
  69. package/dist/languages/typescript.d.ts +4 -0
  70. package/dist/languages/typescript.js +722 -0
  71. package/dist/languages/zig.d.ts +2 -0
  72. package/dist/languages/zig.js +243 -0
  73. package/dist/loc.d.ts +17 -0
  74. package/dist/loc.js +34 -0
  75. package/dist/reach.d.ts +17 -0
  76. package/dist/reach.js +65 -0
  77. package/dist/render.d.ts +18 -0
  78. package/dist/render.js +83 -0
  79. package/dist/review/brand.d.ts +8 -0
  80. package/dist/review/brand.js +25 -0
  81. package/dist/review/call-context.d.ts +27 -0
  82. package/dist/review/call-context.js +446 -0
  83. package/dist/review/call-flow-html.d.ts +32 -0
  84. package/dist/review/call-flow-html.js +1870 -0
  85. package/dist/review/call-flow-nav.d.ts +151 -0
  86. package/dist/review/call-flow-nav.js +317 -0
  87. package/dist/review/call-flow.d.ts +47 -0
  88. package/dist/review/call-flow.js +229 -0
  89. package/dist/review/change-facts.d.ts +69 -0
  90. package/dist/review/change-facts.js +729 -0
  91. package/dist/review/cli.d.ts +2 -0
  92. package/dist/review/cli.js +50 -0
  93. package/dist/review/connected-analysis.d.ts +100 -0
  94. package/dist/review/connected-analysis.js +163 -0
  95. package/dist/review/connected-html.d.ts +17 -0
  96. package/dist/review/connected-html.js +2853 -0
  97. package/dist/review/connected.d.ts +23 -0
  98. package/dist/review/connected.js +141 -0
  99. package/dist/review/escape-html.d.ts +2 -0
  100. package/dist/review/escape-html.js +9 -0
  101. package/dist/review/evidence-html.d.ts +21 -0
  102. package/dist/review/evidence-html.js +521 -0
  103. package/dist/review/evidence-syntax.d.ts +132 -0
  104. package/dist/review/evidence-syntax.js +478 -0
  105. package/dist/review/evidence-types.d.ts +62 -0
  106. package/dist/review/evidence-types.js +1 -0
  107. package/dist/review/evidence.d.ts +31 -0
  108. package/dist/review/evidence.js +1603 -0
  109. package/dist/review/file-role.d.ts +9 -0
  110. package/dist/review/file-role.js +29 -0
  111. package/dist/review/github.d.ts +204 -0
  112. package/dist/review/github.js +1245 -0
  113. package/dist/review/history.d.ts +101 -0
  114. package/dist/review/history.js +412 -0
  115. package/dist/review/html.d.ts +34 -0
  116. package/dist/review/html.js +1104 -0
  117. package/dist/review/input.d.ts +10 -0
  118. package/dist/review/input.js +113 -0
  119. package/dist/review/intent.d.ts +4 -0
  120. package/dist/review/intent.js +75 -0
  121. package/dist/review/mcp-cli.d.ts +2 -0
  122. package/dist/review/mcp-cli.js +25 -0
  123. package/dist/review/mcp.d.ts +12 -0
  124. package/dist/review/mcp.js +414 -0
  125. package/dist/review/module-resolution.d.ts +2 -0
  126. package/dist/review/module-resolution.js +86 -0
  127. package/dist/review/palette.d.ts +7 -0
  128. package/dist/review/palette.js +104 -0
  129. package/dist/review/pipeline.d.ts +77 -0
  130. package/dist/review/pipeline.js +227 -0
  131. package/dist/review/pr-input.d.ts +19 -0
  132. package/dist/review/pr-input.js +130 -0
  133. package/dist/review/questions.d.ts +201 -0
  134. package/dist/review/questions.js +174 -0
  135. package/dist/review/reference-check.d.ts +7 -0
  136. package/dist/review/reference-check.js +733 -0
  137. package/dist/review/report-pages.d.ts +109 -0
  138. package/dist/review/report-pages.js +328 -0
  139. package/dist/review/service.d.ts +23 -0
  140. package/dist/review/service.js +198 -0
  141. package/dist/review/setup.d.ts +112 -0
  142. package/dist/review/setup.js +549 -0
  143. package/dist/review/source.d.ts +26 -0
  144. package/dist/review/source.js +276 -0
  145. package/dist/review/toml.d.ts +38 -0
  146. package/dist/review/toml.js +565 -0
  147. package/dist/review/types.d.ts +179 -0
  148. package/dist/review/types.js +1 -0
  149. package/dist/run.d.ts +49 -0
  150. package/dist/run.js +311 -0
  151. package/dist/types.d.ts +366 -0
  152. package/dist/types.js +83 -0
  153. package/package.json +88 -0
  154. package/scripts/ensure-native-grammar.mjs +188 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Loopback pages for static review reports, owned by one MCP connection.
3
+ *
4
+ * A static review returns its report to the agent as data; this serves the same
5
+ * report as the self-contained HTML page for the human reviewer. The page is
6
+ * read-only: there is no API, nothing is written to disk, and the only route is
7
+ * `GET /report/<token>`, where the token is 256 random bits per report. Reports
8
+ * embed source code, so the listener binds 127.0.0.1 only, rejects any request
9
+ * whose Host is not this exact origin, and forbids caching, framing, and every
10
+ * resource the page does not carry inline. The inline script and stylesheet are
11
+ * allowed by their SHA-256 hashes, so the report HTML is served byte for byte.
12
+ */
13
+ import type { ReviewReport, SuggestedComment } from "./types.js";
14
+ /** Most reports one connection keeps; the oldest page closes first. */
15
+ export declare const MAX_REPORT_PAGES = 20;
16
+ /** Most comments one review may carry from the agent: a reviewer's handful, not a lint dump. */
17
+ export declare const MAX_SUGGESTED_COMMENTS = 30;
18
+ /** Longest suggested comment: a sentence or two, the way a reviewer writes one. */
19
+ export declare const MAX_SUGGESTED_CHARS = 280;
20
+ /** CSP naming exactly the inline script and style blocks of one page. */
21
+ export declare function reportPolicy(html: string): string;
22
+ /** A static review published on this connection. */
23
+ export interface PublishedReview {
24
+ readonly reviewId: string;
25
+ readonly url: string;
26
+ }
27
+ /** One answer as the agent sent it; validated against the question before it is kept. */
28
+ export interface AnswerInput {
29
+ readonly questionId: string;
30
+ readonly choice: string;
31
+ }
32
+ export interface RecordedAnswers {
33
+ readonly reviewId: string;
34
+ readonly recorded: number;
35
+ readonly answered: number;
36
+ readonly unanswered: number;
37
+ }
38
+ export interface RecordedOrder {
39
+ readonly reviewId: string;
40
+ readonly ordered: number;
41
+ }
42
+ export interface RecordedComments {
43
+ readonly reviewId: string;
44
+ readonly suggested: number;
45
+ }
46
+ /** Everything the reviewing agent owes a review before its pages are handed out. */
47
+ export interface FinishInput {
48
+ readonly answers: readonly AnswerInput[];
49
+ readonly order: readonly string[];
50
+ readonly comments: readonly SuggestedComment[];
51
+ }
52
+ export interface FinishedReview {
53
+ readonly reviewId: string;
54
+ readonly answered: number;
55
+ readonly ordered: number;
56
+ readonly suggested: number;
57
+ readonly reportUrl: string;
58
+ }
59
+ export declare class ReportPages {
60
+ private readonly render;
61
+ private readonly pages;
62
+ private readonly tokens;
63
+ constructor(render?: (report: ReviewReport) => string);
64
+ private listening;
65
+ private closed;
66
+ /** Serve one report page and return its URL. */
67
+ add(html: string): Promise<string>;
68
+ /** Serve a static review's page, keeping the report so answers can be recorded. */
69
+ publish(report: ReviewReport): Promise<PublishedReview>;
70
+ /**
71
+ * 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
74
+ * anything is kept, so one gap or bad entry refuses the call and changes
75
+ * nothing. Only a finished review's page addresses are handed out: an agent
76
+ * cannot give the human a page it has not finished reading.
77
+ */
78
+ finish(reviewId: string, input: FinishInput, by: string): FinishedReview;
79
+ /** Whether finish_review accepted this review, so its addresses may be handed out again. */
80
+ isFinished(reviewId: string): boolean;
81
+ /**
82
+ * Update the answers of a review. Every answer is checked before any is kept
83
+ * — a question this review asked, one of that question's options, each
84
+ * question at most once per call — so a call with one bad answer changes
85
+ * nothing. A later answer replaces an earlier one.
86
+ */
87
+ record(reviewId: string, answers: readonly AnswerInput[], answeredBy: string): RecordedAnswers;
88
+ /**
89
+ * Update the reading order the reviewing agent recommends: the report's items
90
+ * are reordered to it, so every page and the connected analysis list hunks in
91
+ * the agent's order. diffninja's own order is kept beside it; statuses and
92
+ * priorities never change. The order must name every hunk exactly once;
93
+ * anything else refuses the call and keeps the previous order.
94
+ */
95
+ recordOrder(reviewId: string, itemIds: readonly string[], orderedBy: string): RecordedOrder;
96
+ /**
97
+ * Update the line comments the reviewing agent suggests. The human sees them
98
+ * under their lines on the pull request page and adds each to their own
99
+ * review, or not; nothing here posts anything. Any bad comment refuses the
100
+ * call and keeps the previous set; an empty list clears it.
101
+ */
102
+ suggestComments(reviewId: string, comments: readonly SuggestedComment[], suggestedBy: string): RecordedComments;
103
+ private review;
104
+ private rerender;
105
+ private origin;
106
+ /** Stop serving every page. Repeated calls are harmless. */
107
+ close(): Promise<void>;
108
+ private listen;
109
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Loopback pages for static review reports, owned by one MCP connection.
3
+ *
4
+ * A static review returns its report to the agent as data; this serves the same
5
+ * report as the self-contained HTML page for the human reviewer. The page is
6
+ * read-only: there is no API, nothing is written to disk, and the only route is
7
+ * `GET /report/<token>`, where the token is 256 random bits per report. Reports
8
+ * embed source code, so the listener binds 127.0.0.1 only, rejects any request
9
+ * whose Host is not this exact origin, and forbids caching, framing, and every
10
+ * resource the page does not carry inline. The inline script and stylesheet are
11
+ * allowed by their SHA-256 hashes, so the report HTML is served byte for byte.
12
+ */
13
+ import { createHash, randomBytes } from "node:crypto";
14
+ import { createServer } from "node:http";
15
+ import { z } from "zod";
16
+ /** Most reports one connection keeps; the oldest page closes first. */
17
+ export const MAX_REPORT_PAGES = 20;
18
+ /** Most comments one review may carry from the agent: a reviewer's handful, not a lint dump. */
19
+ export const MAX_SUGGESTED_COMMENTS = 30;
20
+ /** Longest suggested comment: a sentence or two, the way a reviewer writes one. */
21
+ export const MAX_SUGGESTED_CHARS = 280;
22
+ const CONTROL_CHARACTERS = /[^\P{Cc}]/u;
23
+ /** Report scaffolding a person would not write in a review comment: "Finding 1:", "Attention -", "**Error**", "## Bug". */
24
+ 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
+ const INLINE_BLOCK = /<(script|style)>([\s\S]*?)<\/\1>/g;
26
+ /** CSP naming exactly the inline script and style blocks of one page. */
27
+ export function reportPolicy(html) {
28
+ const hashes = { script: new Set(), style: new Set() };
29
+ for (const match of html.matchAll(INLINE_BLOCK)) {
30
+ const kind = match[1] === "script" ? "script" : "style";
31
+ hashes[kind].add(`'sha256-${createHash("sha256").update(match[2]).digest("base64")}'`);
32
+ }
33
+ const sources = (set) => (set.size === 0 ? "'none'" : [...set].join(" "));
34
+ return [
35
+ "default-src 'none'",
36
+ `script-src ${sources(hashes.script)}`,
37
+ `style-src ${sources(hashes.style)}`,
38
+ "img-src data:",
39
+ "base-uri 'none'",
40
+ "form-action 'none'",
41
+ "frame-ancestors 'none'",
42
+ ].join("; ");
43
+ }
44
+ /** Every answer names a question this review asked, once, with one of its options. */
45
+ function checkAnswers(report, answers) {
46
+ const questions = new Map(report.questions.map((question) => [question.id, question]));
47
+ const seen = new Set();
48
+ answers.forEach((answer, index) => {
49
+ const question = questions.get(answer.questionId);
50
+ if (question === undefined)
51
+ throw new Error(`answers[${index}] names a question this review did not ask.`);
52
+ if (seen.has(answer.questionId))
53
+ throw new Error(`answers[${index}] answers the same question twice in one call.`);
54
+ if (!question.options.includes(answer.choice)) {
55
+ throw new Error(`answers[${index}] is not one of that question's options: ${question.options.join(", ")}.`);
56
+ }
57
+ seen.add(answer.questionId);
58
+ });
59
+ }
60
+ function applyAnswers(report, answers, answeredBy) {
61
+ const questions = new Map(report.questions.map((question) => [question.id, question]));
62
+ const answeredAt = new Date().toISOString();
63
+ for (const answer of answers)
64
+ questions.get(answer.questionId).answer = { choice: answer.choice, answeredBy, answeredAt };
65
+ }
66
+ /** The order names every hunk of the review exactly once. */
67
+ function checkOrder(report, itemIds) {
68
+ const known = new Set(report.items.map((item) => item.id));
69
+ const seen = new Set();
70
+ itemIds.forEach((id, index) => {
71
+ if (!known.has(id))
72
+ throw new Error(`order[${index}] names a hunk this review does not have.`);
73
+ if (seen.has(id))
74
+ throw new Error(`order[${index}] repeats a hunk; name each hunk once.`);
75
+ seen.add(id);
76
+ });
77
+ const missing = report.items.filter((item) => !seen.has(item.id)).map((item) => item.id);
78
+ if (missing.length > 0)
79
+ throw new Error(`order leaves out ${missing.length} of ${known.size} hunks, starting with ${missing[0]}; name every hunk once.`);
80
+ }
81
+ function applyOrder(report, itemIds, orderedBy) {
82
+ const diffninjaIds = report.agentOrder?.diffninjaIds ?? report.items.map((item) => item.id);
83
+ const position = new Map(itemIds.map((id, index) => [id, index]));
84
+ report.items.sort((a, b) => position.get(a.id) - position.get(b.id));
85
+ report.agentOrder = { itemIds: [...itemIds], orderedBy, orderedAt: new Date().toISOString(), diffninjaIds };
86
+ }
87
+ /** Every comment names a line of the diff, one per line, and reads like the reviewer's own. */
88
+ function checkComments(report, comments) {
89
+ const anchors = new Set();
90
+ for (const item of report.items)
91
+ anchorsOf(item, anchors);
92
+ const seen = new Set();
93
+ comments.forEach((comment, index) => {
94
+ const key = anchorKey(comment.path, comment.side, comment.line);
95
+ if (!anchors.has(key))
96
+ throw new Error(`comments[${index}] names ${comment.path}:${comment.line} (${comment.side}), which is not a line of this review's diff.`);
97
+ if (seen.has(key))
98
+ throw new Error(`comments[${index}] is a second comment on the same line; combine them into one.`);
99
+ const problem = commentProblem(comment.body);
100
+ if (problem !== undefined)
101
+ throw new Error(`comments[${index}] ${problem}.`);
102
+ seen.add(key);
103
+ });
104
+ }
105
+ function applyComments(report, comments, suggestedBy) {
106
+ report.agentComments = {
107
+ comments: comments.map(({ path, line, side, body }) => ({ path, line, side, body: body.trim() })),
108
+ suggestedBy,
109
+ suggestedAt: new Date().toISOString(),
110
+ };
111
+ }
112
+ /** Map key of one commentable line. */
113
+ function anchorKey(path, side, line) {
114
+ return JSON.stringify([path, side, line]);
115
+ }
116
+ /** Every line a comment may anchor to in one hunk: added lines on the new side, removed on the old, context on both. */
117
+ function anchorsOf(item, into) {
118
+ let oldLine = item.oldStart;
119
+ let newLine = item.newStart;
120
+ for (const text of item.diff.split("\n").slice(1)) {
121
+ if (text.startsWith("+"))
122
+ into.add(anchorKey(item.file, "RIGHT", newLine++));
123
+ else if (text.startsWith("-"))
124
+ into.add(anchorKey(item.file, "LEFT", oldLine++));
125
+ else if (text.startsWith(" ")) {
126
+ into.add(anchorKey(item.file, "RIGHT", newLine++));
127
+ into.add(anchorKey(item.file, "LEFT", oldLine++));
128
+ }
129
+ }
130
+ }
131
+ /** Why a suggested comment cannot be offered as the reviewer's own words, or undefined when it can. */
132
+ function commentProblem(body) {
133
+ if (body.trim() === "")
134
+ return "is empty";
135
+ if (/[\r\n]/.test(body))
136
+ return "must be one line of text";
137
+ if (CONTROL_CHARACTERS.test(body))
138
+ return "contains control characters";
139
+ if (body.length > MAX_SUGGESTED_CHARS)
140
+ return `is longer than ${MAX_SUGGESTED_CHARS} characters; say it the way a reviewer would, in a sentence or two`;
141
+ if (REPORT_LABEL.test(body))
142
+ return "reads like a report (a heading, list marker, bold, or a label such as \"Finding 1:\"); write it the way the reviewer would say it";
143
+ return undefined;
144
+ }
145
+ export class ReportPages {
146
+ render;
147
+ pages = new Map();
148
+ tokens = new Map();
149
+ constructor(render = () => "") {
150
+ this.render = render;
151
+ }
152
+ listening;
153
+ closed = false;
154
+ /** Serve one report page and return its URL. */
155
+ async add(html) {
156
+ if (this.closed)
157
+ throw new Error("This MCP connection is shutting down; the report page was not served.");
158
+ const { origin } = await (this.listening ??= this.listen());
159
+ const token = randomBytes(32).toString("hex");
160
+ this.pages.set(token, { html, policy: reportPolicy(html) });
161
+ for (const [oldest, page] of this.pages) {
162
+ if (this.pages.size <= MAX_REPORT_PAGES)
163
+ break;
164
+ this.pages.delete(oldest);
165
+ if (page.reviewId !== undefined)
166
+ this.tokens.delete(page.reviewId);
167
+ }
168
+ return `${origin}/report/${token}`;
169
+ }
170
+ /** Serve a static review's page, keeping the report so answers can be recorded. */
171
+ async publish(report) {
172
+ const url = await this.add(this.render(report));
173
+ const token = url.slice(url.lastIndexOf("/") + 1);
174
+ const reviewId = randomBytes(16).toString("hex");
175
+ const page = this.pages.get(token);
176
+ this.pages.set(token, { ...page, report, reviewId });
177
+ this.tokens.set(reviewId, token);
178
+ return { reviewId, url };
179
+ }
180
+ /**
181
+ * 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
184
+ * anything is kept, so one gap or bad entry refuses the call and changes
185
+ * nothing. Only a finished review's page addresses are handed out: an agent
186
+ * cannot give the human a page it has not finished reading.
187
+ */
188
+ finish(reviewId, input, by) {
189
+ const { token, page, report } = this.review(reviewId);
190
+ checkAnswers(report, input.answers);
191
+ const answeredIds = new Set(input.answers.map((answer) => answer.questionId));
192
+ const unanswered = report.questions.filter((question) => !answeredIds.has(question.id));
193
+ if (unanswered.length > 0) {
194
+ throw new Error(`answers leave out ${unanswered.length} of ${report.questions.length} questions, starting with ${unanswered[0].id}; answer every question, cannot-tell when the code does not settle it.`);
195
+ }
196
+ checkOrder(report, input.order);
197
+ checkComments(report, input.comments);
198
+ applyAnswers(report, input.answers, by);
199
+ applyOrder(report, input.order, by);
200
+ applyComments(report, input.comments, by);
201
+ page.finished = true;
202
+ this.rerender(page, report);
203
+ return {
204
+ reviewId,
205
+ answered: input.answers.length,
206
+ ordered: input.order.length,
207
+ suggested: input.comments.length,
208
+ reportUrl: `${this.origin}/report/${token}`,
209
+ };
210
+ }
211
+ /** Whether finish_review accepted this review, so its addresses may be handed out again. */
212
+ isFinished(reviewId) {
213
+ return this.review(reviewId).page.finished === true;
214
+ }
215
+ /**
216
+ * Update the answers of a review. Every answer is checked before any is kept
217
+ * — a question this review asked, one of that question's options, each
218
+ * question at most once per call — so a call with one bad answer changes
219
+ * nothing. A later answer replaces an earlier one.
220
+ */
221
+ record(reviewId, answers, answeredBy) {
222
+ const { page, report } = this.review(reviewId);
223
+ checkAnswers(report, answers);
224
+ applyAnswers(report, answers, answeredBy);
225
+ this.rerender(page, report);
226
+ const answered = report.questions.filter((question) => question.answer !== undefined).length;
227
+ return { reviewId, recorded: answers.length, answered, unanswered: report.questions.length - answered };
228
+ }
229
+ /**
230
+ * Update the reading order the reviewing agent recommends: the report's items
231
+ * are reordered to it, so every page and the connected analysis list hunks in
232
+ * the agent's order. diffninja's own order is kept beside it; statuses and
233
+ * priorities never change. The order must name every hunk exactly once;
234
+ * anything else refuses the call and keeps the previous order.
235
+ */
236
+ recordOrder(reviewId, itemIds, orderedBy) {
237
+ const { page, report } = this.review(reviewId);
238
+ checkOrder(report, itemIds);
239
+ applyOrder(report, itemIds, orderedBy);
240
+ this.rerender(page, report);
241
+ return { reviewId, ordered: itemIds.length };
242
+ }
243
+ /**
244
+ * Update the line comments the reviewing agent suggests. The human sees them
245
+ * under their lines on the pull request page and adds each to their own
246
+ * review, or not; nothing here posts anything. Any bad comment refuses the
247
+ * call and keeps the previous set; an empty list clears it.
248
+ */
249
+ suggestComments(reviewId, comments, suggestedBy) {
250
+ const { page, report } = this.review(reviewId);
251
+ checkComments(report, comments);
252
+ applyComments(report, comments, suggestedBy);
253
+ this.rerender(page, report);
254
+ return { reviewId, suggested: comments.length };
255
+ }
256
+ review(reviewId) {
257
+ const token = this.tokens.get(reviewId);
258
+ const page = token === undefined ? undefined : this.pages.get(token);
259
+ if (token === undefined || page?.report === undefined) {
260
+ throw new Error("No review with that reviewId on this MCP connection. Reviews last as long as the connection, at most the latest 20.");
261
+ }
262
+ return { token, page, report: page.report };
263
+ }
264
+ rerender(page, report) {
265
+ page.html = this.render(report);
266
+ page.policy = reportPolicy(page.html);
267
+ }
268
+ origin = "";
269
+ /** Stop serving every page. Repeated calls are harmless. */
270
+ async close() {
271
+ this.closed = true;
272
+ this.pages.clear();
273
+ this.tokens.clear();
274
+ const listening = this.listening;
275
+ if (listening === undefined)
276
+ return;
277
+ const { server } = await listening.catch(() => ({ server: undefined }));
278
+ if (server === undefined)
279
+ return;
280
+ await new Promise((resolve) => {
281
+ server.close(() => resolve());
282
+ server.closeAllConnections();
283
+ });
284
+ }
285
+ async listen() {
286
+ let origin = "";
287
+ const server = createServer((req, res) => {
288
+ res.setHeader("Cache-Control", "no-store");
289
+ res.setHeader("X-Content-Type-Options", "nosniff");
290
+ res.setHeader("X-Frame-Options", "DENY");
291
+ res.setHeader("Referrer-Policy", "no-referrer");
292
+ const refuse = (code, message) => {
293
+ res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");
294
+ res.writeHead(code, { "Content-Type": "text/plain; charset=utf-8" });
295
+ res.end(message);
296
+ };
297
+ if (req.headers.host !== new URL(origin).host)
298
+ return refuse(403, "Untrusted Host.");
299
+ if (req.method !== "GET")
300
+ return refuse(405, "Read-only report.");
301
+ const token = /^\/report\/([a-f0-9]{64})$/.exec(req.url ?? "")?.[1];
302
+ const page = token === undefined ? undefined : this.pages.get(token);
303
+ if (page === undefined)
304
+ return refuse(404, "No such report on this connection.");
305
+ res.setHeader("Content-Security-Policy", page.policy);
306
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
307
+ res.end(page.html);
308
+ });
309
+ server.requestTimeout = 15_000;
310
+ server.headersTimeout = 10_000;
311
+ await new Promise((resolve, reject) => {
312
+ server.once("error", reject);
313
+ server.listen(0, "127.0.0.1", () => {
314
+ server.removeListener("error", reject);
315
+ resolve();
316
+ });
317
+ });
318
+ const address = z.object({ port: z.number().int().positive() }).parse(server.address());
319
+ origin = `http://127.0.0.1:${address.port}`;
320
+ this.origin = origin;
321
+ // A close that raced this listen still owns the teardown.
322
+ if (this.closed) {
323
+ await new Promise((resolve) => server.close(() => resolve()));
324
+ throw new Error("This MCP connection is shutting down; the report page was not served.");
325
+ }
326
+ return { server, origin };
327
+ }
328
+ }
@@ -0,0 +1,23 @@
1
+ import type { ReviewOptions, ReviewReport, ReviewUnit } from "./types.js";
2
+ export type ReviewInput = {
3
+ diff: string;
4
+ source: string;
5
+ } | {
6
+ repo: string;
7
+ from: string;
8
+ to: string;
9
+ diff?: string;
10
+ source?: string;
11
+ };
12
+ /** Most whole context nodes one hunk carries in the report. */
13
+ export declare const REPORT_CONTEXT_NODES = 8;
14
+ /** Most call-flow block characters one hunk carries in the report. */
15
+ export declare const REPORT_CALL_FLOW_CHARS = 24000;
16
+ /** Most characters of whole-diff call-flow trees the report carries. */
17
+ export declare const REPORT_CALL_FLOW_TREE_CHARS = 64000;
18
+ /** Keep the highest-priority context of one hunk, whole, and say what was left out. */
19
+ export declare function boundReportContext(unit: ReviewUnit): void;
20
+ /** The whole-diff call-flow trees in engine order, whole, within the report bound. */
21
+ export declare function boundCallFlowTrees(trees: readonly string[]): string[];
22
+ /** Shared report orchestration; transports own input reading and output persistence. */
23
+ export declare function reviewDiff(input: ReviewInput, options?: ReviewOptions): Promise<ReviewReport>;
@@ -0,0 +1,198 @@
1
+ import { resolve } from "node:path";
2
+ import { runDiff } from "../run.js";
3
+ import { readSnapshotFile } from "../git.js";
4
+ import { parseDiff, gitDiff } from "./input.js";
5
+ import { reviewUnits } from "./pipeline.js";
6
+ import { buildCallFlows, reportOrderedTextHunkFiles, CALL_FLOW_MAX_DEPTH } from "./call-flow.js";
7
+ import { buildCallContext } from "./call-context.js";
8
+ import { definitionReader } from "./source.js";
9
+ import { buildReviewEvidence } from "./evidence.js";
10
+ import { crossCheckIntent } from "./intent.js";
11
+ import { checkReferences } from "./reference-check.js";
12
+ import { moduleResolver } from "./module-resolution.js";
13
+ import { reviewQuestions } from "./questions.js";
14
+ import { readProjectContext } from "./history.js";
15
+ /** Context width may differ, but source-enriched patches must describe the same changed lines. */
16
+ function changedLineIdentity(units) {
17
+ const changes = [];
18
+ for (const unit of units) {
19
+ if (unit.special)
20
+ continue;
21
+ let oldLine = unit.oldStart, newLine = unit.newStart;
22
+ for (const line of unit.diff.split("\n").slice(1)) {
23
+ if (line.startsWith("+"))
24
+ changes.push(JSON.stringify([unit.file, "+", newLine++, line.slice(1)]));
25
+ else if (line.startsWith("-"))
26
+ changes.push(JSON.stringify([unit.file, "-", oldLine++, line.slice(1)]));
27
+ else if (line.startsWith(" ")) {
28
+ oldLine++;
29
+ newLine++;
30
+ }
31
+ }
32
+ }
33
+ return changes.sort().join("\n");
34
+ }
35
+ /** Most whole context nodes one hunk carries in the report. */
36
+ export const REPORT_CONTEXT_NODES = 8;
37
+ /** Most call-flow block characters one hunk carries in the report. */
38
+ export const REPORT_CALL_FLOW_CHARS = 24_000;
39
+ /** Most characters of whole-diff call-flow trees the report carries. */
40
+ export const REPORT_CALL_FLOW_TREE_CHARS = 64_000;
41
+ /** Leading blocks, whole, within `limit` characters; always at least the first. */
42
+ function keepWithin(blocks, limit) {
43
+ const kept = [];
44
+ let used = 0;
45
+ for (const block of blocks) {
46
+ if (used + block.length > limit && kept.length > 0)
47
+ break;
48
+ kept.push(block);
49
+ used += block.length;
50
+ }
51
+ return { kept, omitted: blocks.length - kept.length };
52
+ }
53
+ /** Keep the highest-priority context of one hunk, whole, and say what was left out. */
54
+ export function boundReportContext(unit) {
55
+ if (unit.contextNodes !== undefined && unit.contextNodes.length > REPORT_CONTEXT_NODES) {
56
+ unit.contextNodes = unit.contextNodes.slice(0, REPORT_CONTEXT_NODES);
57
+ }
58
+ if (unit.callFlow === undefined)
59
+ return;
60
+ const { kept, omitted } = keepWithin(unit.callFlow, REPORT_CALL_FLOW_CHARS);
61
+ if (omitted > 0)
62
+ kept.push(`omitted call-flow blocks=${omitted} reason=report-size-limit chars=${REPORT_CALL_FLOW_CHARS}`);
63
+ unit.callFlow = kept;
64
+ }
65
+ /** The whole-diff call-flow trees in engine order, whole, within the report bound. */
66
+ export function boundCallFlowTrees(trees) {
67
+ const { kept, omitted } = keepWithin(trees, REPORT_CALL_FLOW_TREE_CHARS);
68
+ // A single tree over the bound keeps its leading lines: the root and nearest calls.
69
+ const first = kept[0];
70
+ if (first !== undefined && first.length > REPORT_CALL_FLOW_TREE_CHARS) {
71
+ const lines = first.split("\n");
72
+ const head = keepWithin(lines.map((line) => `${line}\n`), REPORT_CALL_FLOW_TREE_CHARS);
73
+ kept[0] = `${head.kept.join("")}omitted tree lines=${head.omitted} reason=report-size-limit chars=${REPORT_CALL_FLOW_TREE_CHARS}`;
74
+ }
75
+ if (omitted > 0)
76
+ kept.push(`omitted call-flow trees=${omitted} reason=report-size-limit chars=${REPORT_CALL_FLOW_TREE_CHARS}`);
77
+ return kept;
78
+ }
79
+ /** Shared report orchestration; transports own input reading and output persistence. */
80
+ export async function reviewDiff(input, options = {}) {
81
+ const warnings = [];
82
+ const cwd = "repo" in input ? resolve(input.repo) : undefined;
83
+ const snapshots = "repo" in input ? gitDiff(cwd, input.from, input.to) : undefined;
84
+ const text = input.diff ?? snapshots.diff;
85
+ const source = input.source ?? ("repo" in input
86
+ ? `${input.from} → ${input.to} (${snapshots.from.slice(0, 8)} → ${snapshots.to.slice(0, 8)})`
87
+ : "Patch");
88
+ if (!snapshots)
89
+ warnings.push("Patch-only review. Full files and repository call flows are unavailable.");
90
+ const units = parseDiff(text);
91
+ if (snapshots && input.diff !== undefined && changedLineIdentity(units) !== changedLineIdentity(parseDiff(snapshots.diff))) {
92
+ throw new Error("The supplied patch does not match the immutable repository range; refusing mismatched source evidence.");
93
+ }
94
+ if (snapshots && options.pr?.headRef !== undefined && options.pr.headRef !== snapshots.to) {
95
+ throw new Error("The PR head does not match the source snapshot; refusing mismatched intent evidence.");
96
+ }
97
+ let evidence = buildReviewEvidence(units);
98
+ const callFlow = [];
99
+ let trees = [];
100
+ // Definition source comes from the snapshot the definition resolved in: the
101
+ // `to` revision, except for a removed call, whose only definition is the
102
+ // `from` one. Definitions outside the diff, callers and callees alike, are
103
+ // read the same way. Unreadable definitions stay absent rather than guessed.
104
+ // One reader serves the structured call flows and the keyed node context, so
105
+ // each snapshot file is read once per revision.
106
+ let nodeDetail;
107
+ let contextSources = {};
108
+ if (snapshots) {
109
+ const readDefinition = definitionReader(cwd);
110
+ const from = { kind: "commit", ref: snapshots.from };
111
+ const to = { kind: "commit", ref: snapshots.to };
112
+ nodeDetail = (node) => {
113
+ const definition = node.definition;
114
+ if (!definition)
115
+ return {};
116
+ return readDefinition(definition, node.status === "removed" ? from : to);
117
+ };
118
+ // A node carries its definition whole or not at all, so an unreadable
119
+ // span yields no source rather than a shorter one.
120
+ contextSources = {
121
+ before: definition => readDefinition(definition, from).source?.text ?? null,
122
+ after: definition => readDefinition(definition, to).source?.text ?? null,
123
+ };
124
+ }
125
+ // A patch carries no repository, so the only honest answer is that call flows
126
+ // need a git range. Every other value is decided by what analysis returned.
127
+ let callFlowAvailability = snapshots ? "no-changes" : "needs-git-range";
128
+ if (snapshots && units.length) {
129
+ try {
130
+ const flow = runDiff({
131
+ cwd: cwd, from: snapshots.from, to: snapshots.to, maxDepth: CALL_FLOW_MAX_DEPTH, color: false, locs: true,
132
+ onIndexes(before, after) {
133
+ const context = buildCallContext(units, before, after, contextSources);
134
+ for (const unit of units) {
135
+ const entry = context.get(unit.id);
136
+ // Absent, not empty: a hunk with no context carries neither field,
137
+ // exactly as the report treated a hunk with no blocks before.
138
+ unit.callFlow = entry === undefined || entry.entries.length === 0 ? undefined : entry.entries;
139
+ unit.contextNodes = entry === undefined || entry.nodes.length === 0 ? undefined : entry.nodes;
140
+ }
141
+ evidence = buildReviewEvidence(units, {
142
+ before, after, sources: contextSources, baseRef: snapshots.from, headRef: snapshots.to,
143
+ resolveImport: moduleResolver(file => readSnapshotFile(cwd, { kind: "commit", ref: snapshots.to }, file)),
144
+ });
145
+ },
146
+ });
147
+ trees = flow.trees;
148
+ // One tree can reach thousands of callers (a neovim hunk made 5.7 MB); the
149
+ // agent gets the leading trees whole, the structured flows stay bounded.
150
+ callFlow.push(...boundCallFlowTrees(trees.map(tree => tree.ascii)));
151
+ warnings.push("Call flows are syntactic, not a type checker. Dynamic calls and parse failures may be absent. An empty flow is not evidence of safety.");
152
+ }
153
+ catch {
154
+ for (const unit of units) {
155
+ delete unit.callFlow;
156
+ delete unit.contextNodes;
157
+ }
158
+ callFlowAvailability = "failed";
159
+ evidence = buildReviewEvidence(units);
160
+ warnings.push("Call-flow analysis failed. Review is based on the diff only. Inspect repository context manually.");
161
+ }
162
+ }
163
+ if (options.referenceProject !== undefined) {
164
+ if (!snapshots)
165
+ throw new Error("Reference checking requires a repository and immutable git range.");
166
+ const references = await checkReferences(cwd, snapshots.from, snapshots.to, units, options.referenceProject);
167
+ evidence.findings.push(...references.findings);
168
+ evidence.agenda.unshift(...references.findings.map(finding => ({
169
+ id: `agenda-${finding.id}`, title: "Check the newly unresolved reference",
170
+ reason: finding.title, priority: 100, unitIds: finding.unitIds,
171
+ findingIds: [finding.id], evidence: finding.evidence, context: [],
172
+ })));
173
+ evidence.checks = evidence.checks.map(check => check.kind === "broken-reference" ? references.check : check);
174
+ }
175
+ // Analysis above used every context node; the report carries a bounded share of
176
+ // it per hunk, so a change to a widely used type cannot produce a report of tens
177
+ // of megabytes (one real hunk had thousands of callers).
178
+ for (const unit of units)
179
+ boundReportContext(unit);
180
+ // History, guidelines, and conventions come from the local repository only.
181
+ const project = snapshots
182
+ ? readProjectContext(cwd, snapshots.from, snapshots.to, units, options.pr?.title ?? "")
183
+ : undefined;
184
+ const result = reviewUnits(units);
185
+ // Structured flows are grouped per changed file in report order, after
186
+ // ranking, so the HTML can order files by the severity of their worst hunk.
187
+ const callFlows = buildCallFlows(reportOrderedTextHunkFiles(result.items, units), trees, nodeDetail);
188
+ if (callFlows.length > 0)
189
+ callFlowAvailability = "available";
190
+ const report = { title: options.pr?.title || "Focused PR review", source, createdAt: new Date().toISOString(),
191
+ pr: options.pr,
192
+ evidence: { ...evidence, intent: crossCheckIntent(options.pr, units, evidence.agenda, evidence.findings) },
193
+ ...result, callFlow, callFlows, callFlowAvailability, warnings: [...warnings, ...result.warnings],
194
+ questions: reviewQuestions(result.items, options.pr, project) };
195
+ if (project !== undefined)
196
+ report.project = project;
197
+ return report;
198
+ }