Skip to content

Mutation gate spawns the extensionless .bin/stryker shim without a shell, which cannot start on Windows #2410

Open

Description

@easonLiangWorldedtech

Mutation gate spawns the extensionless .bin/stryker shim without a shell, which cannot start on Windows

Measured locally on 2026-11-10 by lane f0-1528 while preflighting unit 2 of the #2226 split. It is a finding about how scripts/stryker-diff.mjs starts Stryker. It is not a report of a CI failure, and the boundary section says what was measured and what was not.

The code shape

Every line number below is counted on blob 7dfe0006d2d5c7e4d2282f65f68b8ed5f16866bc as printed by git cat-file -p (30,108 bytes, 869 lines). That is the blob's own numbering, not a working-tree numbering.

runStryker is declared at line 406. Line 421 reads:

const result = spawnSync(path.join(repoRoot, "node_modules/.bin/stryker"), args, {

The options written inside that call, lines 422 to 436, set cwd to the package run root, encoding to utf8, a 12 minute timeout, a 50 MB maxBuffer, and a rebuilt env carrying STRYKER_VITEST_CONFIG, STRYKER_REPORT_DIR, STRYKER_TEMP_DIR, STRYKER_IN_PLACE, STRYKER_VITEST_RELATED and STRYKER_TEST_FILES. It never sets shell. The args array built at lines 412 to 419 is "run", the repo stryker.config.mjs path, "--force", "--mutate", the package's selectors joined with commas, and for a preflight --dryRunOnly --reporters clear-text --logLevel info. Lines 445 to 449 are where a failed start lands:

if (result.error) {
  throw new Error(
    `${packageEntry.id} Stryker ${dryRunOnly ? "preflight" : "run"} could not start: ${result.error.message}`,
  )
}

Line 847 calls selectFromGit and line 858 calls runManifest. selectFromGit emits a package only when it has changed executable lines, capped by lines 11 and 12 at 500 changed lines and 400 mutants. So line 421 is reached exactly when at least one package is selected.

The measured pair

Environment: a Windows worktree of this repo with the dependency tree installed, node v25.9.0, pnpm 10.8.1, git version 2.54.0.windows.1, platform win32 x64. Stryker is installed. The store entry is @stryker-mutator+core@10.0.0_@types+node@22.20.5, and node_modules/.bin holds three entries for it: stryker (1,704 bytes, 18 lines, first line #!/bin/sh, sha256 ac9e555248c8dc7df1d64cf3d4c3c6765d9d0c7a6c8bf5ad367f593791221150), stryker.CMD (1,564 bytes) and stryker.ps1 (2,419 bytes). The shim's own lines 14 and 16 exec $basedir/../@stryker-mutator/core/bin/stryker.js.

Two spawnSync calls on the same path with the same argv ["--version"], same cwd, same env, printed inside 2026-11-10T14:57:04.409Z to 2026-11-10T14:57:05.886Z:

  • spawnSync(path, ["--version"], { cwd, encoding: "utf8", timeout: 30000 }): status null, signal null, error.code ENOENT, syscall spawnSync, path set to the shim, stdout empty, stderr empty, returned within 1 ms.
  • the same call with shell: true: status 0, signal null, no error, stdout 10.0.0, stderr empty, 1.475 s.

The only difference between the two calls is the option line 421 does not pass. An earlier run of the same pair at 14:27:12.655Z to 14:27:13.103Z returned the same two shapes in 0.45 s, so this is reproducible rather than a one-off.

The gate reaches this line locally and reports it as an advisory

The pair above is a probe. Here is the gate itself reaching the same line, run on the same machine against a real unit delta, inside 2026-11-10T15:02:23.394Z to 15:02:23.971Z. The preflight log at .zdt-mutation-preflight.log (242 bytes, content digest sha256 bffe4ee0c33aaca3ccad65dd53765d78736e39089f90c138b60f705dc1a8b03e) reads, verbatim:

Mutation-testing 1 package(s) from merge base 82ce1a411f78: extension (15 lines)
::warning title=Mutation test advisory::extension Stryker preflight could not start: spawnSync C:\work\Zoo-Code-fork\wt-u2-2226\node_modules\.bin\stryker ENOENT

So a package was selected, line 421 ran, and the message at lines 445 to 449 fired. Where that throw goes is visible in the same blob. runManifest wraps each package in a try, and its catch at lines 806 to 823 pushes error.message into advisories and pushes a result row with valid, killed, timeout, survived and noCoverage all defaulted to 0 and result: "Advisory incomplete". Line 688 prints every advisory through formatAdvisoryCommand. The only place the script sets a nonzero status is the top-level catch of main at lines 862 to 866, which this path never reaches, because the per-package error was already caught.

The consequence is the part worth arguing about. That run exited 0. Its digest at .zdt-mutation-preflight.json (467 bytes, content digest sha256 57c0271353d39605a6c37af9e54b29511ad7f4f864b649168049ba90801f8d91) reads "exitCode": 0, "packages": [], "pass": true. The generated PR comment row reads extension | 15 | 0 | 0 | 0 | 0 | 0 | Advisory incomplete under the marker line <!-- zoo-code-mutation-report head:cbf11148aea5a7390e88759be077e6fd1c035068 blocking:0 advisories:1 -->. On Windows the gate therefore reaches a spawn that cannot succeed, converts its failure into an advisory, and returns green with zero mutants examined. A reader of that row sees six zeros and a word that sounds like a soft warning. A reader of the digest sees pass: true. Neither reader sees that no mutant was ever run.

Why it bites on Windows

The launch failure is locally reproduced, not inferred from code shape. This unit's own preflight selected package extension at 15 changed lines and printed the Advisory incomplete row, and the same call shape that scripts/stryker-diff.mjs uses at line 421 (spawnSync(path.join(repoRoot, "node_modules/.bin/stryker"), args, {...}) with no shell key) was replayed against this worktree's launcher: status null, signal null, error.code ENOENT, syscall spawnSync, about 1 ms, while the identical call with shell true returns status 0 and stdout 10.0.0 in 1.475 s. So the ENOENT that line 421 raises is reproducible on this windows-latest style path, and the advisory row it produces is what a windows-latest mutation-diff run would swallow. What has not been read is any windows-latest mutation-diff log that actually reaches line 421: no such log has been fetched, so the CI-side consequence stays inferred from the exit code path at lines 440 to 449 and the per-package catch at lines 806 to 823.

spawnSync hands the path to CreateProcess, which cannot execute a POSIX #!/bin/sh script, and it does not apply PATHEXT to a file with no extension. An extensionless pnpm shim in node_modules/.bin is unspawnable unless something shell-like runs it. pnpm writes all three forms because different launchers need different ones, and line 421 picks the one form that only a shell can start.

The failure reads wrong. It is not a mutant that survived and not a test that failed. It is "<id> Stryker preflight could not start: spawnSync <path> ENOENT", which reads as if the mutation toolchain were broken on the machine, and it spends a whole gate run to learn nothing about mutants.

Boundary, stated honestly

What I measured is the launcher shape: the two spawnSync calls above against this installed shim on this machine, plus the shape of the spawn the script performs at line 421 of the quoted blob, plus the one local gate run in the section above, which selected the extension package, reached line 421, printed the advisory and exited 0. What I did not measure is any CI behaviour. I have not read a windows-latest mutation-diff log that reached line 421, I have not seen a windows mutation run fail, and the mutation-diff rows I have read in this campaign are ubuntu-latest rows with packages selected, where the extensionless shim starts fine. So this is not a claim that the gate is red on Windows today. It is a claim that the code path is unspawnable on Windows whenever a package is selected, and that anyone running the gate or the local preflight on Windows with a selected package should expect ENOENT where they expect a mutation report.

Options to price, not verdicts

  1. shell: true at line 421. Cheapest, and it is the option my probe used to get status 0. It changes quoting for every entry in args, and those entries include an absolute config path and a comma joined selector list, so it wants a check that nothing in them gets eaten by cmd.exe.
  2. Branch on process.platform and spawn node_modules/.bin/stryker.CMD on Windows. Keeps shells out of the POSIX path, adds a launcher to maintain, and .CMD quoting has its own edges.
  3. Spawn process.execPath with node_modules/@stryker-mutator/core/bin/stryker.js, the entrypoint the shim itself execs. Portable and single-launcher. Worth knowing: git grep -n "process.execPath" -- scripts prints no hits in this repo's scripts directory, so this would be a new shape there, while the automation tooling driving this campaign from a separate fork already spawns process.execPath with the gate script path - that is a tool in that fork, not a file in this repository, so treat it as background rather than as a citation here.
  4. Whatever launcher changes, keep the 12 minute timeout, the 50 MB maxBuffer and the rebuilt env, and pair the change with a Windows run that selects at least one package, so the path is exercised instead of assumed. A green windows row on a run that selected zero packages would not prove the fix. The advisory path at lines 806 to 823 is worth a look on the same occasion: a selected package whose launcher fails should not be able to exit 0.

Repro

const { spawnSync } = await import("node:child_process")
const p = "node_modules/.bin/stryker"
console.log(spawnSync(p, ["--version"], { encoding: "utf8" }))               // status null, error.code ENOENT
console.log(spawnSync(p, ["--version"], { encoding: "utf8", shell: true }))  // status 0, stdout 10.0.0

Run it from a Windows checkout with the dependency tree installed. On Linux or macOS both calls start Stryker.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions