diffninja 0.2.0 → 0.3.1

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.
@@ -3,13 +3,15 @@
3
3
  * agent CLI (Claude Code, Codex, OMP, pi) with one command.
4
4
  *
5
5
  * The setup installs the package globally first so the registration points
6
- * at a permanent binary instead of the npx cache. When the global install is
7
- * unavailable it falls back to an npx-based entry and says so. The server
6
+ * at a permanent binary instead of the npx cache, and brings a global install
7
+ * older than itself up to its own version, so running the newest setup is how
8
+ * a user updates. When the global install is unavailable it falls back to an
9
+ * npx-based entry pinned to its own version and says so. The server
8
10
  * needs no key or environment: reviews are local, and connected reviews reuse
9
11
  * the `gh` session.
10
12
  */
11
13
  import { type Stats } from "node:fs";
12
- export declare const setupHelp = "diffninja setup. Register the diffninja MCP server on every detected agent CLI.\n\n npx -y diffninja 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`) so the registration keeps working; when that\ninstall fails it registers an npx-based entry instead 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 the global install and 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\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";
13
15
  export declare const CLI_NAMES: readonly ["claude", "codex", "omp", "pi"];
14
16
  export type CliName = (typeof CLI_NAMES)[number];
15
17
  export interface McpEntry {
@@ -29,7 +31,8 @@ export interface SetupOptions {
29
31
  }
30
32
  export interface Npm {
31
33
  rootG(): Promise<string>;
32
- installG(): Promise<boolean>;
34
+ /** `npm install -g <spec>`, e.g. `diffninja@0.3.1`; true on success. */
35
+ installG(spec: string): Promise<boolean>;
33
36
  }
34
37
  export interface CliReport {
35
38
  cli: CliName;
@@ -44,9 +47,16 @@ export interface SetupReport {
44
47
  }
45
48
  export interface SetupDeps {
46
49
  npm?: Npm;
50
+ /** Version this setup installs and pins; defaults to the running package's. */
51
+ version?: string;
47
52
  }
48
- /** Entry that runs the published package on demand, without an install. */
49
- export declare function npxEntry(platform?: NodeJS.Platform): McpEntry;
53
+ /**
54
+ * Entry that runs the published package on demand, without an install. It
55
+ * names this setup's version: an unversioned package lets npx keep running
56
+ * whichever copy it cached first, while a pinned one changes with each newer
57
+ * setup, which rewrites the entry.
58
+ */
59
+ export declare function npxEntry(platform?: NodeJS.Platform, version?: string): McpEntry;
50
60
  /**
51
61
  * Entry for a command that Windows ships only as a `.cmd` shim, which a client
52
62
  * that spawns without a shell cannot launch. Running npm's JS entry point with
@@ -60,6 +70,14 @@ export declare function windowsCliEntry(name: string, args: readonly string[], c
60
70
  /** Entry that runs the globally installed package. `globalRoot` is `npm root -g`. */
61
71
  export declare function globalEntry(globalRoot: string): McpEntry;
62
72
  export declare function globalEntryExists(globalRoot: string): boolean;
73
+ /** Version of the globally installed package, or undefined when its manifest is missing or unreadable. */
74
+ export declare function globalVersion(globalRoot: string): string | undefined;
75
+ /**
76
+ * Whether a global install at `installed` should be brought up to `version`.
77
+ * A manifest that cannot be read or compared counts as older, so a broken
78
+ * install is repaired; a newer one is never downgraded.
79
+ */
80
+ export declare function globalIsOlder(installed: string | undefined, version: string): boolean;
63
81
  /**
64
82
  * npm runner. Shell-free by design: on Windows npm is a `.cmd` shim, so runs
65
83
  * go through `npmSpawnSpec`, which resolves npm's JS entry point instead.
@@ -3,37 +3,43 @@
3
3
  * agent CLI (Claude Code, Codex, OMP, pi) with one command.
4
4
  *
5
5
  * The setup installs the package globally first so the registration points
6
- * at a permanent binary instead of the npx cache. When the global install is
7
- * unavailable it falls back to an npx-based entry and says so. The server
6
+ * at a permanent binary instead of the npx cache, and brings a global install
7
+ * older than itself up to its own version, so running the newest setup is how
8
+ * a user updates. When the global install is unavailable it falls back to an
9
+ * npx-based entry pinned to its own version and says so. The server
8
10
  * needs no key or environment: reviews are local, and connected reviews reuse
9
11
  * the `gh` session.
10
12
  */
11
13
  import { spawn } from "node:child_process";
12
14
  import { randomBytes } from "node:crypto";
13
15
  import { once } from "node:events";
14
- import { existsSync } from "node:fs";
16
+ import { existsSync, readFileSync } from "node:fs";
15
17
  import { lstat, mkdir, open, readFile, readlink, realpath, rename, rm, stat } from "node:fs/promises";
16
18
  import { homedir } from "node:os";
17
19
  import { basename, delimiter, dirname, join, resolve } from "node:path";
18
20
  import { z } from "zod";
19
21
  import { npmCliPath, npmSpawnSpec } from "../languages/grammars.js";
20
22
  import { removeTomlTable, upsertTomlTable } from "./toml.js";
23
+ import { compareVersions, packageVersion } from "./version.js";
21
24
  export const setupHelp = `diffninja setup. Register the diffninja MCP server on every detected agent CLI.
22
25
 
23
- npx -y diffninja setup [--cli claude,codex,omp,pi] [--dry-run]
26
+ npx -y diffninja@latest setup [--cli claude,codex,omp,pi] [--dry-run]
24
27
  diffninja setup --uninstall [--cli codex]
25
28
 
26
29
  Detects Claude Code, Codex, OMP, and pi from their config files or binaries
27
30
  and registers the diffninja MCP server in each user config, pointing at the
28
31
  globally installed package. Installs the package globally first
29
- (\`npm install -g diffninja\`) so the registration keeps working; when that
30
- install fails it registers an npx-based entry instead and says so.
32
+ (\`npm install -g diffninja@<this version>\`) so the registration keeps
33
+ working, and updates a global install older than this setup, so running
34
+ \`npx -y diffninja@latest setup\` again is how you update. When that install
35
+ fails it registers an npx-based entry pinned to this version and says so.
31
36
 
32
37
  Options:
33
38
  --cli NAMES Only these CLIs, comma-separated: claude,codex,omp,pi.
34
39
  --uninstall Remove the diffninja server from every detected CLI.
35
40
  --dry-run Show what would change, without installing or writing.
36
- --no-install Skip the global install and register npx-based entries.
41
+ --no-install Skip installing or updating the global package; without one,
42
+ register npx-based entries.
37
43
  --help Show this help.
38
44
 
39
45
  The server needs no API key: static reviews run locally, and pull request
@@ -42,9 +48,14 @@ reviews reuse your authenticated gh session.
42
48
  export const CLI_NAMES = ["claude", "codex", "omp", "pi"];
43
49
  const jsonValueSchema = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(jsonValueSchema), z.record(z.string(), jsonValueSchema)]));
44
50
  const jsonObjectSchema = z.object({}).catchall(jsonValueSchema);
45
- /** Entry that runs the published package on demand, without an install. */
46
- export function npxEntry(platform = process.platform) {
47
- const args = ["-y", "-p", "diffninja", "diffninja-mcp"];
51
+ /**
52
+ * Entry that runs the published package on demand, without an install. It
53
+ * names this setup's version: an unversioned package lets npx keep running
54
+ * whichever copy it cached first, while a pinned one changes with each newer
55
+ * setup, which rewrites the entry.
56
+ */
57
+ export function npxEntry(platform = process.platform, version = packageVersion()) {
58
+ const args = ["-y", "-p", `diffninja@${version}`, "diffninja-mcp"];
48
59
  return platform === "win32" ? windowsCliEntry("npx", args) : { command: "npx", args };
49
60
  }
50
61
  /**
@@ -70,6 +81,27 @@ export function globalEntry(globalRoot) {
70
81
  export function globalEntryExists(globalRoot) {
71
82
  return existsSync(join(globalRoot, "diffninja", "dist", "review", "mcp-cli.js"));
72
83
  }
84
+ const installedManifestSchema = z.object({ version: z.string() });
85
+ /** Version of the globally installed package, or undefined when its manifest is missing or unreadable. */
86
+ export function globalVersion(globalRoot) {
87
+ try {
88
+ const manifest = installedManifestSchema.safeParse(JSON.parse(readFileSync(join(globalRoot, "diffninja", "package.json"), "utf8")));
89
+ return manifest.success ? manifest.data.version : undefined;
90
+ }
91
+ catch {
92
+ return undefined;
93
+ }
94
+ }
95
+ /**
96
+ * Whether a global install at `installed` should be brought up to `version`.
97
+ * A manifest that cannot be read or compared counts as older, so a broken
98
+ * install is repaired; a newer one is never downgraded.
99
+ */
100
+ export function globalIsOlder(installed, version) {
101
+ if (installed === undefined)
102
+ return true;
103
+ return (compareVersions(installed, version) ?? -1) < 0;
104
+ }
73
105
  /**
74
106
  * npm runner. Shell-free by design: on Windows npm is a `.cmd` shim, so runs
75
107
  * go through `npmSpawnSpec`, which resolves npm's JS entry point instead.
@@ -90,8 +122,8 @@ export function createNpm(overrides = {}) {
90
122
  });
91
123
  return (await exitCode(child)) === 0 ? out.trim() : "";
92
124
  },
93
- async installG() {
94
- return (await exitCode(run(["install", "-g", `--allow-scripts=${INSTALL_SCRIPT_PACKAGES.join(",")}`, "diffninja"], true))) === 0;
125
+ async installG(spec) {
126
+ return (await exitCode(run(["install", "-g", `--allow-scripts=${INSTALL_SCRIPT_PACKAGES.join(",")}`, spec], true))) === 0;
95
127
  },
96
128
  };
97
129
  }
@@ -123,35 +155,70 @@ async function globalRoot(npm) {
123
155
  return undefined;
124
156
  }
125
157
  }
158
+ /** Run one global install; a throw counts as a failed install. */
159
+ async function installGlobal(npm, spec) {
160
+ try {
161
+ return await npm.installG(spec);
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ }
126
167
  /**
127
- * Resolve the registration entry. `--dry-run` never installs: it reports the
128
- * install it would have run and the entry that install would have produced.
168
+ * Resolve the registration entry. A global install older than `version` is
169
+ * updated to it first; one that cannot be updated keeps its entry, with a
170
+ * warning naming the command that updates it. `--dry-run` never installs: it
171
+ * reports the install it would have run and the entry that install would have
172
+ * produced.
129
173
  */
130
- async function resolveEntry(noInstall, npm, quiet, dryRun) {
174
+ async function resolveEntry(noInstall, npm, quiet, dryRun, version) {
175
+ const spec = `diffninja@${version}`;
131
176
  const root = await globalRoot(npm);
132
- if (root !== undefined && globalEntryExists(root))
177
+ if (root !== undefined && globalEntryExists(root)) {
178
+ const installed = globalVersion(root);
179
+ if (!globalIsOlder(installed, version))
180
+ return { entry: globalEntry(root), viaNpx: false };
181
+ const from = installed ?? "an unknown version";
182
+ const update = `npm install -g ${spec}`;
183
+ if (noInstall) {
184
+ if (!quiet)
185
+ console.error(`diffninja: the global install is ${from}, older than this setup (${version}); update it with ${update}.`);
186
+ }
187
+ else if (dryRun) {
188
+ if (!quiet)
189
+ console.log(`diffninja: dry run: would update the global install from ${from} to ${version} (${update}).`);
190
+ }
191
+ else {
192
+ if (!quiet)
193
+ console.log(`diffninja: updating the global install from ${from} to ${version} (${update})...`);
194
+ const updated = await installGlobal(npm, spec);
195
+ const updatedRoot = updated ? await globalRoot(npm) : undefined;
196
+ if (updatedRoot !== undefined && globalEntryExists(updatedRoot))
197
+ return { entry: globalEntry(updatedRoot), viaNpx: false };
198
+ if (!globalEntryExists(root)) {
199
+ if (!quiet)
200
+ console.error("diffninja: global update failed and left no install; registering npx-based entries instead.");
201
+ return { entry: npxEntry(process.platform, version), viaNpx: true };
202
+ }
203
+ if (!quiet)
204
+ console.error(`diffninja: update failed; the global install is still ${globalVersion(root) ?? from}. Update it with ${update}.`);
205
+ }
133
206
  return { entry: globalEntry(root), viaNpx: false };
207
+ }
134
208
  if (dryRun) {
135
209
  if (!noInstall && root !== undefined) {
136
210
  if (!quiet)
137
- console.log("diffninja: dry run: would install the package globally (npm install -g diffninja).");
211
+ console.log(`diffninja: dry run: would install the package globally (npm install -g ${spec}).`);
138
212
  return { entry: globalEntry(root), viaNpx: false };
139
213
  }
140
214
  if (!quiet)
141
215
  console.error("diffninja: dry run: global install unavailable; would register npx-based entries instead.");
142
- return { entry: npxEntry(), viaNpx: true };
216
+ return { entry: npxEntry(process.platform, version), viaNpx: true };
143
217
  }
144
218
  if (!noInstall) {
145
219
  if (!quiet)
146
- console.log("diffninja: installing the package globally (npm install -g diffninja)...");
147
- let installed = false;
148
- try {
149
- installed = await npm.installG();
150
- }
151
- catch {
152
- installed = false;
153
- }
154
- if (installed) {
220
+ console.log(`diffninja: installing the package globally (npm install -g ${spec})...`);
221
+ if (await installGlobal(npm, spec)) {
155
222
  const installedRoot = await globalRoot(npm);
156
223
  if (installedRoot !== undefined && globalEntryExists(installedRoot)) {
157
224
  return { entry: globalEntry(installedRoot), viaNpx: false };
@@ -160,7 +227,7 @@ async function resolveEntry(noInstall, npm, quiet, dryRun) {
160
227
  }
161
228
  if (!quiet)
162
229
  console.error("diffninja: global install unavailable; registering npx-based entries instead.");
163
- return { entry: npxEntry(), viaNpx: true };
230
+ return { entry: npxEntry(process.platform, version), viaNpx: true };
164
231
  }
165
232
  function findOnPath(name, pathDirs) {
166
233
  const candidates = process.platform === "win32" ? [name, `${name}.cmd`, `${name}.exe`] : [name];
@@ -486,10 +553,11 @@ export async function runSetup(options = {}, deps = {}) {
486
553
  const quiet = options.quiet === true;
487
554
  const uninstall = options.uninstall === true;
488
555
  const dryRun = options.dryRun === true;
556
+ const version = deps.version ?? packageVersion();
489
557
  let entry;
490
558
  let viaNpx = false;
491
559
  if (!uninstall) {
492
- const resolved = await resolveEntry(options.noInstall === true, deps.npm ?? realNpm, quiet, dryRun);
560
+ const resolved = await resolveEntry(options.noInstall === true, deps.npm ?? realNpm, quiet, dryRun, version);
493
561
  entry = resolved.entry;
494
562
  viaNpx = resolved.viaNpx;
495
563
  }
@@ -545,5 +613,5 @@ export async function runSetup(options = {}, deps = {}) {
545
613
  console.log("note: pi needs the pi-mcp-extension for MCP support (pi install npm:pi-mcp-extension)");
546
614
  }
547
615
  }
548
- return { entry: entry ?? npxEntry(), viaNpx, clis: reports };
616
+ return { entry: entry ?? npxEntry(process.platform, version), viaNpx, clis: reports };
549
617
  }
@@ -2,6 +2,7 @@ import type { PullRequestIntent, ReviewEvidence } from "./evidence-types.js";
2
2
  import type { ChangeFacts } from "./change-facts.js";
3
3
  import type { ReviewQuestion } from "./questions.js";
4
4
  import type { HunkHistory, ProjectContext } from "./history.js";
5
+ import type { AgentExplanation, ExplainedFunction } from "./explanation.js";
5
6
  export type ReviewStatus = "attention" | "uncertain" | "low" | "passed";
6
7
  /**
7
8
  * Structural status for a call-flow node. The engine only knows `same`,
@@ -184,6 +185,20 @@ export interface ReviewReport {
184
185
  * agent's reading rather than a verified claim.
185
186
  */
186
187
  agentSummary?: AgentSummary;
188
+ /**
189
+ * Every function a reader meets in this report (around the hunks and in the
190
+ * call flows), each with a stable `<file>#<name>` id, for the reviewing agent
191
+ * to explain in business terms. Empty for a patch, which resolves none;
192
+ * absent only on a report assembled by hand, which counts as empty.
193
+ */
194
+ functions?: ExplainedFunction[];
195
+ /**
196
+ * The reviewing agent's business explanation: a plain purpose for every
197
+ * listed function, the processes the change touches as steps and decisions,
198
+ * and the business rules it adds, changes, or removes. Attributed to the
199
+ * client that wrote it; never generated by diffninja.
200
+ */
201
+ agentExplanation?: AgentExplanation;
187
202
  /**
188
203
  * Local repository context for a git-range review: prior reverts, contributor
189
204
  * guidelines, and sibling-file conventions. Absent for a patch.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The version of the running diffninja package, read from its own
3
+ * `package.json`. Both layouts put it two levels up: `src/review/` in a
4
+ * checkout and `dist/review/` in the published package, which always ships
5
+ * `package.json`.
6
+ */
7
+ export declare function packageVersion(): string;
8
+ /**
9
+ * Compare two `major.minor.patch` versions: negative when `left` is older,
10
+ * zero when equal, positive when newer. A prerelease sorts before its release;
11
+ * two prereleases of one release compare by their tags as text. Undefined when
12
+ * either is not a version.
13
+ */
14
+ export declare function compareVersions(left: string, right: string): number | undefined;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The version of the running diffninja package, read from its own
3
+ * `package.json`. Both layouts put it two levels up: `src/review/` in a
4
+ * checkout and `dist/review/` in the published package, which always ships
5
+ * `package.json`.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { z } from "zod";
9
+ const manifestSchema = z.object({ version: z.string() });
10
+ let cached;
11
+ export function packageVersion() {
12
+ cached ??= manifestSchema.parse(JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))).version;
13
+ return cached;
14
+ }
15
+ /**
16
+ * Compare two `major.minor.patch` versions: negative when `left` is older,
17
+ * zero when equal, positive when newer. A prerelease sorts before its release;
18
+ * two prereleases of one release compare by their tags as text. Undefined when
19
+ * either is not a version.
20
+ */
21
+ export function compareVersions(left, right) {
22
+ const a = parseVersion(left);
23
+ const b = parseVersion(right);
24
+ if (a === undefined || b === undefined)
25
+ return undefined;
26
+ for (let index = 0; index < 3; index += 1) {
27
+ const difference = a.core[index] - b.core[index];
28
+ if (difference !== 0)
29
+ return difference;
30
+ }
31
+ if (a.prerelease === b.prerelease)
32
+ return 0;
33
+ if (a.prerelease === undefined)
34
+ return 1;
35
+ if (b.prerelease === undefined)
36
+ return -1;
37
+ return a.prerelease < b.prerelease ? -1 : 1;
38
+ }
39
+ function parseVersion(text) {
40
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text.trim());
41
+ if (match === null)
42
+ return undefined;
43
+ return { core: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] };
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffninja",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Local, deterministic PR review for coding agents over MCP: call flows, change facts, and a human review workspace",
5
5
  "type": "module",
6
6
  "exports": {