@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 +8 -0
- package/README.md +1 -0
- package/docs/gates/checks-advisories.md +3 -2
- package/docs/gates/checks-ci-wiring.md +20 -1
- package/docs/gates/checks-flake.md +2 -0
- package/docs/gates/checks-mutation-compare.md +3 -2
- package/docs/gates/checks-mutation.md +4 -3
- package/docs/gates/checks-release-notes.md +1 -0
- package/docs/gates/checks-release-pr.md +2 -2
- package/package.json +1 -1
- package/src/delivery/ci-wiring.ts +107 -5
- package/src/delivery/shell-command.ts +143 -0
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:
|
|
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.
|
|
@@ -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:
|
|
111
|
-
|
|
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:
|
|
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
|
-
|
|
92
|
-
|
|
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 ||
|
|
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
|
|
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,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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|