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.
Files changed (60) hide show
  1. package/README.md +65 -11
  2. package/dist/executables.d.ts +18 -0
  3. package/dist/executables.js +32 -0
  4. package/dist/git.d.ts +26 -1
  5. package/dist/git.js +56 -4
  6. package/dist/languages/child-env.d.ts +11 -0
  7. package/dist/languages/child-env.js +60 -0
  8. package/dist/languages/grammar-lock.d.ts +569 -0
  9. package/dist/languages/grammar-lock.js +574 -0
  10. package/dist/languages/grammars.d.ts +69 -9
  11. package/dist/languages/grammars.js +186 -119
  12. package/dist/review/call-flow-html.js +1 -0
  13. package/dist/review/change-facts.d.ts +21 -1
  14. package/dist/review/change-facts.js +271 -49
  15. package/dist/review/cli.js +13 -1
  16. package/dist/review/connected-analysis.d.ts +4 -1
  17. package/dist/review/connected-analysis.js +4 -2
  18. package/dist/review/connected-html.d.ts +15 -4
  19. package/dist/review/connected-html.js +338 -43
  20. package/dist/review/connected.js +47 -14
  21. package/dist/review/escape-html.d.ts +5 -1
  22. package/dist/review/escape-html.js +7 -2
  23. package/dist/review/explanation.d.ts +4 -0
  24. package/dist/review/explanation.js +6 -1
  25. package/dist/review/github.d.ts +56 -0
  26. package/dist/review/github.js +234 -33
  27. package/dist/review/grammars-command.d.ts +12 -0
  28. package/dist/review/grammars-command.js +60 -0
  29. package/dist/review/hidden-characters.d.ts +31 -0
  30. package/dist/review/hidden-characters.js +113 -0
  31. package/dist/review/history.js +7 -3
  32. package/dist/review/html.js +11 -3
  33. package/dist/review/input.js +5 -2
  34. package/dist/review/intent.d.ts +7 -0
  35. package/dist/review/intent.js +25 -3
  36. package/dist/review/markdown.js +11 -0
  37. package/dist/review/mcp-cli.js +4 -1
  38. package/dist/review/mcp.d.ts +7 -1
  39. package/dist/review/mcp.js +145 -56
  40. package/dist/review/pipeline.d.ts +2 -1
  41. package/dist/review/pipeline.js +4 -3
  42. package/dist/review/pr-input.d.ts +7 -0
  43. package/dist/review/pr-input.js +25 -3
  44. package/dist/review/questions.js +11 -3
  45. package/dist/review/reference-check.d.ts +5 -1
  46. package/dist/review/reference-check.js +40 -16
  47. package/dist/review/report-pages.d.ts +24 -9
  48. package/dist/review/report-pages.js +111 -28
  49. package/dist/review/result-budget.d.ts +28 -0
  50. package/dist/review/result-budget.js +136 -0
  51. package/dist/review/service.js +26 -2
  52. package/dist/review/setup.d.ts +1 -1
  53. package/dist/review/setup.js +9 -4
  54. package/dist/review/types.d.ts +19 -9
  55. package/dist/review/types.js +2 -2
  56. package/dist/review/update-check.d.ts +35 -0
  57. package/dist/review/update-check.js +76 -0
  58. package/dist/run.js +11 -5
  59. package/npm-shrinkwrap.json +3483 -0
  60. package/package.json +3 -2
@@ -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("diffninja does not read this file type, so no facts were established. Read this hunk yourself.");
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); }
@@ -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
  }
@@ -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;
@@ -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 (/<!--.*(?:auto-generated|release notes)/iu.test(raw))
37
+ if (commentThen(raw, GENERATED_COMMENT))
16
38
  generatedComment = true;
17
- if (/<!--.*end of auto-generated/iu.test(raw)) {
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)
@@ -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();
@@ -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
- const server = createReviewServer();
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
@@ -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
- export declare function createReviewServer(): McpServer;
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;