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
@@ -0,0 +1,136 @@
1
+ import { withVisibleControls } from "./hidden-characters.js";
2
+ /**
3
+ * The most JSON one copy of a `review_diff` result may hold. The result travels
4
+ * twice, as text content and as structured content, and MCP clients built on the
5
+ * SDK drop the connection at 10 MiB per message: a 20,000-line pull request
6
+ * produced 12 MB, which made the tool unusable for an ordinary large change.
7
+ * The text copy is itself a JSON string inside the message, so it is measured
8
+ * escaped once more: a change full of escaped quotes nearly doubles in it.
9
+ *
10
+ * Only the copy sent to the agent is trimmed. The server keeps the whole report
11
+ * for the pages and for validating what the agent sends back, and every trim is
12
+ * named in the result's warnings so the agent knows what it was not shown.
13
+ */
14
+ export const MAX_RESULT_BYTES = 4 * 1024 * 1024;
15
+ /** Room left for the fields a result adds around the report (ids, next steps). */
16
+ const RESERVED_BYTES = 64 * 1024;
17
+ /** Author claims kept when the result is over budget: they are navigation aids, not the review. */
18
+ const KEPT_CLAIMS = 20;
19
+ /** Review agenda entries kept when the result is over budget: past the first few they point at hunks the items already list. */
20
+ const KEPT_AGENDA = 20;
21
+ /** Bytes of the value as the text copy carries it. Escaping is per character, so an item adds exactly its own escaped length to the whole. */
22
+ const bytesOf = (value) => Buffer.byteLength(JSON.stringify(JSON.stringify(value)), "utf8");
23
+ const sizeOf = (current) => bytesOf({ snapshot: current.snapshot, report: current.report });
24
+ const stages = [
25
+ ({ snapshot, report }) => snapshot === undefined || snapshot.lines.length === 0 ? undefined : {
26
+ next: { snapshot: { ...snapshot, lines: [] }, report },
27
+ note: `snapshot.lines (a line-by-line copy of the diff, ${snapshot.lines.length} lines) was left out; the hunks in report.items carry every line`,
28
+ },
29
+ ({ snapshot, report }) => report.callFlow.length === 0 && report.callFlows.length === 0 ? undefined : {
30
+ next: { snapshot, report: { ...report, callFlow: [], callFlows: [] } },
31
+ note: "the call-flow trees (callFlow, callFlows) were left out",
32
+ },
33
+ ({ snapshot, report }) => {
34
+ const intent = report.evidence?.intent;
35
+ if (report.evidence === undefined || intent === undefined || intent.claims.length <= KEPT_CLAIMS)
36
+ return undefined;
37
+ return {
38
+ next: { snapshot, report: { ...report, evidence: { ...report.evidence, intent: { ...intent, claims: intent.claims.slice(0, KEPT_CLAIMS) } } } },
39
+ note: `only the first ${KEPT_CLAIMS} of ${intent.claims.length} statements from the pull request text were kept`,
40
+ };
41
+ },
42
+ ({ snapshot, report }) => {
43
+ const evidence = report.evidence;
44
+ if (evidence === undefined || evidence.agenda.length <= KEPT_AGENDA)
45
+ return undefined;
46
+ return {
47
+ next: { snapshot, report: { ...report, evidence: { ...evidence, agenda: evidence.agenda.slice(0, KEPT_AGENDA) } } },
48
+ note: `only the first ${KEPT_AGENDA} of ${evidence.agenda.length} review agenda entries were kept; report.items lists every hunk`,
49
+ };
50
+ },
51
+ ({ snapshot, report }) => {
52
+ if (!report.items.some((item) => item.callFlow !== undefined || item.contextNodes !== undefined))
53
+ return undefined;
54
+ const items = report.items.map(({ callFlow: _flow, contextNodes: _nodes, ...rest }) => rest);
55
+ return { next: { snapshot, report: { ...report, items } }, note: "the per-hunk call context (callFlow, contextNodes) was left out" };
56
+ },
57
+ ];
58
+ function withoutDiff(item) {
59
+ const lines = item.diff.split("\n").length;
60
+ return { ...item, diff: `${item.header}\n[The ${lines} lines of this hunk were left out to keep this result under ${MAX_RESULT_BYTES / 1024 / 1024} MiB. Read them in the repository or on the review page.]` };
61
+ }
62
+ function withoutFacts({ facts: _facts, history: _history, ...rest }) {
63
+ return { ...rest, reasons: [] };
64
+ }
65
+ /**
66
+ * Replace items by a smaller form, from the last (lowest-ranked) up, until the
67
+ * result fits. Each item is measured once, and the size is kept by subtracting
68
+ * what each replacement saves, so a change of thousands of hunks is not
69
+ * serialized again per hunk. An item the smaller form would not shrink is kept.
70
+ */
71
+ function trimItems(current, size, limit, smaller) {
72
+ const items = [...current.report.items];
73
+ let left = size;
74
+ let trimmed = 0;
75
+ for (let index = items.length - 1; index >= 0 && left > limit; index -= 1) {
76
+ const item = items[index];
77
+ if (item === undefined)
78
+ continue;
79
+ const replacement = smaller(item);
80
+ const saved = bytesOf(item) - bytesOf(replacement);
81
+ if (saved <= 0)
82
+ continue;
83
+ items[index] = replacement;
84
+ left -= saved;
85
+ trimmed += 1;
86
+ }
87
+ return { next: { snapshot: current.snapshot, report: { ...current.report, items } }, size: left, trimmed };
88
+ }
89
+ /**
90
+ * The report (and, for a connected review, its snapshot) as the agent is sent
91
+ * it: with hidden characters shown as markers, whole while it fits, otherwise
92
+ * trimmed in a fixed order, from the parts an agent needs least to the hunks
93
+ * ranked last, with each trim recorded in `warnings`. The markers come first
94
+ * because they are what is sent: a 3-byte zero-width space becomes a 12-byte
95
+ * marker, so a budget taken before them undercounts.
96
+ */
97
+ export function boundedForAgent(snapshot, report, budget = MAX_RESULT_BYTES) {
98
+ const limit = budget - RESERVED_BYTES;
99
+ let current = withVisibleControls({ snapshot, report });
100
+ let size = sizeOf(current);
101
+ if (size <= limit)
102
+ return current;
103
+ const notes = [];
104
+ for (const stage of stages) {
105
+ const result = stage(current);
106
+ if (result === undefined)
107
+ continue;
108
+ current = result.next;
109
+ size = sizeOf(current);
110
+ notes.push(result.note);
111
+ if (size <= limit)
112
+ break;
113
+ }
114
+ if (size > limit) {
115
+ const diffs = trimItems(current, size, limit, withoutDiff);
116
+ current = diffs.next;
117
+ size = diffs.size;
118
+ if (diffs.trimmed > 0)
119
+ notes.push(`${diffs.trimmed} of the lowest-ranked hunks have their diff text left out`);
120
+ }
121
+ if (size > limit) {
122
+ const facts = trimItems(current, size, limit, withoutFacts);
123
+ current = facts.next;
124
+ size = facts.size;
125
+ if (facts.trimmed > 0)
126
+ notes.push(`${facts.trimmed} of the lowest-ranked hunks have their facts, reasons, and history left out`);
127
+ }
128
+ // Every hunk must stay listed for the agent to order it, so past this point a
129
+ // result only shrinks by losing hunks; saying so beats a dropped connection.
130
+ if (size > limit) {
131
+ const hunks = report.items.length === 1 ? "1 hunk" : `${report.items.length} hunks`;
132
+ throw new Error(`This result is too large to send: with every diff, fact, and reason left out, the review of this change's ${hunks} is still ${(size / 1024 / 1024).toFixed(1)} MiB, over the ${budget / 1024 / 1024} MiB a client accepts in one message. Review the change in parts, with a narrower git range or a diff of fewer files.`);
133
+ }
134
+ const warning = `This result was over ${budget / 1024 / 1024} MiB, and the message carries it twice, so it was trimmed to stay under what a client accepts: ${notes.join("; ")}. The review page has the complete report.`;
135
+ return { snapshot: current.snapshot, report: { ...current.report, warnings: [...current.report.warnings, warning] } };
136
+ }
@@ -1,6 +1,8 @@
1
+ import { grammarsInstallCommand, takeMissingGrammars } from "../languages/grammars.js";
2
+ import { hiddenControlsByFile } from "./hidden-characters.js";
1
3
  import { resolve } from "node:path";
2
4
  import { runDiff } from "../run.js";
3
- import { readSnapshotFile } from "../git.js";
5
+ import { MAX_INDEXED_FILES, MAX_INDEXED_FILE_BYTES, readSnapshotFile, takeSkippedSources } from "../git.js";
4
6
  import { parseDiff, gitDiff } from "./input.js";
5
7
  import { reviewUnits } from "./pipeline.js";
6
8
  import { buildCallFlows, reportOrderedTextHunkFiles, CALL_FLOW_MAX_DEPTH } from "./call-flow.js";
@@ -95,6 +97,12 @@ export async function reviewDiff(input, options = {}) {
95
97
  if (snapshots && options.pr?.headRef !== undefined && options.pr.headRef !== snapshots.to) {
96
98
  throw new Error("The PR head does not match the source snapshot; refusing mismatched intent evidence.");
97
99
  }
100
+ const hiddenControls = hiddenControlsByFile(units);
101
+ if (hiddenControls.size > 0) {
102
+ const files = [...hiddenControls.keys()];
103
+ const points = [...new Set([...hiddenControls.values()].flat())].sort();
104
+ warnings.push(`Lines this change adds or removes in ${files.slice(0, 5).join(", ")}${files.length > 5 ? ` and ${files.length - 5} more files` : ""} contain hidden or bidirectional control characters (${points.join(", ")}). They can make code read differently from how it compiles (Trojan Source, CVE-2021-42574); the pages show each as a ⟦U+XXXX⟧ marker. Read those lines with the markers before trusting them.`);
105
+ }
98
106
  let evidence = buildReviewEvidence(units);
99
107
  const callFlow = [];
100
108
  let trees = [];
@@ -127,6 +135,8 @@ export async function reviewDiff(input, options = {}) {
127
135
  // need a git range. Every other value is decided by what analysis returned.
128
136
  let callFlowAvailability = snapshots ? "no-changes" : "needs-git-range";
129
137
  if (snapshots && units.length) {
138
+ takeMissingGrammars();
139
+ takeSkippedSources();
130
140
  try {
131
141
  const flow = runDiff({
132
142
  cwd: cwd, from: snapshots.from, to: snapshots.to, maxDepth: CALL_FLOW_MAX_DEPTH, color: false, locs: true,
@@ -160,6 +170,20 @@ export async function reviewDiff(input, options = {}) {
160
170
  evidence = buildReviewEvidence(units);
161
171
  warnings.push("Call-flow analysis failed. Review is based on the diff only. Inspect repository context manually.");
162
172
  }
173
+ const left = takeSkippedSources();
174
+ if (left.oversized > 0 || left.beyondLimit > 0) {
175
+ const parts = [
176
+ left.oversized > 0 ? `${left.oversized} source ${left.oversized === 1 ? "file" : "files"} over ${MAX_INDEXED_FILE_BYTES / 1024 / 1024} MiB (generated or minified code)` : "",
177
+ left.beyondLimit > 0 ? `${left.beyondLimit} ${left.beyondLimit === 1 ? "file" : "files"} beyond the ${MAX_INDEXED_FILES.toLocaleString("en-US")} read per revision` : "",
178
+ ].filter((part) => part !== "");
179
+ warnings.push(`Call flows did not read ${parts.join(" and ")}. Flows through them are absent, which is not evidence of safety.`);
180
+ if (callFlowAvailability === "no-changes")
181
+ callFlowAvailability = "partial";
182
+ }
183
+ const missing = takeMissingGrammars();
184
+ if (missing.length > 0) {
185
+ warnings.push(`Call flows skip the files these grammars would read: ${missing.join(", ")}. diffninja does not download code while it reviews; run \`${grammarsInstallCommand(missing)}\` once and review again to include them. Flows through those files are absent, which is not evidence of safety.`);
186
+ }
163
187
  }
164
188
  if (options.referenceProject !== undefined) {
165
189
  if (!snapshots)
@@ -186,7 +210,7 @@ export async function reviewDiff(input, options = {}) {
186
210
  // Structured flows are grouped per changed file in report order, after
187
211
  // ranking, so the HTML can order files by the severity of their worst hunk.
188
212
  const callFlows = buildCallFlows(reportOrderedTextHunkFiles(result.items, units), trees, nodeDetail);
189
- if (callFlows.length > 0)
213
+ if (callFlows.length > 0 && callFlowAvailability === "no-changes")
190
214
  callFlowAvailability = "available";
191
215
  const report = { title: options.pr?.title || "Focused PR review", source, createdAt: new Date().toISOString(),
192
216
  pr: options.pr,
@@ -11,7 +11,7 @@
11
11
  * the `gh` session.
12
12
  */
13
13
  import { type Stats } from "node:fs";
14
- export declare const setupHelp = "diffninja setup. Register the diffninja MCP server on every detected agent CLI.\n\n npx -y diffninja@latest setup [--cli claude,codex,omp,pi] [--dry-run]\n diffninja setup --uninstall [--cli codex]\n\nDetects Claude Code, Codex, OMP, and pi from their config files or binaries\nand registers the diffninja MCP server in each user config, pointing at the\nglobally installed package. Installs the package globally first\n(`npm install -g diffninja@<this version>`) so the registration keeps\nworking, and updates a global install older than this setup, so running\n`npx -y diffninja@latest setup` again is how you update. When that install\nfails it registers an npx-based entry pinned to this version and says so.\n\nOptions:\n --cli NAMES Only these CLIs, comma-separated: claude,codex,omp,pi.\n --uninstall Remove the diffninja server from every detected CLI.\n --dry-run Show what would change, without installing or writing.\n --no-install Skip installing or updating the global package; without one,\n register npx-based entries.\n --help Show this help.\n\nThe server needs no API key: static reviews run locally, and pull request\nreviews reuse your authenticated gh session.\n";
14
+ export declare const setupHelp = "diffninja setup. Register the diffninja MCP server on every detected agent CLI.\n\n npx -y diffninja@latest setup [--cli claude,codex,omp,pi] [--dry-run]\n diffninja setup --uninstall [--cli codex]\n\nDetects Claude Code, Codex, OMP, and pi from their config files or binaries\nand registers the diffninja MCP server in each user config, pointing at the\nglobally installed package. Installs the package globally first\n(`npm install -g diffninja@<this version>`) so the registration keeps\nworking, and updates a global install older than this setup, so running\n`npx -y diffninja@latest setup` again is how you update. When that install\nfails it registers an npx-based entry pinned to this version and says so.\n\nEach JSON config it changes is rewritten in full, in standard formatting\n(indentation, string escapes and integers above 2^53 can change), and no\nbackup is kept. Copy ~/.claude.json before the first run.\n\nOptions:\n --cli NAMES Only these CLIs, comma-separated: claude,codex,omp,pi.\n --uninstall Remove the diffninja server from every detected CLI.\n --dry-run Show what would change, without installing or writing.\n --no-install Skip installing or updating the global package; without one,\n register npx-based entries.\n --help Show this help.\n\nThe server needs no API key: static reviews run locally, and pull request\nreviews reuse your authenticated gh session.\n";
15
15
  export declare const CLI_NAMES: readonly ["claude", "codex", "omp", "pi"];
16
16
  export type CliName = (typeof CLI_NAMES)[number];
17
17
  export interface McpEntry {
@@ -18,6 +18,7 @@ import { lstat, mkdir, open, readFile, readlink, realpath, rename, rm, stat } fr
18
18
  import { homedir } from "node:os";
19
19
  import { basename, delimiter, dirname, join, resolve } from "node:path";
20
20
  import { z } from "zod";
21
+ import { npmEnvironment, windowsShell } from "../languages/child-env.js";
21
22
  import { npmCliPath, npmSpawnSpec } from "../languages/grammars.js";
22
23
  import { removeTomlTable, upsertTomlTable } from "./toml.js";
23
24
  import { compareVersions, packageVersion } from "./version.js";
@@ -34,6 +35,10 @@ working, and updates a global install older than this setup, so running
34
35
  \`npx -y diffninja@latest setup\` again is how you update. When that install
35
36
  fails it registers an npx-based entry pinned to this version and says so.
36
37
 
38
+ Each JSON config it changes is rewritten in full, in standard formatting
39
+ (indentation, string escapes and integers above 2^53 can change), and no
40
+ backup is kept. Copy ~/.claude.json before the first run.
41
+
37
42
  Options:
38
43
  --cli NAMES Only these CLIs, comma-separated: claude,codex,omp,pi.
39
44
  --uninstall Remove the diffninja server from every detected CLI.
@@ -70,7 +75,7 @@ export function npxEntry(platform = process.platform, version = packageVersion()
70
75
  export function windowsCliEntry(name, args, cliPath = npmCliPath) {
71
76
  const npmCli = cliPath(`${name}-cli.js`);
72
77
  if (npmCli === undefined) {
73
- return { command: process.env["ComSpec"] ?? "cmd.exe", args: ["/d", "/s", "/c", name, ...args] };
78
+ return { command: windowsShell(), args: ["/d", "/s", "/c", name, ...args] };
74
79
  }
75
80
  return { command: process.execPath, args: [npmCli, ...args] };
76
81
  }
@@ -108,7 +113,8 @@ export function globalIsOlder(installed, version) {
108
113
  */
109
114
  export function createNpm(overrides = {}) {
110
115
  const platform = overrides.platform ?? process.platform;
111
- const env = overrides.env;
116
+ // Installing runs the install scripts of diffninja and its dependencies: they get what npm needs, not the shell's tokens.
117
+ const env = overrides.env ?? npmEnvironment();
112
118
  const run = (args, inherit) => {
113
119
  const spec = npmSpawnSpec(args, platform);
114
120
  return spawn(spec.file, spec.args, inherit ? { stdio: "inherit", env } : { env });
@@ -144,7 +150,6 @@ async function exitCode(child) {
144
150
  return -1;
145
151
  }
146
152
  }
147
- const realNpm = createNpm();
148
153
  /** `npm root -g`, or undefined when npm is unavailable or fails. */
149
154
  async function globalRoot(npm) {
150
155
  try {
@@ -557,7 +562,7 @@ export async function runSetup(options = {}, deps = {}) {
557
562
  let entry;
558
563
  let viaNpx = false;
559
564
  if (!uninstall) {
560
- const resolved = await resolveEntry(options.noInstall === true, deps.npm ?? realNpm, quiet, dryRun, version);
565
+ const resolved = await resolveEntry(options.noInstall === true, deps.npm ?? createNpm(), quiet, dryRun, version);
561
566
  entry = resolved.entry;
562
567
  viaNpx = resolved.viaNpx;
563
568
  }
@@ -1,3 +1,4 @@
1
+ import type { UpdateNotice } from "./update-check.js";
1
2
  import type { PullRequestIntent, ReviewEvidence } from "./evidence-types.js";
2
3
  import type { ChangeFacts } from "./change-facts.js";
3
4
  import type { ReviewQuestion } from "./questions.js";
@@ -62,10 +63,12 @@ export interface CallFlowFile {
62
63
  * Why structured call flows are present or absent. `available` means at least
63
64
  * one changed file has trees; `no-changes` means there was nothing structural to
64
65
  * attach (no changed file with text hunks, or no tree reaching one) and is not a
65
- * safety claim; `needs-git-range` means the input was a patch; `failed` means
66
- * the analysis threw.
66
+ * safety claim; `partial` means the analysis left source files out (over the
67
+ * size bound or past the file limit, named in the warnings), with or without
68
+ * trees, so paths through them are absent; `needs-git-range` means the input was
69
+ * a patch; `failed` means the analysis threw.
67
70
  */
68
- export type CallFlowAvailability = "available" | "needs-git-range" | "no-changes" | "failed";
71
+ export type CallFlowAvailability = "available" | "needs-git-range" | "no-changes" | "partial" | "failed";
69
72
  /**
70
73
  * One keyed piece of structured review context for a hunk: a changed, caller, or
71
74
  * callee definition with its snapshot-bound source and the call/binding evidence
@@ -132,12 +135,21 @@ export interface AgentOrder {
132
135
  /** diffninja's own order of the same items, kept so the page can still offer it. */
133
136
  diffninjaIds: string[];
134
137
  }
135
- /** One line comment the reviewing agent suggests; the human adds it to their review or not. */
138
+ /** How the agent came to believe a comment: it ran or reproduced the failure, or followed the code path by reading it. A guess is not a blocker. */
139
+ export declare const COMMENT_EVIDENCE: readonly ["ran", "traced"];
140
+ export type CommentEvidence = (typeof COMMENT_EVIDENCE)[number];
141
+ /** One line comment the reviewing agent suggests. Every one claims to block the merge, so each carries its proof. */
136
142
  export interface SuggestedComment {
137
143
  path: string;
138
144
  line: number;
139
145
  side: "LEFT" | "RIGHT";
146
+ /** The reviewer's own words; the only field that joins the human's draft. */
140
147
  body: string;
148
+ /** A concrete input or state and the wrong result, or the written rule it breaks and where the rule is written. */
149
+ scenario: string;
150
+ evidence: CommentEvidence;
151
+ /** What would have to be true for this not to be a problem. */
152
+ unlessTrue: string;
141
153
  }
142
154
  export interface AgentComments {
143
155
  comments: SuggestedComment[];
@@ -176,7 +188,7 @@ export interface ReviewReport {
176
188
  questions: ReviewQuestion[];
177
189
  /** The reading order the reviewing agent recorded; once present, `items` follow it. */
178
190
  agentOrder?: AgentOrder;
179
- /** Line comments the reviewing agent suggested; nothing is posted until the human submits them. */
191
+ /** Comments the reviewing agent says block the merge; nothing is posted until the human submits them. */
180
192
  agentComments?: AgentComments;
181
193
  /**
182
194
  * The reviewing agent's own paragraph on the pull request's goal, written
@@ -185,6 +197,8 @@ export interface ReviewReport {
185
197
  * agent's reading rather than a verified claim.
186
198
  */
187
199
  agentSummary?: AgentSummary;
200
+ /** A newer diffninja exists on npm; set only by the executable's opt-in version lookup. */
201
+ updateNotice?: UpdateNotice;
188
202
  /**
189
203
  * Every function a reader meets in this report (around the hunks and in the
190
204
  * call flows), each with a stable `<file>#<name>` id, for the reviewing agent
@@ -1 +1,2 @@
1
- export {};
1
+ /** How the agent came to believe a comment: it ran or reproduced the failure, or followed the code path by reading it. A guess is not a blocker. */
2
+ export const COMMENT_EVIDENCE = ["ran", "traced"];
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Tells the user when a newer diffninja is on npm. It is OFF unless the person
3
+ * who starts the server turns it on with `DIFFNINJA_UPDATE_CHECK=1`: a review
4
+ * tool that reads company code should make no request of its own by default.
5
+ * When on, the lookup is one GET of the package's latest version, sent the
6
+ * first time a review is requested (never at process start), and any failure
7
+ * means "no notice", never an error. The server library never looks anything up
8
+ * unless it is handed a lookup; only `mcp-cli.ts` builds one, from the
9
+ * environment.
10
+ */
11
+ /** How to update: re-running setup installs the newest package and re-points every agent. */
12
+ export declare const UPDATE_COMMAND = "npx diffninja@latest setup";
13
+ export interface UpdateNotice {
14
+ current: string;
15
+ latest: string;
16
+ command: string;
17
+ }
18
+ export type LatestVersion = () => Promise<string | undefined>;
19
+ /** The newest published release, or undefined when the registry cannot be reached or answers oddly. */
20
+ export declare function registryLatest(): Promise<string | undefined>;
21
+ /**
22
+ * The lookup the executable uses: none unless `DIFFNINJA_UPDATE_CHECK=1`, and
23
+ * never in CI or when npm's own `NO_UPDATE_NOTIFIER` is set.
24
+ */
25
+ export declare function updateLookupFromEnv(env?: NodeJS.ProcessEnv): LatestVersion | undefined;
26
+ /** One lookup per connection, started by the first review that asks and never awaited longer than WAIT_MS. */
27
+ export declare class UpdateNotifier {
28
+ private readonly lookup;
29
+ private readonly current;
30
+ private pending;
31
+ constructor(lookup: LatestVersion | undefined, current?: string);
32
+ notice(): Promise<UpdateNotice | undefined>;
33
+ }
34
+ /** The sentence for the agent: say it first, in its own words, and carry on with the review. */
35
+ export declare function updateStep(notice: UpdateNotice): string;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Tells the user when a newer diffninja is on npm. It is OFF unless the person
3
+ * who starts the server turns it on with `DIFFNINJA_UPDATE_CHECK=1`: a review
4
+ * tool that reads company code should make no request of its own by default.
5
+ * When on, the lookup is one GET of the package's latest version, sent the
6
+ * first time a review is requested (never at process start), and any failure
7
+ * means "no notice", never an error. The server library never looks anything up
8
+ * unless it is handed a lookup; only `mcp-cli.ts` builds one, from the
9
+ * environment.
10
+ */
11
+ import { z } from "zod";
12
+ import { compareVersions, packageVersion } from "./version.js";
13
+ /** How to update: re-running setup installs the newest package and re-points every agent. */
14
+ export const UPDATE_COMMAND = "npx diffninja@latest setup";
15
+ const REGISTRY_URL = "https://registry.npmjs.org/diffninja/latest";
16
+ const LOOKUP_TIMEOUT_MS = 3000;
17
+ /** The longest a review waits for the lookup; a slower answer shows up on the next review. */
18
+ const WAIT_MS = 1500;
19
+ /** The registry answers with the whole manifest of one release (tens of KB); anything near this is not it. */
20
+ const MAX_RESPONSE_CHARS = 512 * 1024;
21
+ /** Only a plain release counts: nothing else may reach the agent's instructions or a page. */
22
+ const RELEASE = /^\d{1,6}\.\d{1,6}\.\d{1,6}$/;
23
+ const latestSchema = z.object({ version: z.string().regex(RELEASE) });
24
+ /** The newest published release, or undefined when the registry cannot be reached or answers oddly. */
25
+ export async function registryLatest() {
26
+ try {
27
+ // A redirect would send the request somewhere the fixed URL above does not name.
28
+ const response = await fetch(REGISTRY_URL, { headers: { accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS) });
29
+ if (!response.ok)
30
+ return undefined;
31
+ const text = await response.text();
32
+ if (text.length > MAX_RESPONSE_CHARS)
33
+ return undefined;
34
+ return latestSchema.parse(JSON.parse(text)).version;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ /**
41
+ * The lookup the executable uses: none unless `DIFFNINJA_UPDATE_CHECK=1`, and
42
+ * never in CI or when npm's own `NO_UPDATE_NOTIFIER` is set.
43
+ */
44
+ export function updateLookupFromEnv(env = process.env) {
45
+ if (env["DIFFNINJA_UPDATE_CHECK"] !== "1")
46
+ return undefined;
47
+ if (env["NO_UPDATE_NOTIFIER"] !== undefined || env["CI"] !== undefined)
48
+ return undefined;
49
+ return registryLatest;
50
+ }
51
+ /** One lookup per connection, started by the first review that asks and never awaited longer than WAIT_MS. */
52
+ export class UpdateNotifier {
53
+ lookup;
54
+ current;
55
+ pending;
56
+ constructor(lookup, current = packageVersion()) {
57
+ this.lookup = lookup;
58
+ this.current = current;
59
+ }
60
+ async notice() {
61
+ if (this.lookup === undefined)
62
+ return undefined;
63
+ this.pending ??= this.lookup().catch(() => undefined);
64
+ // AbortSignal.timeout's timer never keeps the process alive, so nothing needs clearing.
65
+ const timer = new Promise(resolve => AbortSignal.timeout(WAIT_MS).addEventListener("abort", () => resolve(undefined), { once: true }));
66
+ const latest = await Promise.race([this.pending, timer]);
67
+ if (latest === undefined || !RELEASE.test(latest))
68
+ return undefined;
69
+ const newer = compareVersions(latest, this.current);
70
+ return newer !== undefined && newer > 0 ? { current: this.current, latest, command: UPDATE_COMMAND } : undefined;
71
+ }
72
+ }
73
+ /** The sentence for the agent: say it first, in its own words, and carry on with the review. */
74
+ export function updateStep(notice) {
75
+ return `Before anything else, tell the user in one sentence that diffninja ${notice.latest} is available (this is ${notice.current}) and that they update by running \`${notice.command}\` in a terminal and restarting their agent; then continue this review.`;
76
+ }
package/dist/run.js CHANGED
@@ -1,18 +1,22 @@
1
+ import { GrammarNotInstalledError } from "./languages/grammars.js";
1
2
  import { buildCallTreeFromInfo, exportsInFile, resolveEntry, resolveEntrypointFile, indexedFiles, } from "./calltree.js";
2
3
  import { buildIndex, extractCached } from "./extract.js";
3
- import { assertGitRepo, describeSnapshot, listSnapshotFiles, resolveDiffSnapshotsAndPaths, resolveSnapshotAndPaths, verifyCommit, visitCommitBlobs, visitWorktreeFiles, } from "./git.js";
4
+ import { assertGitRepo, changedPaths, describeSnapshot, listSnapshotFiles, resolveDiffSnapshotsAndPaths, resolveSnapshotAndPaths, verifyCommit, visitCommitBlobs, visitWorktreeFiles, } from "./git.js";
4
5
  import { diffEntry, diffPinnedEntry, inferEntries, resolveExplicitDiffEntries, } from "./infer.js";
5
6
  import { collectPathsTo, findReachPaths } from "./reach.js";
6
7
  import { renderDiff, renderTree } from "./render.js";
7
8
  import { assignOptionalTreeFields } from "./types.js";
8
- function loadIndex(cwd, snapshot, pathFilters, cache = new Map()) {
9
- const files = listSnapshotFiles(cwd, snapshot, pathFilters);
9
+ function loadIndex(cwd, snapshot, pathFilters, cache = new Map(), changed = new Set()) {
10
+ const files = listSnapshotFiles(cwd, snapshot, pathFilters, changed);
10
11
  const extracted = new Map();
11
12
  const extract = (file, source) => {
12
13
  try {
13
14
  extracted.set(file.path, extractCached(file.path, source, cache));
14
15
  }
15
16
  catch (error) {
17
+ // A grammar that is not installed is reported once, by the review, not per file.
18
+ if (error instanceof GrammarNotInstalledError)
19
+ return;
16
20
  const message = error instanceof Error ? error.message : String(error);
17
21
  console.error(`warn: failed to parse ${file.path} @ ${snapshot.ref}: ${message}`);
18
22
  }
@@ -120,8 +124,10 @@ export function runDiff(options = {}) {
120
124
  if (to.kind === "commit")
121
125
  verifyCommit(cwd, to.ref);
122
126
  const extractionCache = new Map();
123
- const before = loadIndex(cwd, from, resolvedPaths, extractionCache);
124
- const after = loadIndex(cwd, to, resolvedPaths, extractionCache);
127
+ // A repository past the file limit still indexes the files this diff changes.
128
+ const changed = changedPaths(cwd, from, to);
129
+ const before = loadIndex(cwd, from, resolvedPaths, extractionCache, changed);
130
+ const after = loadIndex(cwd, to, resolvedPaths, extractionCache, changed);
125
131
  extractionCache.clear();
126
132
  options.onIndexes?.(before, after);
127
133
  const fromLabel = describeSnapshot(from);