@avi2dg/checks 0.35.0 → 0.35.1
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/docs/gates/checks-ci-wiring.md +10 -2
- package/package.json +2 -1
- package/src/delivery/ci-wiring.ts +34 -38
- package/src/delivery/mutation-job.ts +131 -0
- package/src/delivery/shell-command.ts +69 -5
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.35.1
|
|
6
|
+
|
|
7
|
+
Released 2026-10-07.
|
|
8
|
+
|
|
9
|
+
### Fixes
|
|
10
|
+
|
|
11
|
+
- **delivery:** recognise mutation jobs that run stryker through repository shell scripts [#130](https://github.com/avi2d/checks/pull/130)
|
|
12
|
+
|
|
5
13
|
## 0.35.0
|
|
6
14
|
|
|
7
15
|
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
|
|
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
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.1",
|
|
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 {
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
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 +
|
|
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)
|
|
182
|
-
|
|
183
|
-
|
|
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);
|