diffninja 0.3.1 → 0.4.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 +65 -11
- package/dist/executables.d.ts +18 -0
- package/dist/executables.js +32 -0
- package/dist/git.d.ts +26 -1
- package/dist/git.js +56 -4
- package/dist/languages/child-env.d.ts +11 -0
- package/dist/languages/child-env.js +60 -0
- package/dist/languages/grammar-lock.d.ts +569 -0
- package/dist/languages/grammar-lock.js +574 -0
- package/dist/languages/grammars.d.ts +69 -9
- package/dist/languages/grammars.js +186 -119
- package/dist/review/call-flow-html.d.ts +3 -1
- package/dist/review/call-flow-html.js +13 -11
- package/dist/review/change-facts.d.ts +21 -1
- package/dist/review/change-facts.js +271 -49
- package/dist/review/cli.js +13 -1
- package/dist/review/connected-analysis.d.ts +4 -1
- package/dist/review/connected-analysis.js +4 -2
- package/dist/review/connected-html.d.ts +15 -4
- package/dist/review/connected-html.js +342 -35
- package/dist/review/connected.js +47 -14
- package/dist/review/escape-html.d.ts +5 -1
- package/dist/review/escape-html.js +7 -2
- package/dist/review/explanation.d.ts +4 -0
- package/dist/review/explanation.js +6 -1
- package/dist/review/github.d.ts +56 -0
- package/dist/review/github.js +234 -33
- package/dist/review/grammars-command.d.ts +12 -0
- package/dist/review/grammars-command.js +60 -0
- package/dist/review/hidden-characters.d.ts +31 -0
- package/dist/review/hidden-characters.js +113 -0
- package/dist/review/history.js +7 -3
- package/dist/review/html.d.ts +3 -2
- package/dist/review/html.js +19 -18
- package/dist/review/input.js +5 -2
- package/dist/review/intent.d.ts +7 -0
- package/dist/review/intent.js +25 -3
- package/dist/review/markdown.js +11 -0
- package/dist/review/mcp-cli.js +4 -1
- package/dist/review/mcp.d.ts +7 -1
- package/dist/review/mcp.js +145 -59
- package/dist/review/pipeline.d.ts +2 -1
- package/dist/review/pipeline.js +4 -3
- package/dist/review/pr-input.d.ts +7 -0
- package/dist/review/pr-input.js +25 -3
- package/dist/review/process-html.d.ts +1 -5
- package/dist/review/process-html.js +3 -12
- package/dist/review/questions.js +11 -3
- package/dist/review/reference-check.d.ts +5 -1
- package/dist/review/reference-check.js +40 -16
- package/dist/review/report-pages.d.ts +24 -9
- package/dist/review/report-pages.js +111 -28
- package/dist/review/result-budget.d.ts +28 -0
- package/dist/review/result-budget.js +136 -0
- package/dist/review/service.js +26 -2
- package/dist/review/setup.d.ts +1 -1
- package/dist/review/setup.js +9 -4
- package/dist/review/types.d.ts +19 -5
- package/dist/review/types.js +2 -1
- package/dist/review/update-check.d.ts +35 -0
- package/dist/review/update-check.js +76 -0
- package/dist/run.js +11 -5
- package/npm-shrinkwrap.json +3483 -0
- package/package.json +3 -2
package/dist/review/html.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { hiddenControlsIn } from "./hidden-characters.js";
|
|
1
2
|
import { verdictOf } from "./questions.js";
|
|
2
|
-
import { factQuestionsFor } from "./change-facts.js";
|
|
3
|
+
import { factQuestionsFor, unreadCause } from "./change-facts.js";
|
|
3
4
|
import { renderCallFlows, CALL_FLOW_STYLES, CALL_FLOW_SCRIPT } from "./call-flow-html.js";
|
|
4
5
|
import { renderBrief, BRIEF_STYLES } from "./evidence-html.js";
|
|
5
6
|
import { renderBusinessView, BUSINESS_STYLES } from "./process-html.js";
|
|
@@ -110,7 +111,6 @@ const BUSINESS_VIEW_STYLES = `
|
|
|
110
111
|
.business { padding-top: 14px; }
|
|
111
112
|
.business-title { font-size: 20px; }
|
|
112
113
|
.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
114
|
`;
|
|
115
115
|
/**
|
|
116
116
|
* Posts the document's height to the page that frames it, so the pull request
|
|
@@ -122,7 +122,9 @@ const BUSINESS_FRAME_SCRIPT = `
|
|
|
122
122
|
'use strict';
|
|
123
123
|
function post() {
|
|
124
124
|
if (window.parent === window) return;
|
|
125
|
-
|
|
125
|
+
// The html element's own box, not scrollHeight: scrollHeight never drops below the frame's
|
|
126
|
+
// height, so the frame could grow but never shrink back around its content.
|
|
127
|
+
window.parent.postMessage({ type: 'diffninja-business-height', height: Math.ceil(document.documentElement.getBoundingClientRect().height) }, window.location.origin);
|
|
126
128
|
}
|
|
127
129
|
window.addEventListener('load', post);
|
|
128
130
|
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(post).observe(document.body);
|
|
@@ -173,8 +175,9 @@ body { background: var(--bg); font-family: var(--sans); }
|
|
|
173
175
|
/**
|
|
174
176
|
* The call flows of one review as a page of their own, for the connected pull
|
|
175
177
|
* request page to show beside its diff: every changed file with call flows, or
|
|
176
|
-
* only `file` when given.
|
|
177
|
-
*
|
|
178
|
+
* only `file` when given. Only the Tree view is offered, and the business view
|
|
179
|
+
* stays out of it: the page around it already shows that view. Links into the
|
|
180
|
+
* report's diff are dropped, since the page showing this has its own.
|
|
178
181
|
*/
|
|
179
182
|
export function renderCallFlowPage(report, file) {
|
|
180
183
|
const scoped = file === undefined ? report : { ...report, callFlows: report.callFlows.filter((entry) => entry.file === file) };
|
|
@@ -186,20 +189,11 @@ export function renderCallFlowPage(report, file) {
|
|
|
186
189
|
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
187
190
|
'<meta name="color-scheme" content="light dark">',
|
|
188
191
|
`<title>${escapeHtml(file === undefined ? "Call flows" : `Call flow: ${file}`)}</title>`,
|
|
189
|
-
`<style>${STYLES}\n${CALL_FLOW_STYLES}\n${PALETTE_STYLES}\n${
|
|
192
|
+
`<style>${STYLES}\n${CALL_FLOW_STYLES}\n${PALETTE_STYLES}\n${EMBEDDED_FLOW_STYLES}</style>`,
|
|
190
193
|
"</head>",
|
|
191
194
|
"<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"),
|
|
200
195
|
`<section id="view-call-flow" class="flow-embed${file === undefined ? "" : " flow-single"}" aria-label="Call flow">`,
|
|
201
|
-
|
|
202
|
-
renderCallFlows(scoped),
|
|
196
|
+
renderCallFlows(scoped, { treeOnly: true }),
|
|
203
197
|
"</section>",
|
|
204
198
|
`<script>document.documentElement.classList.add('js');\n${CALL_FLOW_SCRIPT}</script>`,
|
|
205
199
|
"</body>",
|
|
@@ -253,12 +247,17 @@ function renderHeader(report) {
|
|
|
253
247
|
`<p class="meta">Files changed · <span class="mono">${escapeHtml(report.source)}</span></p>`,
|
|
254
248
|
scope === "" ? "" : `<p class="meta">${escapeHtml(scope)}</p>`,
|
|
255
249
|
`<p class="mode-note">${mode}</p>`,
|
|
250
|
+
report.updateNotice === undefined ? "" : renderUpdateNotice(report.updateNotice),
|
|
256
251
|
renderLegend(),
|
|
257
252
|
"</header>",
|
|
258
253
|
]
|
|
259
254
|
.filter((part) => part !== "")
|
|
260
255
|
.join("\n");
|
|
261
256
|
}
|
|
257
|
+
/** One line, above the legend: a newer diffninja exists and the command that installs it. */
|
|
258
|
+
function renderUpdateNotice(notice) {
|
|
259
|
+
return `<p class="update-notice" role="status">diffninja ${escapeHtml(notice.latest)} is available (you have ${escapeHtml(notice.current)}). Update by running <code>${escapeHtml(notice.command)}</code> in a terminal, then restart your agent.</p>`;
|
|
260
|
+
}
|
|
262
261
|
/** One color line, no jargon. Counting is left to the filter chips. */
|
|
263
262
|
function renderLegend() {
|
|
264
263
|
const keys = STATUS_ORDER.map((status) => `<span class="key"><span class="dot dot-${status}" aria-hidden="true"></span>${escapeHtml(`${STATUS_LABEL[status]}: ${STATUS_HINT[status]}`)}</span>`).join("");
|
|
@@ -465,7 +464,7 @@ function renderFacts(item) {
|
|
|
465
464
|
const notes = [];
|
|
466
465
|
let list = "";
|
|
467
466
|
if (facts.language === null) {
|
|
468
|
-
notes.push(
|
|
467
|
+
notes.push(`diffninja does not read ${unreadCause(item.file)}, so no facts were established. Read this hunk yourself.`);
|
|
469
468
|
}
|
|
470
469
|
else {
|
|
471
470
|
list = factQuestionsFor(facts.language).map((question) => {
|
|
@@ -559,7 +558,7 @@ function renderDiff(diff) {
|
|
|
559
558
|
.map((row) => `<span class="ln ln-${row.kind}">` +
|
|
560
559
|
`<span class="old-no" aria-hidden="true">${row.oldNo === null ? "" : String(row.oldNo)}</span>` +
|
|
561
560
|
`<span class="new-no" aria-hidden="true">${row.newNo === null ? "" : String(row.newNo)}</span>` +
|
|
562
|
-
`<span class="code">${escapeHtml(row.text)}</span>` +
|
|
561
|
+
`<span class="code${hiddenControlsIn(row.text).length > 0 ? " code-hidden" : ""}">${escapeHtml(row.text)}</span>` +
|
|
563
562
|
"</span>")
|
|
564
563
|
.join("");
|
|
565
564
|
return [
|
|
@@ -964,6 +963,7 @@ a { color: var(--teal); }
|
|
|
964
963
|
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
|
|
965
964
|
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
|
|
966
965
|
}
|
|
966
|
+
.update-notice { padding: 8px 12px; border-left: 3px solid var(--warn); background: var(--warn-bg); border-radius: 4px; font-size: 14px; }
|
|
967
967
|
.masthead {
|
|
968
968
|
display: flex;
|
|
969
969
|
flex-direction: column;
|
|
@@ -1154,6 +1154,7 @@ pre.diff {
|
|
|
1154
1154
|
user-select: none;
|
|
1155
1155
|
}
|
|
1156
1156
|
.code { padding: 0 14px; }
|
|
1157
|
+
.code-hidden { outline: 1px solid var(--warn); outline-offset: -1px; background: var(--warn-bg); }
|
|
1157
1158
|
.ln-add > .code { color: var(--add); }
|
|
1158
1159
|
.ln-del > .code { color: var(--del); }
|
|
1159
1160
|
.ln-add > .code, .ln-add > .old-no, .ln-add > .new-no { background: var(--add-bg); }
|
package/dist/review/input.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveExecutable } from "../executables.js";
|
|
1
2
|
import { execFileSync } from "node:child_process";
|
|
2
3
|
function pathName(raw) {
|
|
3
4
|
const value = raw.split("\t")[0];
|
|
@@ -103,11 +104,13 @@ export function parseDiff(text) {
|
|
|
103
104
|
throw new Error("No reviewable changes found in input.");
|
|
104
105
|
return units;
|
|
105
106
|
}
|
|
107
|
+
/** A git command that has not answered in this long (a dead network share) is stopped, not waited for forever. */
|
|
108
|
+
const GIT_TIMEOUT_MS = 120_000;
|
|
106
109
|
export function resolveCommit(cwd, ref) {
|
|
107
|
-
return execFileSync("git", ["--no-replace-objects", "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], { cwd, encoding: "utf8" }).trim();
|
|
110
|
+
return execFileSync(resolveExecutable("git"), ["--no-replace-objects", "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], { cwd, encoding: "utf8", timeout: GIT_TIMEOUT_MS }).trim();
|
|
108
111
|
}
|
|
109
112
|
export function gitDiff(cwd, from, to) {
|
|
110
113
|
const base = resolveCommit(cwd, from), head = resolveCommit(cwd, to);
|
|
111
|
-
const diff = execFileSync("git", ["--no-replace-objects", "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=5", base, head, "--"], { cwd, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
|
114
|
+
const diff = execFileSync(resolveExecutable("git"), ["--no-replace-objects", "-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=5", base, head, "--"], { cwd, encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
112
115
|
return { diff, from: base, to: head };
|
|
113
116
|
}
|
package/dist/review/intent.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { IntentCrossCheck, PullRequestIntent, ReviewAgendaEntry, AutomaticFinding } from "./evidence-types.js";
|
|
2
2
|
import type { ReviewUnit } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Whether a line opens an HTML comment and then, on the same line, matches `after`: the regex
|
|
5
|
+
* `<!--.*after` in linear time. That regex rescanned the rest of the line from every `<!--`, so
|
|
6
|
+
* a pull request body of 65,000 characters of them took seconds. `.` stops at a line terminator, so
|
|
7
|
+
* each terminator-free stretch is read from its first `<!--`, which leaves the most text to match.
|
|
8
|
+
*/
|
|
9
|
+
export declare function commentThen(line: string, after: RegExp): boolean;
|
|
3
10
|
/** Links are discovery aids, never a semantic entailment or a passing test. */
|
|
4
11
|
export declare function crossCheckIntent(pr: PullRequestIntent | undefined, units: readonly ReviewUnit[], agenda: readonly ReviewAgendaEntry[], findings: readonly AutomaticFinding[]): IntentCrossCheck;
|
package/dist/review/intent.js
CHANGED
|
@@ -2,6 +2,28 @@ const STOP_WORDS = Object.fromEntries("a about after all also an and any are as
|
|
|
2
2
|
function terms(text) {
|
|
3
3
|
return new Set(text.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().match(/[a-z][a-z0-9]+/g)?.filter(word => !Object.hasOwn(STOP_WORDS, word)).map(word => word.replace(/(?:ing|ies|s)$/u, "")).filter(word => word.length > 2) ?? []);
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* Statements read from a pull request's text. The text is written by whoever
|
|
7
|
+
* opened the pull request: a 65,000-character body of short lines produced
|
|
8
|
+
* 10,874 claims, a 2.25 MB result and one comparison against every hunk each.
|
|
9
|
+
*/
|
|
10
|
+
const MAX_CLAIMS = 100;
|
|
11
|
+
/**
|
|
12
|
+
* Whether a line opens an HTML comment and then, on the same line, matches `after`: the regex
|
|
13
|
+
* `<!--.*after` in linear time. That regex rescanned the rest of the line from every `<!--`, so
|
|
14
|
+
* a pull request body of 65,000 characters of them took seconds. `.` stops at a line terminator, so
|
|
15
|
+
* each terminator-free stretch is read from its first `<!--`, which leaves the most text to match.
|
|
16
|
+
*/
|
|
17
|
+
export function commentThen(line, after) {
|
|
18
|
+
for (const stretch of line.split(/[\r\u2028\u2029]/u)) {
|
|
19
|
+
const open = stretch.indexOf("<!--");
|
|
20
|
+
if (open !== -1 && after.test(stretch.slice(open + 4)))
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const GENERATED_COMMENT = /auto-generated|release notes/iu;
|
|
26
|
+
const END_OF_GENERATED = /end of auto-generated/iu;
|
|
5
27
|
/** Keep author claims separate from generated release notes; never execute PR text. */
|
|
6
28
|
function statements(pr) {
|
|
7
29
|
const claims = [];
|
|
@@ -12,9 +34,9 @@ function statements(pr) {
|
|
|
12
34
|
let comment = false;
|
|
13
35
|
let relevant = true;
|
|
14
36
|
for (const raw of pr.body.split(/\r?\n/u)) {
|
|
15
|
-
if (
|
|
37
|
+
if (commentThen(raw, GENERATED_COMMENT))
|
|
16
38
|
generatedComment = true;
|
|
17
|
-
if (
|
|
39
|
+
if (commentThen(raw, END_OF_GENERATED)) {
|
|
18
40
|
generatedComment = false;
|
|
19
41
|
generatedHeadingDepth = undefined;
|
|
20
42
|
continue;
|
|
@@ -53,7 +75,7 @@ export function crossCheckIntent(pr, units, agenda, findings) {
|
|
|
53
75
|
obligations: ["Provide the expected behavior, including failure and boundary cases. No intended outcome was inferred from the diff."],
|
|
54
76
|
};
|
|
55
77
|
const indexed = units.map(unit => ({ unit, terms: terms(unit.file + "\n" + unit.diff.split("\n").filter(line => line.startsWith("+")).join("\n")) }));
|
|
56
|
-
const claims = statements(pr).map(claim => {
|
|
78
|
+
const claims = statements(pr).slice(0, MAX_CLAIMS).map(claim => {
|
|
57
79
|
const wanted = [...terms(claim.text)];
|
|
58
80
|
const matches = indexed.map(({ unit, terms: actual }) => ({ unit, count: wanted.reduce((count, term) => count + (actual.has(term) ? 1 : 0), 0) }))
|
|
59
81
|
.filter(({ count }) => count >= Math.min(2, wanted.length) && wanted.length > 0)
|
package/dist/review/markdown.js
CHANGED
|
@@ -55,6 +55,14 @@ const tokensSchema = z.array(tokenSchema);
|
|
|
55
55
|
const MAX_DEPTH = 12;
|
|
56
56
|
/** Nodes one description may become; past this the rest is named, not rendered. */
|
|
57
57
|
const MAX_NODES = 50_000;
|
|
58
|
+
/**
|
|
59
|
+
* The longest description that is lexed. Emphasis resolution in `marked` is
|
|
60
|
+
* quadratic on some short repeated patterns (`_a*` repeated: 16,000 characters
|
|
61
|
+
* took 1.3 s, 65,000 took 19 s, with the whole single-threaded server waiting),
|
|
62
|
+
* so a longer body is shown as plain text instead, which the page already does
|
|
63
|
+
* for a body past the node budget.
|
|
64
|
+
*/
|
|
65
|
+
const MAX_SOURCE_CHARS = 12_000;
|
|
58
66
|
const NAMED_ENTITIES = new Map([
|
|
59
67
|
["amp", "&"], ["lt", "<"], ["gt", ">"], ["quot", "\""], ["apos", "'"], ["nbsp", "\u00a0"],
|
|
60
68
|
["copy", "\u00a9"], ["reg", "\u00ae"], ["trade", "\u2122"], ["hellip", "\u2026"], ["mdash", "\u2014"],
|
|
@@ -324,6 +332,9 @@ let cached;
|
|
|
324
332
|
export function markdownBlocks(source) {
|
|
325
333
|
if (cached !== undefined && cached.source === source)
|
|
326
334
|
return cached.parsed;
|
|
335
|
+
// Past the cap the beginning is kept as plain text, never lexed: `truncated` tells the page to show the body as text.
|
|
336
|
+
if (source.length > MAX_SOURCE_CHARS)
|
|
337
|
+
return { blocks: [{ t: "para", c: [textNode(source.slice(0, MAX_SOURCE_CHARS))] }], truncated: true };
|
|
327
338
|
let parsed;
|
|
328
339
|
try {
|
|
329
340
|
const budget = new Budget();
|
package/dist/review/mcp-cli.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { createReviewServer } from "./mcp.js";
|
|
4
|
+
import { updateLookupFromEnv } from "./update-check.js";
|
|
4
5
|
try {
|
|
5
6
|
if (process.argv.length > 2)
|
|
6
7
|
throw new Error("diffninja-mcp accepts no arguments. Configure it as a stdio MCP server; pass inputs to review_diff.");
|
|
7
|
-
|
|
8
|
+
// Update notices are off unless DIFFNINJA_UPDATE_CHECK=1; nothing else asks the network for anything.
|
|
9
|
+
const latestVersion = updateLookupFromEnv();
|
|
10
|
+
const server = createReviewServer(latestVersion === undefined ? {} : { latestVersion });
|
|
8
11
|
// Closing the connection is what closes its connected review pages, and this
|
|
9
12
|
// transport cannot tell that the client hung up its end of the pipe, so EOF
|
|
10
13
|
// here is what ends the session. A signal still terminates the process the
|
package/dist/review/mcp.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { type LatestVersion } from "./update-check.js";
|
|
2
3
|
export interface AnalysisScope {
|
|
3
4
|
readonly source: "repository" | "patch";
|
|
4
5
|
readonly note: string;
|
|
@@ -9,4 +10,9 @@ export interface AnalysisScope {
|
|
|
9
10
|
* link before anything is loaded, and `static` never navigates a link it finds
|
|
10
11
|
* inside a diff.
|
|
11
12
|
*/
|
|
12
|
-
|
|
13
|
+
/** Options only the executable sets: the library never reaches the network on its own. */
|
|
14
|
+
export interface ReviewServerOptions {
|
|
15
|
+
/** Looks up the newest published diffninja version; leave out to never check. */
|
|
16
|
+
readonly latestVersion?: LatestVersion;
|
|
17
|
+
}
|
|
18
|
+
export declare function createReviewServer(options?: ReviewServerOptions): McpServer;
|