@avi2dg/checks 0.33.0 → 0.34.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,14 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.34.0
6
+
7
+ Released 2026-10-03.
8
+
9
+ ### Features
10
+
11
+ - **delivery:** pin mutation jobs to winbox and default other jobs to hosted runners [#123](https://github.com/avi2d/checks/pull/123)
12
+
5
13
  ## 0.33.0
6
14
 
7
15
  Released 2026-10-03.
package/README.md CHANGED
@@ -104,6 +104,7 @@ To consume the kit from a repository:
104
104
  ```
105
105
 
106
106
  Add a pull request title lint step in another workflow using `./node_modules/.bin/commitlint`.
107
+ A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on each job, as [checks-ci-wiring](docs/gates/checks-ci-wiring.md#runners) requires.
107
108
  `bun run lint` then ends with `checks-lint: <count> gate(s) pass`.
108
109
 
109
110
  ## What runs
@@ -110,7 +110,7 @@ on:
110
110
  workflow_dispatch:
111
111
  jobs:
112
112
  advisories:
113
- runs-on: self-hosted
113
+ runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}
114
114
  timeout-minutes: 10
115
115
  steps:
116
116
  - uses: actions/checkout@v5
@@ -119,8 +119,9 @@ jobs:
119
119
  - run: ./node_modules/.bin/checks-advisories --all
120
120
  ```
121
121
 
122
+ The job runs on a hosted runner unless `CI_RUNS_ON` names another, as [checks-ci-wiring](checks-ci-wiring.md#runners) requires.
123
+ A hosted runner starts each run with an empty cache, so each run downloads both the scanner and the database.
122
124
  A self-hosted runner keeps `~/.cache/avi2dg-checks/` between runs, so it downloads the scanner once per pinned version and the database about once a day.
123
- A hosted runner starts each run with an empty cache, so each run downloads both.
124
125
 
125
126
  ## Related topics
126
127
 
@@ -21,10 +21,22 @@ It refuses a step or job with `if: false` or `continue-on-error: true`.
21
21
  It also refuses a workflow that does not trigger on both `opened` and `synchronize` pull requests to the target branch.
22
22
  A path filter cannot cover every pull request and therefore cannot satisfy the check.
23
23
 
24
+ ### Runners
25
+
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`.
28
+ The call counts as a command of its own or inside a command substitution, never as an argument to another command.
29
+ 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
+ Any other job that names `CI_RUNS_ON` in `runs-on` carries exactly the hosted-default expression `${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}`.
31
+ 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.
32
+ A public repository may keep `runs-on: ubuntu-latest` on every job, because a pull request from a fork runs its own code on the runner.
33
+ The check knows a repository is private only from GitHub's event, so a run outside CI judges only the rules that hold in either.
34
+
24
35
  ## What it reads
25
36
 
26
37
  The bin reads `.github/workflows/*.yml`, `*.yaml` and the `scripts` in `package.json` from the working tree.
27
38
  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
+ It reads whether the repository is private from `repository.private` in that event file.
28
40
  It parses the workflows with `Bun.YAML` without executing them.
29
41
 
30
42
  ## Arguments
@@ -36,7 +48,7 @@ It takes no arguments.
36
48
  | Code | Result |
37
49
  | --- | --- |
38
50
  | 0 | Every required command has a reachable step. |
39
- | 1 | A required command is missing or blocked. |
51
+ | 1 | A required command is missing or blocked, or a job runs on the wrong runner. |
40
52
  | 2 | A workflow or `package.json` cannot be decoded. |
41
53
 
42
54
  ## Sample output
@@ -49,6 +61,13 @@ ci-wiring: 1 of 6 gate(s) do not run on pull requests to main:
49
61
  no run step invokes it
50
62
  ```
51
63
 
64
+ A mutation job that names `CI_RUNS_ON` in `runs-on` produces a report like this:
65
+
66
+ ```
67
+ ci-wiring: 1 job(s) run on the wrong runner:
68
+ .github/workflows/mutation-compare.yml job mutation-compare: a mutation job reads CI_RUNS_ON, so an override moves its full sweeps off winbox; set runs-on: [self-hosted, Linux, X64, winbox]
69
+ ```
70
+
52
71
  ## When it runs
53
72
 
54
73
  `checks-lint` runs it in every repository.
@@ -76,6 +76,8 @@ jobs:
76
76
  path: flake-report.json
77
77
  ```
78
78
 
79
+ A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on the job, as [checks-ci-wiring](checks-ci-wiring.md#runners) requires.
80
+
79
81
  ## Related topics
80
82
 
81
83
  - [checks-test](checks-test.md)
@@ -107,8 +107,9 @@ jobs:
107
107
  Both Stryker runs are full sweeps, and GitHub sets `CI=true` on every runner, so the preset lets them through.
108
108
  The base worktree's path carries the run's id and attempt, and the last step removes it even when a run fails, so a runner kept between jobs starts each job clean.
109
109
  A public repository keeps `runs-on: ubuntu-latest`, because a pull request from a fork runs its own code on the runner.
110
- A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || fromJSON('["self-hosted","Linux","X64","winbox"]') }}` instead.
111
- With `CI_RUNS_ON` unset, the job then runs on the fleet's self-hosted Linux runner labelled `winbox`, which is where a private repository sends its full sweeps.
110
+ A private repository sets `runs-on: [self-hosted, Linux, X64, winbox]` instead, which is the fleet's self-hosted Linux runner where it sends its full sweeps.
111
+ The job never reads `CI_RUNS_ON`, so an override that moves a repository's other jobs during a runner outage leaves the comparison waiting for `winbox`.
112
+ [checks-ci-wiring](checks-ci-wiring.md#runners) refuses a mutation job that names it in `runs-on`.
112
113
 
113
114
  ## Related topics
114
115
 
@@ -73,7 +73,7 @@ on:
73
73
  workflow_dispatch:
74
74
  jobs:
75
75
  mutation:
76
- runs-on: ${{ vars.CI_RUNS_ON || fromJSON('["self-hosted","Linux","X64","winbox"]') }}
76
+ runs-on: [self-hosted, Linux, X64, winbox]
77
77
  steps:
78
78
  - uses: actions/checkout@v5
79
79
  - uses: oven-sh/setup-bun@v2
@@ -88,8 +88,9 @@ jobs:
88
88
  path: reports/mutation/mutation.json
89
89
  ```
90
90
 
91
- With `CI_RUNS_ON` unset, the job runs on the fleet's self-hosted Linux runner labelled `winbox`, which is where a private repository sends its full sweeps.
92
- A repository sets `CI_RUNS_ON` only to name a different runner.
91
+ The job runs on the fleet's self-hosted Linux runner labelled `winbox`, which is where a repository sends its full sweeps.
92
+ It never reads `CI_RUNS_ON`, so an override that moves a repository's other jobs during a runner outage leaves the sweep waiting for `winbox`.
93
+ [checks-ci-wiring](checks-ci-wiring.md#runners) refuses a mutation job that names it in `runs-on`.
93
94
  The `name: mutation` line is what `gh workflow run mutation` looks up.
94
95
  GitHub sets `CI=true` on every runner, so the preset lets the full run through there.
95
96
 
@@ -133,6 +133,7 @@ jobs:
133
133
  run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/release-notes.md"
134
134
  ```
135
135
 
136
+ A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on each job in either workflow, as [checks-ci-wiring](checks-ci-wiring.md#runners) requires.
136
137
  A release is a pull request that holds only the version bump and the built changelog, which [checks-release-pr](checks-release-pr.md) opens once a day.
137
138
  When it lands, [checks-release-tag](checks-release-tag.md) tags the merge commit and dispatches this workflow on the tag.
138
139
  A tag the workflow token pushes starts no `push` run, so the workflow triggers on `workflow_dispatch` as well, and refuses a dispatch on a ref that is not a tag.
@@ -138,11 +138,11 @@ jobs:
138
138
  ```
139
139
 
140
140
  A public repository keeps `runs-on: ubuntu-latest`.
141
- A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || fromJSON('["self-hosted","Linux","X64","winbox"]') }}` on both jobs, as its other workflows do.
141
+ A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on both jobs, as [checks-ci-wiring](checks-ci-wiring.md#runners) requires of every job but a mutation job.
142
142
  A repository whose build needs more than Bun adds the steps its `.github/workflows/ci.yml` runs before `bun run build` to both jobs, and nothing after it.
143
143
  Neither job runs a test suite or a mutation run.
144
144
  On a hosted runner the `pull-request` job takes about 25 seconds, which GitHub bills as one minute, whether it opens, refreshes or leaves the pull request alone.
145
- A private repository runs it on its self-hosted runner, which bills no minutes.
145
+ A private repository runs it there too unless `CI_RUNS_ON` names its self-hosted runner, which bills no minutes.
146
146
  The `tag` job runs only when a release lands, and a job its `if` skips bills nothing.
147
147
  The checks it dispatches are the release pull request's own required checks, and they run again only when `main` moves under it.
148
148
  The pull request also lists its own `pull_request` runs as waiting for approval.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, FileSystem, Path, Schema } from "effect";
2
+ 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 { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
6
+ import { fromProgram, invokes, mentions, plainCommand, shellCommands, withoutOptions, type Command } from "./shell-command.ts";
7
7
 
8
8
  export type { Command };
9
9
 
@@ -34,6 +34,18 @@ export type Gap = {
34
34
  readonly blocked: readonly BlockedInvocation[];
35
35
  };
36
36
 
37
+ export type Visibility = "private" | "public" | "unknown";
38
+
39
+ export type RunnerPolicy = {
40
+ readonly visibility: Visibility;
41
+ readonly scripts: ReadonlyMap<string, string>;
42
+ };
43
+
44
+ export type RunnerFault = {
45
+ readonly location: string;
46
+ readonly fault: string;
47
+ };
48
+
37
49
  type RunStep = {
38
50
  readonly location: string;
39
51
  readonly blocker: string | undefined;
@@ -58,6 +70,17 @@ const CONSTANTS = new Map([
58
70
  ["false", false],
59
71
  ]);
60
72
  const STATUS_OVERRIDE = /\b(?:always|failure|cancelled)\s*\(/;
73
+ // GitHub expressions match context and property names without regard to case.
74
+ const OVERRIDE_ACCESS = String.raw`vars\s*(?:\.\s*CI_RUNS_ON\b|\[\s*'CI_RUNS_ON'\s*\])`;
75
+ const RUNNER_OVERRIDE = new RegExp(String.raw`\b${OVERRIDE_ACCESS}`, "i");
76
+ const HOSTED_OVERRIDE = new RegExp(String.raw`^\$\{\{\s*${OVERRIDE_ACCESS}\s*\|\|\s*'ubuntu-latest'\s*\}\}$`, "i");
77
+ const MUTATION_RUNNER = ["self-hosted", "Linux", "X64", "winbox"];
78
+ 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
+ const EventRepository = Schema.fromJsonString(Schema.Struct({ repository: Schema.Struct({ private: Schema.Boolean }) }));
61
84
 
62
85
  function isRecord(value: unknown): value is Record<string, unknown> {
63
86
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -243,13 +266,75 @@ export function declarationFor(scripts: readonly string[], branch: string): Decl
243
266
  };
244
267
  }
245
268
 
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
+ function sameLabels(runsOn: unknown, labels: readonly string[]): boolean {
293
+ const named = Array.isArray(runsOn) ? names(runsOn) : undefined;
294
+ return named?.length === labels.length && labels.every((label) => named.includes(label));
295
+ }
296
+
297
+ function readsOverride(runsOn: unknown): boolean {
298
+ if (typeof runsOn === "string") return RUNNER_OVERRIDE.test(runsOn);
299
+ const values = isRecord(runsOn) ? Object.values(runsOn) : Array.isArray(runsOn) ? runsOn : [];
300
+ return values.some(readsOverride);
301
+ }
302
+
303
+ function runnerFault(runsOn: unknown, mutation: boolean, visibility: Visibility): string | undefined {
304
+ const overridden = readsOverride(runsOn);
305
+ const pin = `set runs-on: [${MUTATION_RUNNER.join(", ")}]`;
306
+ if (mutation && overridden) return `a mutation job reads CI_RUNS_ON, so an override moves its full sweeps off winbox; ${pin}`;
307
+ if (mutation) return visibility === "private" && !sameLabels(runsOn, MUTATION_RUNNER) ? `a mutation job in a private repository runs off winbox; ${pin}` : undefined;
308
+ const hosted = typeof runsOn === "string" && HOSTED_OVERRIDE.test(runsOn.trim());
309
+ if (hosted || (visibility !== "private" && !overridden)) return undefined;
310
+ return `the job is not on a hosted runner CI_RUNS_ON can override; set runs-on: ${HOSTED_DEFAULT}`;
311
+ }
312
+
313
+ 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));
321
+ const fault = runnerFault(job["runs-on"], mutation, policy.visibility);
322
+ return fault === undefined ? [] : [{ location: `${workflow.path} job ${id}`, fault }];
323
+ });
324
+ });
325
+ }
326
+
327
+ export function formatRunnerReport(faults: readonly RunnerFault[]): string {
328
+ return [`ci-wiring: ${faults.length} job(s) run on the wrong runner:`, ...faults.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
329
+ }
330
+
246
331
  export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflow, WiringError> =>
247
332
  Effect.try({
248
333
  try: () => ({ path, document: Bun.YAML.parse(text) }),
249
334
  catch: (error) => new WiringError({ message: `cannot parse ${path}: ${String(error)}` }),
250
335
  });
251
336
 
252
- export const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
337
+ const readScripts = Effect.fn("readScripts")(function* (root: string) {
253
338
  const fs = yield* FileSystem.FileSystem;
254
339
  const file = (yield* Path.Path).join(root, "package.json");
255
340
  const { scripts = {} } = (yield* fs.exists(file))
@@ -258,7 +343,22 @@ export const readDeclaration = Effect.fn("readDeclaration")(function* (root: str
258
343
  Effect.mapError((cause) => new WiringError({ message: `cannot read ${file}: ${cause.message}` })),
259
344
  )
260
345
  : Manifest.make({});
261
- return declarationFor(Object.keys(scripts), yield* defaultBranch(root));
346
+ return new Map(Object.entries(scripts));
347
+ });
348
+
349
+ export const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
350
+ return declarationFor([...(yield* readScripts(root)).keys()], yield* defaultBranch(root));
351
+ });
352
+
353
+ // Only GitHub's event says whether the repository is private, so a run outside CI judges what holds in either.
354
+ const readVisibility = Effect.gen(function* () {
355
+ const eventPath = yield* Config.String("GITHUB_EVENT_PATH").pipe(Config.withDefault(""));
356
+ if (eventPath === "") return "unknown" satisfies Visibility;
357
+ return yield* (yield* FileSystem.FileSystem).readFileString(eventPath).pipe(
358
+ Effect.flatMap(Schema.decodeUnknownEffect(EventRepository)),
359
+ Effect.map(({ repository }): Visibility => (repository.private ? "private" : "public")),
360
+ Effect.orElseSucceed((): Visibility => "unknown"),
361
+ );
262
362
  });
263
363
 
264
364
  export const readWorkflows = Effect.fn("readWorkflows")(function* (root: string) {
@@ -283,7 +383,9 @@ const wiring = Effect.gen(function* () {
283
383
  const gaps = findGaps(declaration, workflows);
284
384
  const gapReport = formatReport(declaration, gaps);
285
385
  yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
286
- return gaps.length === 0;
386
+ const faults = findRunnerFaults({ visibility: yield* readVisibility, scripts: yield* readScripts(root) }, workflows);
387
+ if (faults.length > 0) yield* Console.error(formatRunnerReport(faults));
388
+ return gaps.length === 0 && faults.length === 0;
287
389
  });
288
390
 
289
391
  if (import.meta.main) runMain("ci-wiring", wiring);
@@ -64,6 +64,149 @@ export function plainCommand(script: string): Command | undefined {
64
64
  return words.length === 0 ? undefined : words;
65
65
  }
66
66
 
67
+ type ShellPart = Span & { readonly substitutions: readonly string[] };
68
+
69
+ const COMMAND_BREAKS = new Set(["\n", ";", "&", "|", "(", ")", "`"]);
70
+ const WORD_BREAKS = new Set([" ", "\t"]);
71
+ const REDIRECTS = new Set(["<", ">"]);
72
+ const REDIRECT_OPERATOR = /^[<>][<>&|]*/;
73
+ const FILE_DESCRIPTOR = /^\d+$/;
74
+ const DOUBLE_QUOTE_ESCAPES = new Set(["$", "`", '"', "\\"]);
75
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
76
+ const RESERVED_WORDS = new Set(["!", "{", "if", "then", "elif", "else", "while", "until", "do"]);
77
+ const LAUNCHER_OPERANDS = new Map<string, ReadonlySet<string>>([
78
+ ["time", new Set(["-o", "-f"])],
79
+ ["env", new Set(["-u", "-C", "--unset", "--chdir"])],
80
+ ["exec", new Set(["-a"])],
81
+ ["nohup", new Set()],
82
+ ["sudo", new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-U", "--user", "--group", "--chdir", "--host", "--prompt"])],
83
+ ["bunx", new Set(["-p", "--package"])],
84
+ ["npx", new Set(["-p", "--package", "-c", "--call", "-w", "--workspace"])],
85
+ ]);
86
+
87
+ function substitutionEnd(script: string, start: number): number {
88
+ if (script.charAt(start) === "`") {
89
+ const close = script.indexOf("`", start + 1);
90
+ return close === -1 ? script.length : close;
91
+ }
92
+ let depth = 0;
93
+ for (let index = start + 1; index < script.length; index += 1) {
94
+ if (script.charAt(index) === "(") depth += 1;
95
+ if (script.charAt(index) === ")") depth -= 1;
96
+ if (depth === 0) return index;
97
+ }
98
+ return script.length;
99
+ }
100
+
101
+ function doubleQuotedPart(script: string, index: number): ShellPart {
102
+ const char = script.charAt(index);
103
+ const next = script.charAt(index + 1);
104
+ if (char === "\\" && next === "\n") return { text: "", end: index + 2, substitutions: [] };
105
+ if (char === "\\" && DOUBLE_QUOTE_ESCAPES.has(next)) return { text: next, end: index + 2, substitutions: [] };
106
+ if (char !== "`" && !(char === "$" && next === "(")) return { text: char, end: index + 1, substitutions: [] };
107
+ const end = substitutionEnd(script, index);
108
+ return { text: "", end: end + 1, substitutions: [script.slice(index + (char === "`" ? 1 : 2), end)] };
109
+ }
110
+
111
+ function shellDoubleQuoted(script: string, open: number): ShellPart {
112
+ let text = "";
113
+ const substitutions: string[] = [];
114
+ let index = open + 1;
115
+ while (index < script.length && script.charAt(index) !== '"') {
116
+ const part = doubleQuotedPart(script, index);
117
+ text += part.text;
118
+ substitutions.push(...part.substitutions);
119
+ index = part.end;
120
+ }
121
+ return { text, end: index + 1, substitutions };
122
+ }
123
+
124
+ function shellPart(script: string, index: number): ShellPart {
125
+ const char = script.charAt(index);
126
+ if (char === "'") {
127
+ const close = script.indexOf("'", index + 1);
128
+ const end = close === -1 ? script.length : close;
129
+ return { text: script.slice(index + 1, end), end: end + 1, substitutions: [] };
130
+ }
131
+ if (char === '"') return shellDoubleQuoted(script, index);
132
+ if (char === "\\") return { text: script.charAt(index + 1), end: index + 2, substitutions: [] };
133
+ return { text: char, end: index + 1, substitutions: [] };
134
+ }
135
+
136
+ function commentEnd(script: string, from: number): number {
137
+ const newline = script.indexOf("\n", from);
138
+ return newline === -1 ? script.length : newline;
139
+ }
140
+
141
+ type ShellParse = {
142
+ readonly commands: string[][];
143
+ readonly substitutions: string[];
144
+ words: string[];
145
+ word: string | undefined;
146
+ redirecting: boolean;
147
+ };
148
+
149
+ function endWord(parse: ShellParse): void {
150
+ if (parse.word === undefined) return;
151
+ if (!parse.redirecting) parse.words.push(parse.word);
152
+ parse.redirecting = false;
153
+ parse.word = undefined;
154
+ }
155
+
156
+ function endCommand(parse: ShellParse): void {
157
+ endWord(parse);
158
+ parse.commands.push(parse.words);
159
+ parse.words = [];
160
+ parse.redirecting = false;
161
+ }
162
+
163
+ function startRedirect(parse: ShellParse, script: string, index: number): number {
164
+ if (parse.word !== undefined && FILE_DESCRIPTOR.test(parse.word)) parse.word = undefined;
165
+ endWord(parse);
166
+ parse.redirecting = true;
167
+ return index + (REDIRECT_OPERATOR.exec(script.slice(index))?.[0].length ?? 1);
168
+ }
169
+
170
+ export function shellCommands(script: string): readonly Command[] {
171
+ const parse: ShellParse = { commands: [], substitutions: [], words: [], word: undefined, redirecting: false };
172
+ let index = 0;
173
+ while (index < script.length) {
174
+ const char = script.charAt(index);
175
+ if (char === "\\" && script.charAt(index + 1) === "\n") {
176
+ index += 2;
177
+ } else if (char === "#" && parse.word === undefined) {
178
+ index = commentEnd(script, index);
179
+ } else if (REDIRECTS.has(char)) {
180
+ 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);
184
+ index += 1;
185
+ } else {
186
+ const part = shellPart(script, index);
187
+ parse.word = (parse.word ?? "") + part.text;
188
+ parse.substitutions.push(...part.substitutions);
189
+ index = part.end;
190
+ }
191
+ }
192
+ endCommand(parse);
193
+ return [...parse.commands, ...parse.substitutions.flatMap(shellCommands)].filter((command) => command.length > 0);
194
+ }
195
+
196
+ export function withoutOptions(words: Command, operands: ReadonlySet<string>): Command {
197
+ const [first = "", ...rest] = words;
198
+ if (first === "--") return rest;
199
+ if (!first.startsWith("-")) return words;
200
+ return withoutOptions(operands.has(first) ? rest.slice(1) : rest, operands);
201
+ }
202
+
203
+ export function fromProgram(words: Command): Command {
204
+ const [first = "", ...rest] = words;
205
+ if (ASSIGNMENT.test(first) || RESERVED_WORDS.has(first)) return fromProgram(rest);
206
+ const operands = LAUNCHER_OPERANDS.get(first);
207
+ return operands === undefined ? words : fromProgram(withoutOptions(rest, operands));
208
+ }
209
+
67
210
  // bun run resolves any other word, even one holding a slash, to a package.json script of that name first.
68
211
  const FILE_PATH = /^\.{0,2}\//;
69
212