diffninja 0.3.2 → 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.js +1 -0
- 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 +338 -43
- 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.js +11 -3
- 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 -56
- 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/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 -9
- package/dist/review/types.js +2 -2
- 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";
|
|
@@ -246,12 +247,17 @@ function renderHeader(report) {
|
|
|
246
247
|
`<p class="meta">Files changed · <span class="mono">${escapeHtml(report.source)}</span></p>`,
|
|
247
248
|
scope === "" ? "" : `<p class="meta">${escapeHtml(scope)}</p>`,
|
|
248
249
|
`<p class="mode-note">${mode}</p>`,
|
|
250
|
+
report.updateNotice === undefined ? "" : renderUpdateNotice(report.updateNotice),
|
|
249
251
|
renderLegend(),
|
|
250
252
|
"</header>",
|
|
251
253
|
]
|
|
252
254
|
.filter((part) => part !== "")
|
|
253
255
|
.join("\n");
|
|
254
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
|
+
}
|
|
255
261
|
/** One color line, no jargon. Counting is left to the filter chips. */
|
|
256
262
|
function renderLegend() {
|
|
257
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("");
|
|
@@ -458,7 +464,7 @@ function renderFacts(item) {
|
|
|
458
464
|
const notes = [];
|
|
459
465
|
let list = "";
|
|
460
466
|
if (facts.language === null) {
|
|
461
|
-
notes.push(
|
|
467
|
+
notes.push(`diffninja does not read ${unreadCause(item.file)}, so no facts were established. Read this hunk yourself.`);
|
|
462
468
|
}
|
|
463
469
|
else {
|
|
464
470
|
list = factQuestionsFor(facts.language).map((question) => {
|
|
@@ -552,7 +558,7 @@ function renderDiff(diff) {
|
|
|
552
558
|
.map((row) => `<span class="ln ln-${row.kind}">` +
|
|
553
559
|
`<span class="old-no" aria-hidden="true">${row.oldNo === null ? "" : String(row.oldNo)}</span>` +
|
|
554
560
|
`<span class="new-no" aria-hidden="true">${row.newNo === null ? "" : String(row.newNo)}</span>` +
|
|
555
|
-
`<span class="code">${escapeHtml(row.text)}</span>` +
|
|
561
|
+
`<span class="code${hiddenControlsIn(row.text).length > 0 ? " code-hidden" : ""}">${escapeHtml(row.text)}</span>` +
|
|
556
562
|
"</span>")
|
|
557
563
|
.join("");
|
|
558
564
|
return [
|
|
@@ -957,6 +963,7 @@ a { color: var(--teal); }
|
|
|
957
963
|
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
|
|
958
964
|
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
|
|
959
965
|
}
|
|
966
|
+
.update-notice { padding: 8px 12px; border-left: 3px solid var(--warn); background: var(--warn-bg); border-radius: 4px; font-size: 14px; }
|
|
960
967
|
.masthead {
|
|
961
968
|
display: flex;
|
|
962
969
|
flex-direction: column;
|
|
@@ -1147,6 +1154,7 @@ pre.diff {
|
|
|
1147
1154
|
user-select: none;
|
|
1148
1155
|
}
|
|
1149
1156
|
.code { padding: 0 14px; }
|
|
1157
|
+
.code-hidden { outline: 1px solid var(--warn); outline-offset: -1px; background: var(--warn-bg); }
|
|
1150
1158
|
.ln-add > .code { color: var(--add); }
|
|
1151
1159
|
.ln-del > .code { color: var(--del); }
|
|
1152
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;
|