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.
Files changed (64) 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.d.ts +3 -1
  13. package/dist/review/call-flow-html.js +13 -11
  14. package/dist/review/change-facts.d.ts +21 -1
  15. package/dist/review/change-facts.js +271 -49
  16. package/dist/review/cli.js +13 -1
  17. package/dist/review/connected-analysis.d.ts +4 -1
  18. package/dist/review/connected-analysis.js +4 -2
  19. package/dist/review/connected-html.d.ts +15 -4
  20. package/dist/review/connected-html.js +342 -35
  21. package/dist/review/connected.js +47 -14
  22. package/dist/review/escape-html.d.ts +5 -1
  23. package/dist/review/escape-html.js +7 -2
  24. package/dist/review/explanation.d.ts +4 -0
  25. package/dist/review/explanation.js +6 -1
  26. package/dist/review/github.d.ts +56 -0
  27. package/dist/review/github.js +234 -33
  28. package/dist/review/grammars-command.d.ts +12 -0
  29. package/dist/review/grammars-command.js +60 -0
  30. package/dist/review/hidden-characters.d.ts +31 -0
  31. package/dist/review/hidden-characters.js +113 -0
  32. package/dist/review/history.js +7 -3
  33. package/dist/review/html.d.ts +3 -2
  34. package/dist/review/html.js +19 -18
  35. package/dist/review/input.js +5 -2
  36. package/dist/review/intent.d.ts +7 -0
  37. package/dist/review/intent.js +25 -3
  38. package/dist/review/markdown.js +11 -0
  39. package/dist/review/mcp-cli.js +4 -1
  40. package/dist/review/mcp.d.ts +7 -1
  41. package/dist/review/mcp.js +145 -59
  42. package/dist/review/pipeline.d.ts +2 -1
  43. package/dist/review/pipeline.js +4 -3
  44. package/dist/review/pr-input.d.ts +7 -0
  45. package/dist/review/pr-input.js +25 -3
  46. package/dist/review/process-html.d.ts +1 -5
  47. package/dist/review/process-html.js +3 -12
  48. package/dist/review/questions.js +11 -3
  49. package/dist/review/reference-check.d.ts +5 -1
  50. package/dist/review/reference-check.js +40 -16
  51. package/dist/review/report-pages.d.ts +24 -9
  52. package/dist/review/report-pages.js +111 -28
  53. package/dist/review/result-budget.d.ts +28 -0
  54. package/dist/review/result-budget.js +136 -0
  55. package/dist/review/service.js +26 -2
  56. package/dist/review/setup.d.ts +1 -1
  57. package/dist/review/setup.js +9 -4
  58. package/dist/review/types.d.ts +19 -5
  59. package/dist/review/types.js +2 -1
  60. package/dist/review/update-check.d.ts +35 -0
  61. package/dist/review/update-check.js +76 -0
  62. package/dist/run.js +11 -5
  63. package/npm-shrinkwrap.json +3483 -0
  64. 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";
@@ -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
- window.parent.postMessage({ type: 'diffninja-business-height', height: Math.ceil(document.documentElement.scrollHeight) }, window.location.origin);
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. Tree, Graph and Sequence work as in the report; links
177
- * into the report's diff are dropped, since the page showing this has its own.
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${BUSINESS_STYLES}\n${BUSINESS_VIEW_STYLES}\n${EMBEDDED_FLOW_STYLES}</style>`,
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
- report.agentExplanation === undefined ? "" : '<h2 class="flow-section-title">Call flow</h2>',
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("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.`);
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); }
@@ -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;