@avi2dg/checks 0.35.0 → 0.36.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.36.0
6
+
7
+ Released 2026-10-08.
8
+
9
+ ### Features
10
+
11
+ - **testing:** add unitTestFiles so each mutant loads only the unit tests that cover it [#132](https://github.com/avi2d/checks/pull/132)
12
+
13
+ ## 0.35.1
14
+
15
+ Released 2026-10-07.
16
+
17
+ ### Fixes
18
+
19
+ - **delivery:** recognise mutation jobs that run stryker through repository shell scripts [#130](https://github.com/avi2d/checks/pull/130)
20
+
5
21
  ## 0.35.0
6
22
 
7
23
  Released 2026-10-07.
@@ -24,8 +24,16 @@ A path filter cannot cover every pull request and therefore cannot satisfy the c
24
24
  ### Runners
25
25
 
26
26
  It also judges the runner of every job in every workflow.
27
- A mutation job is one with a run step that calls `stryker run`, `checks-mutation`, `checks-mutation-compare` or a `package.json` script that does, directly or through `bun`, `bun run` or `bunx`.
27
+ A mutation job is one with a run step that calls `stryker run`, `checks-mutation` or `checks-mutation-compare`, directly or through `bun`, `bun run` or `bunx`.
28
+ The call counts too through a `package.json` script or a shell script in the repository.
29
+ A command runs a shell script by a path inside the repository, such as `tools/mutate.sh` or `./mutate.sh`, or as a `bun` target that no `package.json` script names.
30
+ The path resolves from the directory the command runs in: the step's `working-directory`, the job's or the workflow's `defaults.run.working-directory`, `bun --cwd` and a literal `cd`.
31
+ A script file runs in its caller's directory, and a `package.json` script runs from the repository root.
32
+ A path the check cannot know before the run, such as one built from an expression or a variable, reaches no file.
33
+ A file counts as a shell script when its name ends in `.sh` or its first line names `sh`, `bash`, `dash`, `ksh` or `zsh`.
28
34
  The call counts as a command of its own or inside a command substitution, never as an argument to another command.
35
+ A here-document that `sh`, `bash`, `dash`, `ksh` or `zsh` reads is a script, and its calls count.
36
+ The text of any other here-document is data, so a call there counts only inside a command substitution when the delimiter is unquoted.
29
37
  A mutation job never names `CI_RUNS_ON` directly in `runs-on`, as `vars.CI_RUNS_ON` or `vars['CI_RUNS_ON']`, so an override for an outage cannot send its full sweeps to hosted runners.
30
38
  Any other job that names `CI_RUNS_ON` in `runs-on` carries exactly the hosted-default expression `${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}`.
31
39
  In a private repository each mutation job carries exactly the labels `[self-hosted, Linux, X64, winbox]` and every other job exactly the hosted-default expression.
@@ -34,7 +42,7 @@ The check knows a repository is private only from GitHub's event, so a run outsi
34
42
 
35
43
  ## What it reads
36
44
 
37
- The bin reads `.github/workflows/*.yml`, `*.yaml` and the `scripts` in `package.json` from the working tree.
45
+ The bin reads `.github/workflows/*.yml`, `*.yaml`, the `scripts` in `package.json` and the shell scripts those steps and scripts run from the working tree.
38
46
  It reads the target branch from `GITHUB_BASE_REF`, then from `refs/remotes/origin/HEAD` and then from the event file `GITHUB_EVENT_PATH` names.
39
47
  It reads whether the repository is private from `repository.private` in that event file.
40
48
  It parses the workflows with `Bun.YAML` without executing them.
@@ -23,6 +23,22 @@ The shared Stryker preset makes the same decision from `process.argv` when a `st
23
23
  So a bare `bunx stryker run` in a repository whose `stryker.conf.mjs` spreads the preset is refused the same way, and `checks-mutation` is a thin wrapper over that decision.
24
24
  The preset also registers an ignore plugin that checks the incremental report once Stryker has resolved its options, because a config file can set `incrementalFile` after the preset loads.
25
25
  A config that replaces `plugins` or `ignorers` must keep the preset's entries, or that check does not run.
26
+ The preset also exports `unitTestFiles`, which lists every `.test.ts` and `.test.tsx` file under `tests/unit` at config load.
27
+ A repository sets `bun.testFiles` to its result, so a covered, non-static mutant loads only the test files that cover it.
28
+ A static mutant has no per-test coverage, so it still loads every listed file.
29
+ The config then reads:
30
+
31
+ ```js
32
+ import preset, { unitTestFiles } from "@avi2dg/checks/stryker.preset.js";
33
+
34
+ export default {
35
+ ...preset,
36
+ bun: {
37
+ ...preset.bun,
38
+ testFiles: unitTestFiles(),
39
+ },
40
+ };
41
+ ```
26
42
 
27
43
  ## What it reads
28
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -47,6 +47,7 @@
47
47
  "src/testing/subsumed-tests.ts",
48
48
  "src/delivery/ci-wiring.ts",
49
49
  "src/delivery/shell-command.ts",
50
+ "src/delivery/mutation-job.ts",
50
51
  "src/delivery/secrets.ts",
51
52
  "src/delivery/gitleaks.ts",
52
53
  "src/delivery/gitleaks.toml",
@@ -3,7 +3,8 @@ import { Config, Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { defaultBranch, git } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
5
  import { ENTRY_POINT, KIT_GATES, type KitGate } from "../core/gates.ts";
6
- import { fromProgram, invokes, mentions, plainCommand, shellCommands, withoutOptions, type Command } from "./shell-command.ts";
6
+ import { readShellFiles, runsMutation, within, type Run } from "./mutation-job.ts";
7
+ import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
7
8
 
8
9
  export type { Command };
9
10
 
@@ -39,6 +40,7 @@ export type Visibility = "private" | "public" | "unknown";
39
40
  export type RunnerPolicy = {
40
41
  readonly visibility: Visibility;
41
42
  readonly scripts: ReadonlyMap<string, string>;
43
+ readonly files?: ReadonlyMap<string, string>;
42
44
  };
43
45
 
44
46
  export type RunnerFault = {
@@ -76,10 +78,6 @@ const RUNNER_OVERRIDE = new RegExp(String.raw`\b${OVERRIDE_ACCESS}`, "i");
76
78
  const HOSTED_OVERRIDE = new RegExp(String.raw`^\$\{\{\s*${OVERRIDE_ACCESS}\s*\|\|\s*'ubuntu-latest'\s*\}\}$`, "i");
77
79
  const MUTATION_RUNNER = ["self-hosted", "Linux", "X64", "winbox"];
78
80
  const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
79
- const MUTATION_BINS = ["checks-mutation", "checks-mutation-compare"];
80
- const BUN_OPERANDS = new Set(["--cwd", "-c", "--config", "--env-file", "-F", "--filter", "-r", "--preload", "--require", "--import", "-e", "--eval", "-p", "--print", "--elide-lines", "--tsconfig-override"]);
81
- // bun's own commands take precedence over a package.json script of the same name unless bun run names it.
82
- const BUN_COMMANDS = new Set(["test", "repl", "exec", "install", "i", "add", "a", "remove", "rm", "update", "outdated", "link", "unlink", "pm", "build", "init", "create", "c", "upgrade", "publish", "patch", "patch-commit", "audit", "info", "why"]);
83
81
  const EventRepository = Schema.fromJsonString(Schema.Struct({ repository: Schema.Struct({ private: Schema.Boolean }) }));
84
82
 
85
83
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -266,29 +264,6 @@ export function declarationFor(scripts: readonly string[], branch: string): Decl
266
264
  };
267
265
  }
268
266
 
269
- function runsMutation(script: string, scripts: ReadonlyMap<string, string>, walked: readonly string[] = []): boolean {
270
- return shellCommands(script).some((command) => mutationCommand(command, scripts, walked));
271
- }
272
-
273
- function mutationCommand(command: Command, scripts: ReadonlyMap<string, string>, walked: readonly string[]): boolean {
274
- const [first = "", ...rest] = fromProgram(command);
275
- return first === "bun" ? bunRunsMutation(rest, scripts, walked) : mutationBin([first, ...rest]);
276
- }
277
-
278
- function bunRunsMutation(args: Command, scripts: ReadonlyMap<string, string>, walked: readonly string[]): boolean {
279
- const [command = "", ...rest] = withoutOptions(args, BUN_OPERANDS);
280
- if (command === "x") return mutationCommand(["bunx", ...rest], scripts, walked);
281
- if (BUN_COMMANDS.has(command)) return false;
282
- const [target = "", ...targetArgs] = command === "run" ? withoutOptions(rest, BUN_OPERANDS) : [command, ...rest];
283
- const body = walked.includes(target) ? undefined : scripts.get(target);
284
- return body === undefined ? mutationBin([target, ...targetArgs]) : runsMutation(body, scripts, [...walked, target]);
285
- }
286
-
287
- function mutationBin([program = "", subcommand]: Command): boolean {
288
- const bin = program.slice(program.lastIndexOf("/") + 1);
289
- return MUTATION_BINS.includes(bin) || (bin === "stryker" && subcommand === "run");
290
- }
291
-
292
267
  function sameLabels(runsOn: unknown, labels: readonly string[]): boolean {
293
268
  const named = Array.isArray(runsOn) ? names(runsOn) : undefined;
294
269
  return named?.length === labels.length && labels.every((label) => named.includes(label));
@@ -310,18 +285,37 @@ function runnerFault(runsOn: unknown, mutation: boolean, visibility: Visibility)
310
285
  return `the job is not on a hosted runner CI_RUNS_ON can override; set runs-on: ${HOSTED_DEFAULT}`;
311
286
  }
312
287
 
288
+ function stepDirectory(value: unknown, inherited: string | undefined): string | undefined {
289
+ if (value === undefined) return inherited;
290
+ return typeof value === "string" ? within("", value) : undefined;
291
+ }
292
+
293
+ function defaultDirectory(node: unknown, inherited: string | undefined): string | undefined {
294
+ const defaults = isRecord(node) ? node["defaults"] : undefined;
295
+ const run = isRecord(defaults) ? defaults["run"] : undefined;
296
+ return stepDirectory(isRecord(run) ? run["working-directory"] : undefined, inherited);
297
+ }
298
+
299
+ function jobRuns(workflow: Workflow, job: Readonly<Record<string, unknown>>): readonly Run[] {
300
+ const dir = defaultDirectory(job, defaultDirectory(workflow.document, ""));
301
+ const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
302
+ return steps.flatMap((step: unknown) => (isRecord(step) && typeof step["run"] === "string" ? [{ script: step["run"], dir: stepDirectory(step["working-directory"], dir) }] : []));
303
+ }
304
+
305
+ function workflowJobs(workflow: Workflow): readonly (readonly [string, Readonly<Record<string, unknown>>])[] {
306
+ const jobs = isRecord(workflow.document) ? workflow.document["jobs"] : undefined;
307
+ return isRecord(jobs) ? Object.entries(jobs).flatMap(([id, job]) => (isRecord(job) ? [[id, job] as const] : [])) : [];
308
+ }
309
+
313
310
  export function findRunnerFaults(policy: RunnerPolicy, workflows: readonly Workflow[]): readonly RunnerFault[] {
314
- return workflows.flatMap((workflow) => {
315
- const jobs = isRecord(workflow.document) ? workflow.document["jobs"] : undefined;
316
- if (!isRecord(jobs)) return [];
317
- return Object.entries(jobs).flatMap(([id, job]) => {
318
- if (!isRecord(job) || job["runs-on"] === undefined) return [];
319
- const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
320
- const mutation = steps.some((step: unknown) => isRecord(step) && typeof step["run"] === "string" && runsMutation(step["run"], policy.scripts));
311
+ return workflows.flatMap((workflow) =>
312
+ workflowJobs(workflow).flatMap(([id, job]) => {
313
+ if (job["runs-on"] === undefined) return [];
314
+ const mutation = jobRuns(workflow, job).some((run) => runsMutation(run, policy.scripts, (file) => policy.files?.get(file)));
321
315
  const fault = runnerFault(job["runs-on"], mutation, policy.visibility);
322
316
  return fault === undefined ? [] : [{ location: `${workflow.path} job ${id}`, fault }];
323
- });
324
- });
317
+ }),
318
+ );
325
319
  }
326
320
 
327
321
  export function formatRunnerReport(faults: readonly RunnerFault[]): string {
@@ -383,7 +377,9 @@ const wiring = Effect.gen(function* () {
383
377
  const gaps = findGaps(declaration, workflows);
384
378
  const gapReport = formatReport(declaration, gaps);
385
379
  yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
386
- const faults = findRunnerFaults({ visibility: yield* readVisibility, scripts: yield* readScripts(root) }, workflows);
380
+ const scripts = yield* readScripts(root);
381
+ const runs = workflows.flatMap((workflow) => workflowJobs(workflow).flatMap(([, job]) => jobRuns(workflow, job)));
382
+ const faults = findRunnerFaults({ visibility: yield* readVisibility, scripts, files: yield* readShellFiles(root, scripts, runs) }, workflows);
387
383
  if (faults.length > 0) yield* Console.error(formatRunnerReport(faults));
388
384
  return gaps.length === 0 && faults.length === 0;
389
385
  });
@@ -0,0 +1,131 @@
1
+ import { Effect, FileSystem, Path } from "effect";
2
+ import { fromProgram, SHELLS, shellCommands, withoutOptions, type Command } from "./shell-command.ts";
3
+
4
+ // An undefined directory is one known only at run time.
5
+ export type Run = {
6
+ readonly script: string;
7
+ readonly dir: string | undefined;
8
+ };
9
+
10
+ type Invocation =
11
+ | { readonly script: string; readonly body: string }
12
+ | { readonly program: Command; readonly file: string | undefined; readonly dir: string | undefined };
13
+
14
+ type Walked = {
15
+ readonly scripts: readonly string[];
16
+ readonly files: readonly string[];
17
+ };
18
+
19
+ type Context = {
20
+ readonly scripts: ReadonlyMap<string, string>;
21
+ readonly files: (file: string) => string | undefined;
22
+ };
23
+
24
+ const MUTATION_BINS = ["checks-mutation", "checks-mutation-compare"];
25
+ const BUN_OPERANDS = new Set(["--cwd", "-c", "--config", "--env-file", "-F", "--filter", "-r", "--preload", "--require", "--import", "-e", "--eval", "-p", "--print", "--elide-lines", "--tsconfig-override"]);
26
+ // bun's own commands take precedence over a package.json script of the same name unless bun run names it.
27
+ const BUN_COMMANDS = new Set(["test", "repl", "exec", "install", "i", "add", "a", "remove", "rm", "update", "outdated", "link", "unlink", "pm", "build", "init", "create", "c", "upgrade", "publish", "patch", "patch-commit", "audit", "info", "why"]);
28
+ const SHELL_SHEBANG = new RegExp(String.raw`^#!\s*(?:\S*\/)?(?:env\s+(?:-\S+\s+)*)?(?:${SHELLS.join("|")})(?:\s|$)`);
29
+ const UNRESOLVED = /^[/~]|[$`]/;
30
+ const ROOT = "";
31
+
32
+ export function within(dir: string | undefined, path: string): string | undefined {
33
+ if (dir === undefined || UNRESOLVED.test(path)) return undefined;
34
+ const resolved = dir === ROOT ? [] : dir.split("/");
35
+ for (const part of path.split("/")) {
36
+ if (part === ".." && resolved.length === 0) return undefined;
37
+ if (part === "..") resolved.pop();
38
+ else if (part !== "" && part !== ".") resolved.push(part);
39
+ }
40
+ return resolved.join("/");
41
+ }
42
+
43
+ export function runsMutation(run: Run, scripts: ReadonlyMap<string, string>, files: (file: string) => string | undefined): boolean {
44
+ return scriptRunsMutation(run.script, run.dir, { scripts, files }, { scripts: [], files: [] });
45
+ }
46
+
47
+ function scriptRunsMutation(script: string, dir: string | undefined, context: Context, walked: Walked): boolean {
48
+ let cwd = dir;
49
+ for (const command of shellCommands(script)) {
50
+ const [program = "", ...args] = fromProgram(command);
51
+ if (program === "cd") cwd = changedDirectory(cwd, withoutOptions(args, new Set()));
52
+ else if (mutationCommand(command, cwd, context, walked)) return true;
53
+ }
54
+ return false;
55
+ }
56
+
57
+ function changedDirectory(cwd: string | undefined, [target]: Command): string | undefined {
58
+ return target === undefined || target === "-" ? undefined : within(cwd, target);
59
+ }
60
+
61
+ function bunOptions(words: Command): Command {
62
+ return words.slice(0, words.length - withoutOptions(words, BUN_OPERANDS).length);
63
+ }
64
+
65
+ function bunDirectory(options: Command, dir: string | undefined): string | undefined {
66
+ return options.reduce<string | undefined>((current, word, index) => {
67
+ const value = word === "--cwd" ? options[index + 1] : word.startsWith("--cwd=") ? word.slice("--cwd=".length) : undefined;
68
+ return value === undefined ? current : within(dir, value);
69
+ }, dir);
70
+ }
71
+
72
+ function fileAt(dir: string | undefined, path: string): string | undefined {
73
+ return within(dir, path) || undefined;
74
+ }
75
+
76
+ function invocation(command: Command, dir: string | undefined, context: Context, walked: Walked): Invocation | undefined {
77
+ const [first = "", ...rest] = fromProgram(command);
78
+ if (first !== "bun") return { program: [first, ...rest], file: first.includes("/") ? fileAt(dir, first) : undefined, dir };
79
+ const [subcommand = "", ...operands] = withoutOptions(rest, BUN_OPERANDS);
80
+ if (subcommand === "x") return invocation(["bunx", ...operands], dir, context, walked);
81
+ if (BUN_COMMANDS.has(subcommand)) return undefined;
82
+ const [target = "", ...targetArgs] = subcommand === "run" ? withoutOptions(operands, BUN_OPERANDS) : [subcommand, ...operands];
83
+ const cwd = bunDirectory([...bunOptions(rest), ...(subcommand === "run" ? bunOptions(operands) : [])], dir);
84
+ const body = walked.scripts.includes(target) ? undefined : context.scripts.get(target);
85
+ return body === undefined ? { program: [target, ...targetArgs], file: fileAt(cwd, target), dir: cwd } : { script: target, body };
86
+ }
87
+
88
+ function mutationCommand(command: Command, dir: string | undefined, context: Context, walked: Walked): boolean {
89
+ const call = invocation(command, dir, context, walked);
90
+ if (call === undefined) return false;
91
+ // bun runs a script from the directory of the package.json that names it.
92
+ if ("body" in call) return scriptRunsMutation(call.body, ROOT, context, { ...walked, scripts: [...walked.scripts, call.script] });
93
+ return mutationBin(call.program) || fileRunsMutation(call.file, call.dir, context, walked);
94
+ }
95
+
96
+ // A script file runs in its caller's directory, not its own.
97
+ function fileRunsMutation(file: string | undefined, dir: string | undefined, context: Context, walked: Walked): boolean {
98
+ if (file === undefined || walked.files.includes(file)) return false;
99
+ const body = context.files(file);
100
+ return body !== undefined && scriptRunsMutation(body, dir, context, { ...walked, files: [...walked.files, file] });
101
+ }
102
+
103
+ function mutationBin([program = "", subcommand]: Command): boolean {
104
+ const bin = program.slice(program.lastIndexOf("/") + 1);
105
+ return MUTATION_BINS.includes(bin) || (bin === "stryker" && subcommand === "run");
106
+ }
107
+
108
+ function filesWanted(runs: readonly Run[], scripts: ReadonlyMap<string, string>, files: ReadonlyMap<string, string>, read: ReadonlySet<string>): readonly string[] {
109
+ const wanted = new Set<string>();
110
+ const lookup = (file: string) => {
111
+ if (!read.has(file)) wanted.add(file);
112
+ return files.get(file);
113
+ };
114
+ for (const run of runs) runsMutation(run, scripts, lookup);
115
+ return [...wanted];
116
+ }
117
+
118
+ export const readShellFiles = Effect.fn("readShellFiles")(function* (root: string, scripts: ReadonlyMap<string, string>, runs: readonly Run[]) {
119
+ const fs = yield* FileSystem.FileSystem;
120
+ const path = yield* Path.Path;
121
+ const contents = new Map<string, string>();
122
+ const read = new Set<string>();
123
+ for (let wanted = filesWanted(runs, scripts, contents, read); wanted.length > 0; wanted = filesWanted(runs, scripts, contents, read)) {
124
+ for (const file of wanted) {
125
+ read.add(file);
126
+ const text = yield* fs.readFileString(path.join(root, file)).pipe(Effect.orElseSucceed(() => undefined));
127
+ if (text !== undefined && (file.endsWith(".sh") || SHELL_SHEBANG.test(text))) contents.set(file, text);
128
+ }
129
+ }
130
+ return contents;
131
+ });
@@ -70,6 +70,7 @@ const COMMAND_BREAKS = new Set(["\n", ";", "&", "|", "(", ")", "`"]);
70
70
  const WORD_BREAKS = new Set([" ", "\t"]);
71
71
  const REDIRECTS = new Set(["<", ">"]);
72
72
  const REDIRECT_OPERATOR = /^[<>][<>&|]*/;
73
+ const DELIMITER_BREAKS = new Set([...WORD_BREAKS, ...COMMAND_BREAKS, ...REDIRECTS]);
73
74
  const FILE_DESCRIPTOR = /^\d+$/;
74
75
  const DOUBLE_QUOTE_ESCAPES = new Set(["$", "`", '"', "\\"]);
75
76
  const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
@@ -138,9 +139,19 @@ function commentEnd(script: string, from: number): number {
138
139
  return newline === -1 ? script.length : newline;
139
140
  }
140
141
 
142
+ export const SHELLS = ["sh", "bash", "dash", "ksh", "zsh"];
143
+
144
+ type Heredoc = {
145
+ readonly consumer: Command;
146
+ readonly delimiter: string;
147
+ readonly tabs: boolean;
148
+ readonly literal: boolean;
149
+ };
150
+
141
151
  type ShellParse = {
142
152
  readonly commands: string[][];
143
153
  readonly substitutions: string[];
154
+ readonly heredocs: Heredoc[];
144
155
  words: string[];
145
156
  word: string | undefined;
146
157
  redirecting: boolean;
@@ -163,12 +174,63 @@ function endCommand(parse: ShellParse): void {
163
174
  function startRedirect(parse: ShellParse, script: string, index: number): number {
164
175
  if (parse.word !== undefined && FILE_DESCRIPTOR.test(parse.word)) parse.word = undefined;
165
176
  endWord(parse);
177
+ const operator = REDIRECT_OPERATOR.exec(script.slice(index))?.[0] ?? script.charAt(index);
178
+ if (operator === "<<") return startHeredoc(parse, script, index + operator.length);
166
179
  parse.redirecting = true;
167
- return index + (REDIRECT_OPERATOR.exec(script.slice(index))?.[0].length ?? 1);
180
+ return index + operator.length;
181
+ }
182
+
183
+ function startHeredoc(parse: ShellParse, script: string, from: number): number {
184
+ const tabs = script.charAt(from) === "-";
185
+ let start = tabs ? from + 1 : from;
186
+ while (WORD_BREAKS.has(script.charAt(start))) start += 1;
187
+ let delimiter = "";
188
+ let end = start;
189
+ while (end < script.length && !DELIMITER_BREAKS.has(script.charAt(end))) {
190
+ const part = shellPart(script, end);
191
+ delimiter += part.text;
192
+ end = part.end;
193
+ }
194
+ parse.heredocs.push({ consumer: parse.words, delimiter, tabs, literal: /['"\\]/.test(script.slice(start, end)) });
195
+ return end;
196
+ }
197
+
198
+ function heredocBody(script: string, from: number, { delimiter, tabs }: Heredoc): Span {
199
+ let line = from;
200
+ while (line < script.length) {
201
+ const lineEnd = commentEnd(script, line);
202
+ const text = script.slice(line, lineEnd);
203
+ if ((tabs ? text.replace(/^\t+/, "") : text) === delimiter) return { text: script.slice(from, line), end: lineEnd + 1 };
204
+ line = lineEnd + 1;
205
+ }
206
+ return { text: script.slice(from), end: script.length };
207
+ }
208
+
209
+ function bodySubstitutions(body: string): readonly string[] {
210
+ const substitutions: string[] = [];
211
+ let index = 0;
212
+ while (index < body.length) {
213
+ const part = doubleQuotedPart(body, index);
214
+ substitutions.push(...part.substitutions);
215
+ index = part.end;
216
+ }
217
+ return substitutions;
218
+ }
219
+
220
+ function skipHeredocs(parse: ShellParse, script: string, from: number): number {
221
+ let index = from;
222
+ for (const heredoc of parse.heredocs.splice(0)) {
223
+ const body = heredocBody(script, index, heredoc);
224
+ const [program = ""] = fromProgram(heredoc.consumer);
225
+ if (SHELLS.includes(program.slice(program.lastIndexOf("/") + 1))) parse.substitutions.push(body.text);
226
+ else if (!heredoc.literal) parse.substitutions.push(...bodySubstitutions(body.text));
227
+ index = body.end;
228
+ }
229
+ return index;
168
230
  }
169
231
 
170
232
  export function shellCommands(script: string): readonly Command[] {
171
- const parse: ShellParse = { commands: [], substitutions: [], words: [], word: undefined, redirecting: false };
233
+ const parse: ShellParse = { commands: [], substitutions: [], heredocs: [], words: [], word: undefined, redirecting: false };
172
234
  let index = 0;
173
235
  while (index < script.length) {
174
236
  const char = script.charAt(index);
@@ -178,9 +240,11 @@ export function shellCommands(script: string): readonly Command[] {
178
240
  index = commentEnd(script, index);
179
241
  } else if (REDIRECTS.has(char)) {
180
242
  index = startRedirect(parse, script, index);
181
- } else if (COMMAND_BREAKS.has(char) || WORD_BREAKS.has(char)) {
182
- if (COMMAND_BREAKS.has(char)) endCommand(parse);
183
- else endWord(parse);
243
+ } else if (COMMAND_BREAKS.has(char)) {
244
+ endCommand(parse);
245
+ index = char === "\n" ? skipHeredocs(parse, script, index + 1) : index + 1;
246
+ } else if (WORD_BREAKS.has(char)) {
247
+ endWord(parse);
184
248
  index += 1;
185
249
  } else {
186
250
  const part = shellPart(script, index);
package/stryker.preset.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readdirSync } from "node:fs";
1
2
  import { availableParallelism } from "node:os";
2
3
  import { fileURLToPath } from "node:url";
3
4
  import { GUARD_IGNORER } from "./src/testing/mutation-guard-plugin.js";
@@ -9,6 +10,13 @@ if (refusal !== undefined) throw new Error(refusal);
9
10
 
10
11
  export const halfAvailableCores = (cores = availableParallelism()) => Math.max(1, Math.floor(cores / 2));
11
12
 
13
+ // The bun runner narrows a mutant's run to its covering files only when each listed path equals a test id's file part, which carries no leading ./.
14
+ export const unitTestFiles = () =>
15
+ readdirSync("tests/unit", { recursive: true, encoding: "utf8" })
16
+ .filter((name) => name.endsWith(".test.ts") || name.endsWith(".test.tsx"))
17
+ .map((name) => `tests/unit/${name}`)
18
+ .toSorted();
19
+
12
20
  export default {
13
21
  packageManager: "npm",
14
22
  plugins: [