jules-orchestrator-kit 0.42.0 → 0.52.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.
@@ -0,0 +1,328 @@
1
+ import { readFileSync, readdirSync, mkdtempSync, rmSync, existsSync, realpathSync } from "node:fs";
2
+ import { join, resolve, relative, isAbsolute } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { fileURLToPath } from "node:url";
5
+ import { execSync } from "node:child_process";
6
+ import { resolveVerify } from "./config.mjs";
7
+
8
+ /**
9
+ * Checks if a file path should be excluded from diff coverage enforcement.
10
+ * Excludes test files, configs, documentation, and agent rules.
11
+ * @param {string} filePath
12
+ * @returns {boolean}
13
+ */
14
+ export function isExcludedFromCoverage(filePath = "") {
15
+ if (!filePath) return true;
16
+ const norm = filePath.replace(/\\/g, "/").toLowerCase();
17
+ if (
18
+ norm.startsWith(".agent/") ||
19
+ norm.startsWith(".github/") ||
20
+ norm.startsWith(".git/") ||
21
+ norm.startsWith("test/") ||
22
+ norm.startsWith("tests/") ||
23
+ norm.includes("/__tests__/") ||
24
+ norm.includes(".test.") ||
25
+ norm.includes(".spec.") ||
26
+ norm.includes("_test.") ||
27
+ norm.endsWith(".md") ||
28
+ norm.endsWith(".json") ||
29
+ norm.endsWith(".yml") ||
30
+ norm.endsWith(".yaml") ||
31
+ norm.endsWith(".lock")
32
+ ) {
33
+ return true;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ /**
39
+ * Maps V8 function range offsets to 1-indexed line hit counts.
40
+ * @param {string} sourceContent - Full text of source file
41
+ * @param {Array<object>} v8Functions - Functions array from V8 coverage JSON
42
+ * @returns {Map<number, number>} Map of lineNo -> hitCount
43
+ */
44
+ export function mapV8RangesToLines(sourceContent = "", v8Functions = []) {
45
+ if (!sourceContent || !Array.isArray(v8Functions) || v8Functions.length === 0) {
46
+ return new Map();
47
+ }
48
+
49
+ const lines = sourceContent.split("\n");
50
+ const lineRanges = [];
51
+ let currentOffset = 0;
52
+ for (let i = 0; i < lines.length; i++) {
53
+ const start = currentOffset;
54
+ const end = currentOffset + lines[i].length;
55
+ lineRanges.push({ lineNo: i + 1, start, end, text: lines[i] });
56
+ currentOffset = end + 1;
57
+ }
58
+
59
+ const allRanges = [];
60
+ for (const fn of v8Functions) {
61
+ if (Array.isArray(fn.ranges)) {
62
+ for (const r of fn.ranges) {
63
+ allRanges.push(r);
64
+ }
65
+ }
66
+ }
67
+
68
+ // Sort ranges: broader outer ranges first
69
+ allRanges.sort((a, b) => {
70
+ if (a.startOffset !== b.startOffset) return a.startOffset - b.startOffset;
71
+ return b.endOffset - a.endOffset;
72
+ });
73
+
74
+ const lineHits = new Map();
75
+
76
+ for (const { lineNo, start, text } of lineRanges) {
77
+ const trimmed = text.trim();
78
+ if (
79
+ !trimmed ||
80
+ trimmed.startsWith("//") ||
81
+ trimmed.startsWith("/*") ||
82
+ trimmed.startsWith("*") ||
83
+ trimmed.startsWith("import ") ||
84
+ trimmed.startsWith("export default {") ||
85
+ trimmed === "};" ||
86
+ trimmed === "}"
87
+ ) {
88
+ continue;
89
+ }
90
+
91
+ const nonWsStart = start + text.search(/\S/);
92
+ const nonWsEnd = start + text.trimEnd().length;
93
+
94
+ let count = 0;
95
+ let matched = false;
96
+
97
+ for (const r of allRanges) {
98
+ if (r.startOffset <= nonWsStart && nonWsEnd <= r.endOffset) {
99
+ count = r.count;
100
+ matched = true;
101
+ }
102
+ }
103
+
104
+ if (matched) {
105
+ lineHits.set(lineNo, count);
106
+ }
107
+ }
108
+
109
+ return lineHits;
110
+ }
111
+
112
+ /**
113
+ * Spawns the verification test command with NODE_V8_COVERAGE and collects raw coverage maps.
114
+ * @param {string} [testCmd] - Command to execute (defaults to package test cmd)
115
+ * @param {object} [options]
116
+ * @param {string} [options.root=process.cwd()]
117
+ * @param {number} [options.timeoutMs=60000]
118
+ * @returns {object} { ok, coverageFiles, coverageByFile, stdout, stderr, exitCode }
119
+ */
120
+ export function runV8Coverage(testCmd, options = {}) {
121
+ const root = options.root || process.cwd();
122
+ const cmd = testCmd || resolveVerify(root).testCmd || "npm test";
123
+ const timeoutMs = options.timeoutMs || 60000;
124
+
125
+ const tempCoverageDir = mkdtempSync(join(tmpdir(), "jules-v8-cov-"));
126
+
127
+ try {
128
+ const env = {
129
+ ...process.env,
130
+ NODE_V8_COVERAGE: tempCoverageDir,
131
+ };
132
+ // Strip child test runner flags to avoid collisions
133
+ for (const k of Object.keys(env)) {
134
+ if (k.startsWith("NODE_TEST_") || k.startsWith("NODE_CHANNEL_")) {
135
+ delete env[k];
136
+ }
137
+ }
138
+
139
+ let stdout = "";
140
+ let stderr = "";
141
+ let exitCode = 0;
142
+
143
+ try {
144
+ stdout = execSync(cmd, {
145
+ cwd: root,
146
+ env,
147
+ timeout: timeoutMs,
148
+ stdio: ["ignore", "pipe", "pipe"],
149
+ encoding: "utf-8",
150
+ });
151
+ } catch (err) {
152
+ exitCode = err.status || 1;
153
+ stdout = err.stdout ? String(err.stdout) : "";
154
+ stderr = err.stderr ? String(err.stderr) : err.message;
155
+ }
156
+
157
+ const coverageByFile = new Map();
158
+ const files = existsSync(tempCoverageDir) ? readdirSync(tempCoverageDir) : [];
159
+
160
+ for (const file of files) {
161
+ if (!file.endsWith(".json")) continue;
162
+ const fullPath = join(tempCoverageDir, file);
163
+ try {
164
+ const content = readFileSync(fullPath, "utf-8");
165
+ const parsed = JSON.parse(content);
166
+ if (Array.isArray(parsed.result)) {
167
+ for (const item of parsed.result) {
168
+ if (!item.url || item.url.startsWith("node:")) continue;
169
+ let relPath = item.url;
170
+ if (relPath.startsWith("file://")) {
171
+ try {
172
+ relPath = fileURLToPath(item.url);
173
+ } catch (_) {
174
+ relPath = relPath.replace(/^file:\/\//, "");
175
+ }
176
+ }
177
+
178
+ let resolvedRoot = root;
179
+ try {
180
+ if (existsSync(root)) resolvedRoot = realpathSync(root);
181
+ } catch (_) {}
182
+
183
+ try {
184
+ if (existsSync(relPath)) relPath = realpathSync(relPath);
185
+ } catch (_) {}
186
+
187
+ if (isAbsolute(relPath)) {
188
+ relPath = relative(resolvedRoot, relPath);
189
+ }
190
+ relPath = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
191
+
192
+ if (!isExcludedFromCoverage(relPath)) {
193
+ if (!coverageByFile.has(relPath)) {
194
+ coverageByFile.set(relPath, []);
195
+ }
196
+ coverageByFile.get(relPath).push(...(item.functions || []));
197
+ }
198
+ }
199
+ }
200
+ } catch (_) {}
201
+ }
202
+
203
+ return {
204
+ ok: exitCode === 0,
205
+ exitCode,
206
+ stdout,
207
+ stderr,
208
+ coverageFiles: files.length,
209
+ coverageByFile,
210
+ };
211
+ } finally {
212
+ try {
213
+ rmSync(tempCoverageDir, { recursive: true, force: true });
214
+ } catch (_) {}
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Parses diff text to extract added line numbers for each modified file.
220
+ * @param {string} diffStr
221
+ * @returns {Map<string, number[]>} Map of filePath -> added line numbers
222
+ */
223
+ export function extractAddedLinesFromDiff(diffStr = "") {
224
+ const fileAddedLines = new Map();
225
+ if (!diffStr) return fileAddedLines;
226
+
227
+ let currentFile = null;
228
+ let currentLineNo = null;
229
+
230
+ const lines = diffStr.split("\n");
231
+ for (let i = 0; i < lines.length; i++) {
232
+ const line = lines[i];
233
+
234
+ if ((line.startsWith("+++ ") || line.startsWith("+++ b/") || line.startsWith("+++ /dev/null")) && !line.startsWith("++++")) {
235
+ const target = line.slice(3).split("\t")[0].trim().replace(/^b\//, "");
236
+ currentFile = target && target !== "/dev/null" ? target : null;
237
+ currentLineNo = null;
238
+ continue;
239
+ }
240
+
241
+ const hunkMatch = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line);
242
+ if (hunkMatch) {
243
+ currentLineNo = parseInt(hunkMatch[1], 10);
244
+ continue;
245
+ }
246
+
247
+ if (currentLineNo === null || !currentFile) continue;
248
+
249
+ if (line.startsWith("+") && !line.startsWith("+++")) {
250
+ if (!isExcludedFromCoverage(currentFile)) {
251
+ if (!fileAddedLines.has(currentFile)) {
252
+ fileAddedLines.set(currentFile, []);
253
+ }
254
+ fileAddedLines.get(currentFile).push(currentLineNo);
255
+ }
256
+ currentLineNo++;
257
+ } else if (!line.startsWith("-") && !line.startsWith("\\")) {
258
+ currentLineNo++;
259
+ }
260
+ }
261
+
262
+ return fileAddedLines;
263
+ }
264
+
265
+ /**
266
+ * Calculates diff coverage percentage by cross-referencing V8 coverage against added lines in the git diff.
267
+ * @param {Map<string, Array<object>>} coverageByFile - Map of relative file path -> V8 function arrays
268
+ * @param {string} diffStr - Unified git diff
269
+ * @param {object} [options]
270
+ * @param {string} [options.root=process.cwd()]
271
+ * @param {number} [options.minCoverage=100] - Expected minimum coverage percentage (0-100)
272
+ * @returns {object} { ok, score, totalLines, coveredLines, missedLines, missedByFile, summary }
273
+ */
274
+ export function calculateDiffCoverage(coverageByFile, diffStr = "", options = {}) {
275
+ const root = options.root || process.cwd();
276
+ const minCoverage = typeof options.minCoverage === "number" ? options.minCoverage : 100;
277
+ const addedLinesMap = extractAddedLinesFromDiff(diffStr);
278
+
279
+ let totalLines = 0;
280
+ let coveredLines = 0;
281
+ const missedByFile = {};
282
+
283
+ for (const [file, addedLines] of addedLinesMap.entries()) {
284
+ const absPath = resolve(root, file);
285
+ if (!existsSync(absPath)) continue;
286
+
287
+ let sourceContent = "";
288
+ try {
289
+ sourceContent = readFileSync(absPath, "utf-8");
290
+ } catch (_) {
291
+ continue;
292
+ }
293
+
294
+ const v8Functions = coverageByFile.get(file) || [];
295
+ const lineHits = mapV8RangesToLines(sourceContent, v8Functions);
296
+
297
+ const missed = [];
298
+ for (const lineNo of addedLines) {
299
+ if (lineHits.has(lineNo)) {
300
+ totalLines++;
301
+ const count = lineHits.get(lineNo);
302
+ if (count > 0) {
303
+ coveredLines++;
304
+ } else {
305
+ missed.push(lineNo);
306
+ }
307
+ }
308
+ }
309
+
310
+ if (missed.length > 0) {
311
+ missedByFile[file] = missed;
312
+ }
313
+ }
314
+
315
+ const score = totalLines > 0 ? Math.round((coveredLines / totalLines) * 10000) / 100 : 100;
316
+ const ok = score >= minCoverage;
317
+
318
+ return {
319
+ ok,
320
+ score,
321
+ minCoverage,
322
+ totalLines,
323
+ coveredLines,
324
+ missedLines: totalLines - coveredLines,
325
+ missedByFile,
326
+ summary: `Diff Coverage: ${score}% (${coveredLines}/${totalLines} added executable lines covered, min: ${minCoverage}%)`,
327
+ };
328
+ }
package/src/engine.mjs CHANGED
@@ -14,7 +14,7 @@ import { appendTelemetry as appendTelemetryUnsafe } from "./telemetry.mjs";
14
14
 
15
15
  import { spawn } from "node:child_process";
16
16
  import { resolveAffectedTests, executeQueueDag } from "./dag-engine.mjs";
17
- import { recordRemediation, queryRemediations, harvestFailureRecord, hydrateMemory } from "./remediation.mjs";
17
+ import { recordRemediation, queryRemediations, harvestFailureRecord, hydrateMemory, createWhackAMoleDetector } from "./remediation.mjs";
18
18
  import { hydratePrompt, harvestFailure } from "./memory.mjs";
19
19
  import { resolveRolePrompt } from "./role-resolver.mjs";
20
20
 
@@ -593,17 +593,23 @@ export async function repair(failure, opts = {}) {
593
593
  threshold: 2,
594
594
  cooldownMs: 60000,
595
595
  });
596
+ const whackAMole = createWhackAMoleDetector({
597
+ maxHistory: Math.max(maxRetries + 2, 8),
598
+ threshold: 2,
599
+ });
596
600
 
597
601
  let currentFailure = failure;
598
602
  const initialFingerprint = fingerprintFailureState(currentFailure, root);
599
603
  breaker.observe(initialFingerprint);
604
+ whackAMole.recordTestOutcome(currentFailure.stderr || currentFailure.message);
605
+ let extraPromptDirective = null;
600
606
 
601
607
  for (let n = 1; n <= maxRetries; n++) {
602
608
  if (progressBus && progressToken) {
603
609
  progressBus.reportProgress(progressToken, Math.round((n / maxRetries) * 100), 100, `OODA Repair attempt ${n}/${maxRetries}`);
604
610
  }
605
611
 
606
- const repairPrompt = buildRepairPrompt(currentFailure, n, config);
612
+ const repairPrompt = buildRepairPrompt(currentFailure, n, config, extraPromptDirective);
607
613
  let session;
608
614
  try {
609
615
  session = await withBudget(
@@ -657,6 +663,7 @@ export async function repair(failure, opts = {}) {
657
663
  const gateRes = await gate({ root, config, fix: false, progressBus, progressToken });
658
664
  if (gateRes.ok) {
659
665
  breaker.reset();
666
+ whackAMole.reset();
660
667
  recordRemediation(root, {
661
668
  fingerprint: initialFingerprint,
662
669
  symptom: currentFailure.stderr || currentFailure.message || "Failure resolved by OODA repair",
@@ -669,6 +676,12 @@ export async function repair(failure, opts = {}) {
669
676
  currentFailure = gateRes.phases.find((p) => p.phase === "verify")?.testResult || failure;
670
677
  const currentFingerprint = fingerprintFailureState(currentFailure, root);
671
678
 
679
+ // Whack-a-Mole test-oscillation check
680
+ const whackCheck = whackAMole.recordTestOutcome(currentFailure.stderr || currentFailure.message);
681
+ if (whackCheck.whackAMole) {
682
+ extraPromptDirective = whackCheck.promptDirective;
683
+ }
684
+
672
685
  const check = breaker.observe(currentFingerprint);
673
686
  if (check.tripped) {
674
687
  harvestFailureRecord(root, {
@@ -800,13 +813,16 @@ export async function pollSessionState(provider, session, opts = {}) {
800
813
  return { ...session, status: String(session.status || "COMPLETED").toUpperCase() };
801
814
  }
802
815
 
803
- function buildRepairPrompt(failure, attempt, _config) {
816
+ function buildRepairPrompt(failure, attempt, _config, extraPromptDirective = null) {
804
817
  const cleanStderr = redactSecrets(failure.stderr || failure.stdout || "Unknown Error");
805
818
 
806
819
  let escalationStrategy = "DIRECT_REPAIR";
807
820
  let escalationDirective = "1. Ground your fix strictly in the error log below. Do NOT guess file contents or function signatures.";
808
821
 
809
- if (attempt === 2) {
822
+ if (extraPromptDirective) {
823
+ escalationStrategy = "WHACK_A_MOLE_PIVOT";
824
+ escalationDirective = `1. ARCHITECTURAL PIVOT: ${extraPromptDirective}`;
825
+ } else if (attempt === 2) {
810
826
  escalationStrategy = "DIAGNOSTIC_ANALYSIS";
811
827
  escalationDirective = "1. DIAGNOSE FIRST: Two attempts have failed. Analyze if the root cause is in a different file or component than where the previous patch was applied.";
812
828
  } else if (attempt >= 3) {
package/src/git.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFileSync, execSync } from "node:child_process";
2
2
  import { readFileSync, existsSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { join, delimiter } from "node:path";
4
4
  import { normalizePath } from "./config.mjs";
5
5
 
6
6
 
@@ -18,6 +18,118 @@ export class GateError extends Error {
18
18
  const DEFAULT_TIMEOUT = 10 * 60 * 1000; // 10 minutes
19
19
  const DEFAULT_MAX_BUFFER = 10 * 1024 * 1024; // 10 MB
20
20
 
21
+ // Windows cmd.exe quoting. `.cmd`/`.bat` shims (npm.cmd, claude.cmd, …) cannot
22
+ // be spawned by CreateProcess, so they must run through cmd.exe, and the
23
+ // command line cmd.exe sees must round-trip every argv element exactly. The
24
+ // rules below follow the C runtime's argument parser and cmd.exe's meta-char
25
+ // escaping — the same algorithm cross-spawn uses — reimplemented here so the
26
+ // kit stays dependency-free.
27
+
28
+ // Characters cmd.exe treats specially outside double quotes.
29
+ const WINDOWS_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
30
+ // A real Windows executable, which CreateProcess can start without a shell.
31
+ const WINDOWS_NATIVE_EXE = /\.(?:com|exe)$/i;
32
+ // An npm-style cmd shim, which re-parses its own arguments and therefore needs
33
+ // its meta chars double-escaped.
34
+ const WINDOWS_CMD_SHIM = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i;
35
+
36
+ /** Escapes cmd.exe meta characters in a command *name*. */
37
+ export function windowsEscapeCommand(command) {
38
+ return String(command).replace(WINDOWS_META_CHARS, "^$1");
39
+ }
40
+
41
+ /**
42
+ * Quotes a single argv element for cmd.exe.
43
+ *
44
+ * @param {string} arg
45
+ * @param {boolean} [doubleEscapeMetaChars] true when the command is a cmd-shim
46
+ * that will re-parse its own arguments (node_modules/.bin/*.cmd).
47
+ * @returns {string}
48
+ */
49
+ export function windowsEscapeArgument(arg, doubleEscapeMetaChars = false) {
50
+ let ret = String(arg);
51
+ // Backslash runs immediately before a double quote: double the backslashes
52
+ // and escape the quote.
53
+ ret = ret.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"');
54
+ // A backslash run at the very end will sit in front of the closing quote we
55
+ // add next: double it so it stays literal.
56
+ ret = ret.replace(/(?=(\\+?)?)\1$/, "$1$1");
57
+ ret = `"${ret}"`;
58
+ ret = ret.replace(WINDOWS_META_CHARS, "^$1");
59
+ if (doubleEscapeMetaChars) ret = ret.replace(WINDOWS_META_CHARS, "^$1");
60
+ return ret;
61
+ }
62
+
63
+ /**
64
+ * Resolves `binary` against PATH + PATHEXT on Windows.
65
+ *
66
+ * @param {string} binary
67
+ * @param {NodeJS.ProcessEnv} env
68
+ * @returns {string | null}
69
+ */
70
+ function resolveWindowsCommand(binary, env) {
71
+ const pathExt = (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean);
72
+ const hasSeparator = /[\\/]/.test(binary) || /^[A-Za-z]:/.test(binary);
73
+
74
+ const candidates = [];
75
+ if (hasSeparator) {
76
+ candidates.push(binary);
77
+ for (const ext of pathExt) candidates.push(binary + ext);
78
+ } else {
79
+ const dirs = (env.PATH || "").split(delimiter).filter(Boolean);
80
+ for (const dir of dirs) {
81
+ for (const ext of pathExt) candidates.push(join(dir, binary + ext));
82
+ candidates.push(join(dir, binary));
83
+ }
84
+ }
85
+
86
+ for (const candidate of candidates) {
87
+ try {
88
+ if (existsSync(candidate)) return candidate;
89
+ } catch (_) {}
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Builds the spawn arguments that correctly execute `binary` with `args` on
96
+ * Windows, handling the `.cmd`/`.bat` shims package managers and CLI providers
97
+ * install. Returns null on non-Windows so callers keep spawning the binary
98
+ * directly (`P-07`).
99
+ *
100
+ * @param {string} binary
101
+ * @param {string[]} args
102
+ * @param {NodeJS.ProcessEnv} [env]
103
+ * @param {NodeJS.Platform} [platform]
104
+ * @returns {{ file: string, args: string[], verbatim: boolean } | null}
105
+ */
106
+ export function resolveWindowsSpawn(binary, args, env = process.env, platform = process.platform) {
107
+ if (platform !== "win32") return null;
108
+
109
+ const argArray = (args || []).map((a) => String(a));
110
+ const resolved = resolveWindowsCommand(binary, env);
111
+
112
+ // A native executable is spawned directly — its argv needs no quoting.
113
+ if (resolved && WINDOWS_NATIVE_EXE.test(resolved)) {
114
+ return { file: resolved, args: argArray, verbatim: false };
115
+ }
116
+
117
+ // Everything else (`.cmd`, `.bat`, or an unresolved name) goes through
118
+ // cmd.exe. The `/s /c` invocation plus one outer quote pair is the shape
119
+ // cmd.exe expects so the pre-quoted argv parses back into the same elements.
120
+ const commandName = resolved || binary;
121
+ const doubleEscape = WINDOWS_CMD_SHIM.test(resolved || commandName);
122
+ const shellCommand = [windowsEscapeCommand(commandName)]
123
+ .concat(argArray.map((a) => windowsEscapeArgument(a, doubleEscape)))
124
+ .join(" ");
125
+
126
+ return {
127
+ file: (env && env.ComSpec) || "cmd.exe",
128
+ args: ["/d", "/s", "/c", `"${shellCommand}"`],
129
+ verbatim: true,
130
+ };
131
+ }
132
+
21
133
  export function runCmd(command, opts = {}) {
22
134
  const cwd = opts.cwd || process.cwd();
23
135
  const timeout = opts.timeout || DEFAULT_TIMEOUT;
@@ -53,14 +165,23 @@ export function runCmd(command, opts = {}) {
53
165
  // Windows install, and the gate reported it as a plain non-zero verification
54
166
  // rather than as an environment problem.
55
167
  //
56
- // Node's `shell: true` rebuilds the command line as `[file, ...args].join(" ")`
57
- // and passes it to cmd.exe verbatim. That reconstruction is normally lossy —
58
- // it does not quote an argument containing a space — but it is exact here,
59
- // because these tokens were produced by splitting on whitespace in the first
60
- // place. It is also only reached when the command contains no shell
61
- // metacharacter, since those take the execSync branch above. An array command
62
- // is excluded: its elements may legitimately contain spaces.
168
+ // A whitespace-split string command is re-run under `shell: true`: Node
169
+ // rebuilds the command line as `[file, ...args].join(" ")` and passes it to
170
+ // cmd.exe verbatim. That reconstruction is normally lossy — it does not quote
171
+ // an argument containing a space — but it is exact here, because these tokens
172
+ // were produced by splitting on whitespace in the first place, and none of
173
+ // them can contain a shell metacharacter (those took the execSync branch).
174
+ //
175
+ // An *array* command cannot use that trick: its elements may legitimately
176
+ // contain spaces, so the same join would corrupt argv. Arrays are therefore
177
+ // wrapped in cmd.exe with every element quoted per the C-runtime + cmd.exe
178
+ // rules, which preserves argv exactly while still letting the shell resolve
179
+ // the `.cmd` shim (P-07).
63
180
  const winShim = tokenized && process.platform === "win32";
181
+ const winSpawn =
182
+ !useShell && !winShim && process.platform === "win32"
183
+ ? resolveWindowsSpawn(binary, args, opts.env || process.env)
184
+ : null;
64
185
 
65
186
  if (!binary && !useShell) {
66
187
  if (opts.ignoreError) return { status: 0, stdout: "", stderr: "" };
@@ -77,10 +198,11 @@ export function runCmd(command, opts = {}) {
77
198
  timeout,
78
199
  maxBuffer,
79
200
  })
80
- : execFileSync(binary, args, {
201
+ : execFileSync(winSpawn ? winSpawn.file : binary, winSpawn ? winSpawn.args : args, {
81
202
  cwd,
82
203
  encoding: "utf-8",
83
204
  shell: winShim,
205
+ windowsVerbatimArguments: Boolean(winSpawn && winSpawn.verbatim),
84
206
  stdio: ["ignore", "pipe", "pipe"],
85
207
  env: opts.env || process.env,
86
208
  timeout,