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
package/README.md CHANGED
@@ -10,7 +10,7 @@ submit the review to GitHub yourself.
10
10
 
11
11
  - **Read the important changes first.** Big PRs are hard to follow file by
12
12
  file. diffninja numbers each change, most important first, and lets you step
13
- through them with `j` and `k`.
13
+ through them with `j` and `k` and tick them off.
14
14
  - **See what the change does to the product, not just the code.** Your agent
15
15
  explains the change in business terms: the processes it touches as
16
16
  flowcharts, with new and changed steps highlighted, the business rules it
@@ -19,15 +19,29 @@ submit the review to GitHub yourself.
19
19
  - **Your agent does the first pass.** For each change, the agent answers simple
20
20
  questions: does it change behavior, is it tested, does it match the PR's
21
21
  goal. The answers sit above the code.
22
- - **Suggested comments, never posted for you.** The agent can suggest short
23
- line comments. You add them with one click, edit them, or dismiss them.
24
- Nothing reaches GitHub until you press Submit.
22
+ - **Suggested comments are blockers only, never posted for you.** The agent
23
+ suggests a comment only for what blocks the merge, and shows its proof: the
24
+ case that fails, how it checked, and what would make it fine. No comment at
25
+ all is the normal result, and anything that does not block is dropped. You
26
+ add a comment with one click, edit it, or dismiss it.
27
+ None of diffninja's tools posts anything. The review reaches GitHub when
28
+ Submit is pressed on your review page, and your agent is told never to do
29
+ that itself.
25
30
  - **Facts you can check.** diffninja points at the exact lines that changed a
26
31
  comparison, a limit, an input check, or error handling. It also shows call
27
32
  flows: which functions call the changed code.
28
- - **Private.** The analysis runs on your machine. diffninja calls no AI model,
29
- needs no API key, and sends your code nowhere. It uses your existing GitHub
30
- CLI login to read the PR and post your review.
33
+ - **The analysis runs on your machine.** diffninja calls no AI model, needs no
34
+ API key, has no telemetry of its own, and does not download or build code
35
+ during a review. It makes no request of its own while it reviews, except an
36
+ update notice that is off unless you turn it on. It uses your existing GitHub
37
+ CLI login to read the PR, to post your review when Submit is pressed, and to
38
+ mark a file Viewed on GitHub when you tick its changes.
39
+ Installing diffninja and adding language grammars use npm. Your agent sends
40
+ what diffninja returns, source text included, to its own model, as it does
41
+ with any tool result. It is not "local only" in every respect.
42
+ [docs/security.md](docs/security.md) lists what runs, what is downloaded, what
43
+ is written and what your agent can reach, including the parts that are not
44
+ local.
31
45
 
32
46
  ## What you need
33
47
 
@@ -45,8 +59,23 @@ Run this once:
45
59
  npx -y diffninja@latest setup
46
60
  ```
47
61
 
48
- It installs diffninja and adds it to every agent CLI it finds on your machine.
49
- Then restart your agent CLI.
62
+ It installs diffninja globally with `npm install -g` (if that fails, it registers
63
+ an `npx` entry instead), and adds diffninja to every agent CLI it finds on your
64
+ machine by editing that CLI's config file. Then restart your agent CLI.
65
+
66
+ Setup rewrites a JSON config file in full, in standard formatting, and keeps no
67
+ backup. Indentation, string escapes and integers above 2^53 in it can change.
68
+ `~/.claude.json` is Claude Code's main state file, so copy it before the first
69
+ run (`cp ~/.claude.json ~/.claude.json.bak`).
70
+
71
+ [docs/security.md](docs/security.md) and this README describe diffninja 0.4.0
72
+ and later. Version 0.3.2 and earlier behave as two security audits found. The
73
+ review page's submit API had no secret in its path, so any local process could
74
+ use it. Reviews installed grammars with npm at review time. Hidden characters
75
+ were not marked, and the package had no pinned dependency tree.
76
+ `npx -y diffninja@latest setup` installs the newest published version. Check
77
+ that it is 0.4.0 or later with `npm ls -g diffninja`, or read the version in the
78
+ entry setup wrote to your agent's config.
50
79
 
51
80
  To update later, run the same command again: setup brings an older global
52
81
  install up to its own version, then restart your agent CLI. (Setup from
@@ -76,6 +105,12 @@ your machine at `127.0.0.1`). On the page:
76
105
  `j` and `k` move to the next and previous change, and the list on the left
77
106
  shows where you are. The agent sets the reading order; the connected page
78
107
  does not label changes “Attention”.
108
+ Tick a change in that list when you have read it. Scrolling ticks nothing.
109
+ GitHub's **Viewed** mark belongs to a file, so a file is marked Viewed on
110
+ GitHub once every change of it is ticked, and unticking one clears the mark.
111
+ A file already marked Viewed on GitHub starts ticked. If diffninja cannot
112
+ read or write the marks, the page says "not synced with GitHub" and keeps
113
+ your ticks in the tab.
79
114
  2. Hover a line and press **+** to write a comment, or add the agent's
80
115
  suggestions.
81
116
  3. Write a summary, choose **Comment**, **Approve**, or **Request changes**.
@@ -84,7 +119,21 @@ your machine at `127.0.0.1`). On the page:
84
119
 
85
120
  Tip: if your agent is running inside a local clone of the repository, it can
86
121
  pass the clone to diffninja. You then also get call-flow diagrams for the
87
- changed files.
122
+ changed files. Call flows read JavaScript and TypeScript out of the box. For
123
+ other languages (Python, Go, Java, Rust, C#, Ruby, and more), install their
124
+ grammars once. This step downloads grammar code through npm at exact versions,
125
+ checked against hashes shipped with diffninja, and runs no install script. Use
126
+ the version of diffninja your agent runs, because diffninja may not read
127
+ grammars that another version installed. A review that needs them says which
128
+ files its call flows skipped and prints the exact command, which has the form
129
+ `npx -y diffninja@<version> grammars install`. Kotlin and Perl also need
130
+ `--build`, which compiles them on your machine and runs their install scripts.
131
+ A review never installs anything itself.
132
+
133
+ If the local clone is a partial clone (for example made with
134
+ `git clone --filter=blob:none`), git may fetch missing objects from that clone's
135
+ own remote while diffninja reads it, as `git log -p` would. diffninja itself
136
+ never runs a fetch or a checkout.
88
137
 
89
138
  You can also review changes that aren't a PR yet:
90
139
 
@@ -100,7 +149,12 @@ and gives you a link to read the same report in your browser.
100
149
  - The review page closes when you exit your agent CLI.
101
150
  - The page and the agent's session contain source code. Treat them like the
102
151
  code itself.
103
- - diffninja never approves, blocks, or merges anything on its own.
152
+ - No diffninja tool approves, blocks, merges or posts anything. A review is
153
+ submitted when Submit is pressed on the review page. The page's one other
154
+ write to GitHub is a file's Viewed mark, which follows your ticks. Anyone
155
+ holding that page's link can do both through the page's API, and your agent
156
+ holds the link. diffninja tells the agent never to submit. It cannot enforce
157
+ that.
104
158
 
105
159
  ## More
106
160
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Where a command the review runs (`git`, `gh`) really is.
3
+ *
4
+ * On Windows the runtime looks for a bare command name in the child's working
5
+ * directory before it looks at PATH. Every git call runs with the repository under
6
+ * review as its working directory, so a `git.exe` committed in that repository, or
7
+ * a `gh.exe` in the directory the agent was started in, would run instead of the
8
+ * real tool as soon as its branch is checked out. Here the name is resolved once
9
+ * against PATH's absolute entries only, to a full path, and that path is what runs.
10
+ * Elsewhere the name is returned as it is: PATH lookup does not search the
11
+ * working directory.
12
+ */
13
+ export interface ExecutableLookup {
14
+ readonly platform?: NodeJS.Platform;
15
+ readonly env?: NodeJS.ProcessEnv;
16
+ readonly exists?: (path: string) => boolean;
17
+ }
18
+ export declare function resolveExecutable(name: string, lookup?: ExecutableLookup): string;
@@ -0,0 +1,32 @@
1
+ import { existsSync } from "node:fs";
2
+ import { win32 } from "node:path";
3
+ /** Only real executables: a `.cmd` or `.bat` cannot be started without a shell. */
4
+ const EXTENSIONS = [".exe", ".com"];
5
+ const resolved = new Map();
6
+ export function resolveExecutable(name, lookup = {}) {
7
+ const platform = lookup.platform ?? process.platform;
8
+ if (platform !== "win32")
9
+ return name;
10
+ const env = lookup.env ?? process.env;
11
+ const cacheable = lookup.env === undefined && lookup.exists === undefined;
12
+ const known = cacheable ? resolved.get(name) : undefined;
13
+ if (known !== undefined)
14
+ return known;
15
+ const exists = lookup.exists ?? existsSync;
16
+ const path = env["PATH"] ?? env["Path"] ?? "";
17
+ for (const raw of path.split(";")) {
18
+ const directory = raw.trim().replace(/^"|"$/g, "");
19
+ // A relative or empty entry means "here": never a place to find a tool.
20
+ if (directory === "" || !win32.isAbsolute(directory))
21
+ continue;
22
+ for (const extension of EXTENSIONS) {
23
+ const candidate = win32.join(directory, name + extension);
24
+ if (!exists(candidate))
25
+ continue;
26
+ if (cacheable)
27
+ resolved.set(name, candidate);
28
+ return candidate;
29
+ }
30
+ }
31
+ throw new Error(`${name} was not found on PATH. Only absolute PATH entries are searched, never the repository or the current directory.`);
32
+ }
package/dist/git.d.ts CHANGED
@@ -26,7 +26,32 @@ export interface SnapshotFile {
26
26
  oid?: string;
27
27
  size?: number;
28
28
  }
29
- export declare function listSnapshotFiles(cwd: string, snapshot: Snapshot, pathFilters?: string[]): SnapshotFile[];
29
+ /**
30
+ * Bounds on what call-flow analysis parses from one revision: a source file over
31
+ * this size is generated or minified code, not something a person reads. Past
32
+ * the file limit, the files the diff changes are read first, then the rest of
33
+ * their directories, then everything else in code-point order, so the same range
34
+ * leaves out the same files on every machine. The review names what was skipped.
35
+ */
36
+ export declare const MAX_INDEXED_FILE_BYTES: number;
37
+ export declare const MAX_INDEXED_FILES = 15000;
38
+ export interface SkippedSources {
39
+ /** Source files over the size bound. */
40
+ oversized: number;
41
+ /** Files past the count bound. */
42
+ beyondLimit: number;
43
+ }
44
+ /** What was left out of call-flow analysis since the last call; clears the count. */
45
+ export declare function takeSkippedSources(): SkippedSources;
46
+ /**
47
+ * Paths that differ between two snapshots, both sides of a rename included,
48
+ * relative to `cwd` as the snapshot listings are.
49
+ */
50
+ export declare function changedPaths(cwd: string, from: Snapshot, to: Snapshot): Set<string>;
51
+ export declare function listSnapshotFiles(cwd: string, snapshot: Snapshot, pathFilters?: string[], changed?: ReadonlySet<string>, limits?: {
52
+ readonly maxFiles: number;
53
+ readonly maxFileBytes: number;
54
+ }): SnapshotFile[];
30
55
  export declare function visitCommitBlobs(cwd: string, files: SnapshotFile[], visit: (oid: string, source: string) => void): void;
31
56
  export declare function visitWorktreeFiles(cwd: string, files: SnapshotFile[], visit: (file: SnapshotFile, source: string) => void): void;
32
57
  export declare function describeSnapshot(snapshot: Snapshot): string;
package/dist/git.js CHANGED
@@ -1,12 +1,16 @@
1
+ import { resolveExecutable } from "./executables.js";
1
2
  import { execFileSync } from "node:child_process";
2
3
  import { existsSync, lstatSync, readFileSync } from "node:fs";
3
4
  import { resolve } from "node:path";
4
5
  import { listSupportedExtensions } from "./languages/registry.js";
6
+ /** A git command that has not answered in this long (a dead network share) is stopped, not waited for forever. */
7
+ const GIT_TIMEOUT_MS = 120_000;
5
8
  function gitBuffer(cwd, args, input, maxBuffer = 64 * 1024 * 1024) {
6
- return execFileSync("git", ["--no-replace-objects", ...args], {
9
+ return execFileSync(resolveExecutable("git"), ["--no-replace-objects", ...args], {
7
10
  cwd,
8
11
  input,
9
12
  maxBuffer,
13
+ timeout: GIT_TIMEOUT_MS,
10
14
  stdio: ["pipe", "pipe", "pipe"],
11
15
  });
12
16
  }
@@ -183,13 +187,61 @@ function pathAllowed(file, pathFilters) {
183
187
  file.endsWith(normalized));
184
188
  });
185
189
  }
186
- export function listSnapshotFiles(cwd, snapshot, pathFilters = []) {
190
+ /**
191
+ * Bounds on what call-flow analysis parses from one revision: a source file over
192
+ * this size is generated or minified code, not something a person reads. Past
193
+ * the file limit, the files the diff changes are read first, then the rest of
194
+ * their directories, then everything else in code-point order, so the same range
195
+ * leaves out the same files on every machine. The review names what was skipped.
196
+ */
197
+ export const MAX_INDEXED_FILE_BYTES = 1024 * 1024;
198
+ export const MAX_INDEXED_FILES = 15_000;
199
+ /** Paths, so a file left out of both revisions of a diff counts once. */
200
+ const skipped = { oversized: new Set(), beyondLimit: new Set() };
201
+ /** What was left out of call-flow analysis since the last call; clears the count. */
202
+ export function takeSkippedSources() {
203
+ const counts = { oversized: skipped.oversized.size, beyondLimit: skipped.beyondLimit.size };
204
+ skipped.oversized.clear();
205
+ skipped.beyondLimit.clear();
206
+ return counts;
207
+ }
208
+ /**
209
+ * Paths that differ between two snapshots, both sides of a rename included,
210
+ * relative to `cwd` as the snapshot listings are.
211
+ */
212
+ export function changedPaths(cwd, from, to) {
213
+ const refs = [from, to].filter((snapshot) => snapshot.kind === "commit").map((snapshot) => snapshot.ref);
214
+ return new Set(git(cwd, ["diff", "--relative", "--name-only", "-z", "--no-renames", ...refs, "--"]).split("\0").filter(Boolean));
215
+ }
216
+ function directoryOf(path) {
217
+ return path.slice(0, path.lastIndexOf("/") + 1);
218
+ }
219
+ export function listSnapshotFiles(cwd, snapshot, pathFilters = [], changed = new Set(), limits = { maxFiles: MAX_INDEXED_FILES, maxFileBytes: MAX_INDEXED_FILE_BYTES }) {
187
220
  const files = snapshot.kind === "worktree"
188
221
  ? listWorktreeFiles(cwd)
189
222
  : listCommitFiles(cwd, snapshot.ref);
190
- return files
223
+ const wanted = files
191
224
  .filter((file) => pathAllowed(file.path, pathFilters))
192
- .sort((a, b) => a.path.localeCompare(b.path));
225
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
226
+ const small = [];
227
+ for (const file of wanted) {
228
+ if (file.size !== undefined && file.size > limits.maxFileBytes)
229
+ skipped.oversized.add(file.path);
230
+ else
231
+ small.push(file);
232
+ }
233
+ if (small.length <= limits.maxFiles)
234
+ return small;
235
+ const directories = new Set([...changed].map(directoryOf));
236
+ const first = [], neighbours = [], rest = [];
237
+ for (const file of small) {
238
+ (changed.has(file.path) ? first : directories.has(directoryOf(file.path)) ? neighbours : rest).push(file);
239
+ }
240
+ const kept = new Set([...first, ...neighbours, ...rest].slice(0, limits.maxFiles));
241
+ for (const file of small)
242
+ if (!kept.has(file))
243
+ skipped.beyondLimit.add(file.path);
244
+ return small.filter((file) => kept.has(file));
193
245
  }
194
246
  const BATCH_BYTES = 32 * 1024 * 1024;
195
247
  function chunkBlobs(files) {
@@ -0,0 +1,11 @@
1
+ /** Compared without regard to case: Windows spells `Path`, `ComSpec` and `SystemRoot` its own way. */
2
+ export declare function npmEnvironment(env?: NodeJS.ProcessEnv, extra?: Readonly<Record<string, string>>, options?: {
3
+ readonly build?: boolean;
4
+ }): NodeJS.ProcessEnv;
5
+ /**
6
+ * The shell Windows runs a `.cmd` shim with, when no npm JS entry point can be
7
+ * found. ComSpec is used only when it is an absolute path to `cmd.exe`; anything
8
+ * else in that variable would otherwise be written into an agent's config and run
9
+ * every time the agent starts.
10
+ */
11
+ export declare function windowsShell(env?: NodeJS.ProcessEnv): string;
@@ -0,0 +1,60 @@
1
+ import { isAbsolute } from "node:path";
2
+ /**
3
+ * The environment npm gets when diffninja runs it. npm and the install scripts
4
+ * of the packages it fetches inherit their parent's environment, and an engineer's
5
+ * shell carries tokens (GitHub, cloud providers, package registries) that no
6
+ * install has any business seeing. Only what npm and node-gyp need to find their
7
+ * tools, cache, proxy and certificates is passed on; npm's own `npm_config_*`
8
+ * variables are the user's npm configuration and pass too.
9
+ */
10
+ const KEPT = new Set([
11
+ "PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TZ",
12
+ "TMPDIR", "TMP", "TEMP",
13
+ "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME",
14
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY",
15
+ "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR",
16
+ // Windows: without these npm cannot find its own files, Python or the compiler.
17
+ "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA",
18
+ "PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMW6432", "COMMONPROGRAMFILES", "COMMONPROGRAMFILES(X86)",
19
+ "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "PATHEXT", "OS", "PROCESSOR_ARCHITECTURE", "NUMBER_OF_PROCESSORS",
20
+ ]);
21
+ /** What a source build (node-gyp) also looks for: compiler, Python and SDK locations. Only when the person asked for a build. */
22
+ const BUILD_KEPT = new Set(["CC", "CXX", "CFLAGS", "CXXFLAGS", "CPPFLAGS", "LDFLAGS", "PYTHON", "SDKROOT", "DEVELOPER_DIR", "MACOSX_DEPLOYMENT_TARGET", "INCLUDE", "LIB", "LIBPATH"]);
23
+ const BUILD_PREFIXES = ["GYP_", "VSINSTALLDIR", "VCINSTALLDIR", "VCTOOLS", "VSCMD_", "VS1", "VISUALSTUDIO", "WINDOWSSDK", "UNIVERSALCRT", "UCRT"];
24
+ /**
25
+ * The variables the person running diffninja chose to pass to npm as well, named in
26
+ * DIFFNINJA_NPM_ENV (for example `NPM_TOKEN,NODE_AUTH_TOKEN`). A private registry whose
27
+ * `.npmrc` reads its token from the environment answers 401 without it. Names only. Anything
28
+ * else is refused without being echoed, because what was written there may be the secret itself.
29
+ */
30
+ function chosenNames(env) {
31
+ const names = (env["DIFFNINJA_NPM_ENV"] ?? "").split(",").map((name) => name.trim()).filter((name) => name !== "");
32
+ if (names.some((name) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))) {
33
+ throw new Error("DIFFNINJA_NPM_ENV must list only environment variable names separated by commas, such as NPM_TOKEN,NODE_AUTH_TOKEN.");
34
+ }
35
+ return new Set(names.map((name) => name.toUpperCase()));
36
+ }
37
+ /** Compared without regard to case: Windows spells `Path`, `ComSpec` and `SystemRoot` its own way. */
38
+ export function npmEnvironment(env = process.env, extra = {}, options = {}) {
39
+ const chosen = chosenNames(env);
40
+ const out = {};
41
+ for (const [name, value] of Object.entries(env)) {
42
+ if (value === undefined)
43
+ continue;
44
+ const upper = name.toUpperCase();
45
+ const forBuild = options.build === true && (BUILD_KEPT.has(upper) || BUILD_PREFIXES.some((prefix) => upper.startsWith(prefix)));
46
+ if (KEPT.has(upper) || upper.startsWith("NPM_CONFIG_") || upper.startsWith("LC_") || forBuild || chosen.has(upper))
47
+ out[name] = value;
48
+ }
49
+ return { ...out, ...extra };
50
+ }
51
+ /**
52
+ * The shell Windows runs a `.cmd` shim with, when no npm JS entry point can be
53
+ * found. ComSpec is used only when it is an absolute path to `cmd.exe`; anything
54
+ * else in that variable would otherwise be written into an agent's config and run
55
+ * every time the agent starts.
56
+ */
57
+ export function windowsShell(env = process.env) {
58
+ const candidate = env["ComSpec"] ?? env["COMSPEC"];
59
+ return candidate !== undefined && /[\\/]cmd\.exe$/i.test(candidate) && (isAbsolute(candidate) || /^[A-Za-z]:[\\/]/.test(candidate)) ? candidate : "cmd.exe";
60
+ }