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,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,16 +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
- /** How much a suggested comment matters, most to least: what a reviewer triages by. */
136
- export declare const COMMENT_SEVERITIES: readonly ["critical", "major", "minor"];
137
- export type CommentSeverity = (typeof COMMENT_SEVERITIES)[number];
138
- /** 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. */
139
142
  export interface SuggestedComment {
140
143
  path: string;
141
144
  line: number;
142
145
  side: "LEFT" | "RIGHT";
146
+ /** The reviewer's own words; the only field that joins the human's draft. */
143
147
  body: string;
144
- severity: CommentSeverity;
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;
145
153
  }
146
154
  export interface AgentComments {
147
155
  comments: SuggestedComment[];
@@ -180,7 +188,7 @@ export interface ReviewReport {
180
188
  questions: ReviewQuestion[];
181
189
  /** The reading order the reviewing agent recorded; once present, `items` follow it. */
182
190
  agentOrder?: AgentOrder;
183
- /** 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. */
184
192
  agentComments?: AgentComments;
185
193
  /**
186
194
  * The reviewing agent's own paragraph on the pull request's goal, written
@@ -189,6 +197,8 @@ export interface ReviewReport {
189
197
  * agent's reading rather than a verified claim.
190
198
  */
191
199
  agentSummary?: AgentSummary;
200
+ /** A newer diffninja exists on npm; set only by the executable's opt-in version lookup. */
201
+ updateNotice?: UpdateNotice;
192
202
  /**
193
203
  * Every function a reader meets in this report (around the hunks and in the
194
204
  * call flows), each with a stable `<file>#<name>` id, for the reviewing agent
@@ -1,2 +1,2 @@
1
- /** How much a suggested comment matters, most to least: what a reviewer triages by. */
2
- export const COMMENT_SEVERITIES = ["critical", "major", "minor"];
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);