@avi2dg/checks 0.3.0 → 0.4.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/README.md +126 -8
- package/package.json +17 -2
- package/scripts/ci-wiring.ts +327 -0
- package/scripts/test-layout.ts +5 -1
package/README.md
CHANGED
|
@@ -6,8 +6,8 @@ Effect language-service block, the shared commitlint config, the shared
|
|
|
6
6
|
dependency-cruiser base, the test-layout check with its bunfig preset,
|
|
7
7
|
the commit-identity check with its workflow, the comment gate with its
|
|
8
8
|
workflow and backtest, the Stryker mutation-testing preset with its
|
|
9
|
-
no-regression comparator, and the Effect
|
|
10
|
-
to JavaScript.
|
|
9
|
+
no-regression comparator, the CI-wiring check, and the Effect
|
|
10
|
+
error-channel plugin compiled to JavaScript.
|
|
11
11
|
|
|
12
12
|
Published as `@avi2dg/checks` on the public npm registry.
|
|
13
13
|
|
|
@@ -105,7 +105,10 @@ file and the path to move it to when it does not:
|
|
|
105
105
|
- `scripts.test` is exactly `bun test --randomize` and `scripts.lint` runs
|
|
106
106
|
this check.
|
|
107
107
|
- `bunfig.toml` carries every `[test]` key of the shipped preset with the
|
|
108
|
-
same value
|
|
108
|
+
same value, and `[test].pathIgnorePatterns` is always
|
|
109
|
+
`["**/tests/quarantine/**"]`: the check pins it itself, so this repo,
|
|
110
|
+
whose bunfig is the preset, cannot drift it either. Other tables, and
|
|
111
|
+
extra `[test]` keys, are the repo's own.
|
|
109
112
|
|
|
110
113
|
The in-process half is what a mutation run can mutate; `tests/e2e/**` is
|
|
111
114
|
excluded from a mutate scope by construction, because a subprocess kills
|
|
@@ -183,7 +186,15 @@ on:
|
|
|
183
186
|
types: [opened, edited, synchronize, reopened]
|
|
184
187
|
jobs:
|
|
185
188
|
commitlint:
|
|
186
|
-
|
|
189
|
+
runs-on: ubuntu-latest
|
|
190
|
+
steps:
|
|
191
|
+
- uses: actions/checkout@v5
|
|
192
|
+
- uses: oven-sh/setup-bun@v2
|
|
193
|
+
- run: bun install --frozen-lockfile
|
|
194
|
+
- run: printf '%s' "$PR_TITLE (#0000)" > "$RUNNER_TEMP/pr-title"
|
|
195
|
+
env:
|
|
196
|
+
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
197
|
+
- run: ./node_modules/.bin/commitlint --config ./node_modules/@avi2dg/checks/commitlint.config.js --edit "$RUNNER_TEMP/pr-title"
|
|
187
198
|
```
|
|
188
199
|
|
|
189
200
|
It lints the pull request title and nothing else. The title is the
|
|
@@ -234,10 +245,20 @@ on:
|
|
|
234
245
|
types: [opened, edited, synchronize, reopened]
|
|
235
246
|
jobs:
|
|
236
247
|
commit-identity:
|
|
237
|
-
|
|
248
|
+
runs-on: ubuntu-latest
|
|
249
|
+
steps:
|
|
250
|
+
- uses: actions/checkout@v5
|
|
251
|
+
with:
|
|
252
|
+
fetch-depth: 0
|
|
253
|
+
- uses: oven-sh/setup-bun@v2
|
|
254
|
+
- run: bun install --frozen-lockfile
|
|
255
|
+
- run: bunx checks-commit-identity "origin/$BASE_REF" "$HEAD_SHA"
|
|
256
|
+
env:
|
|
257
|
+
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
258
|
+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
238
259
|
```
|
|
239
260
|
|
|
240
|
-
The
|
|
261
|
+
The checkout fetches the full history and the range starts at the
|
|
241
262
|
fetched base branch, not the event's recorded base sha, which GitHub
|
|
242
263
|
leaves stale once the base branch advances after the pull request opens.
|
|
243
264
|
|
|
@@ -271,9 +292,100 @@ on:
|
|
|
271
292
|
types: [opened, edited, synchronize, reopened]
|
|
272
293
|
jobs:
|
|
273
294
|
comment-gate:
|
|
274
|
-
|
|
295
|
+
runs-on: ubuntu-latest
|
|
296
|
+
steps:
|
|
297
|
+
- uses: actions/checkout@v5
|
|
298
|
+
with:
|
|
299
|
+
fetch-depth: 0
|
|
300
|
+
- uses: oven-sh/setup-bun@v2
|
|
301
|
+
- run: bun install --frozen-lockfile
|
|
302
|
+
- run: bunx checks-comment-gate "origin/$BASE_REF" "$HEAD_SHA"
|
|
303
|
+
env:
|
|
304
|
+
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
305
|
+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## CI wiring
|
|
309
|
+
|
|
310
|
+
`checks-ci-wiring` fails when a command the repository's CI must run no
|
|
311
|
+
longer runs on pull requests to the default branch. No local check sees
|
|
312
|
+
that: a workflow whose lint step became a no-op leaves `bun run lint`
|
|
313
|
+
green.
|
|
314
|
+
|
|
315
|
+
The repository declares its gates once, in `package.json`, and `lint`
|
|
316
|
+
runs the check:
|
|
317
|
+
|
|
318
|
+
```json
|
|
319
|
+
"ciWiring": {
|
|
320
|
+
"gates": ["bun run lint", "bun run typecheck", "bun run test"]
|
|
321
|
+
},
|
|
322
|
+
"scripts": {
|
|
323
|
+
"lint": "oxlint --type-aware && checks-lint-coverage && checks-test-layout && checks-ci-wiring"
|
|
324
|
+
}
|
|
275
325
|
```
|
|
276
326
|
|
|
327
|
+
It parses every `.github/workflows/*.yml` and `*.yaml` and looks, for
|
|
328
|
+
each gate, for a `run:` step that is the gate command alone on one line,
|
|
329
|
+
optionally followed by plain arguments: words, quoted strings, and
|
|
330
|
+
`$VAR` or `${VAR}` expansions. `bun run lint --quiet` and
|
|
331
|
+
`bunx checks-comment-gate "origin/$BASE_REF" "$HEAD_SHA"` count;
|
|
332
|
+
`bun run lint:deps`, `echo bun run lint` and a step `name:` do not. A
|
|
333
|
+
step whose script has a second line, or any `|`, `||`, `&&`, `;`, `&`,
|
|
334
|
+
`$(...)`, backticks, `<` or `>` redirection, a comment or a leading
|
|
335
|
+
`NAME=value`, never counts, because each can run the gate without its
|
|
336
|
+
failure failing the step; the report names the gate and says to give it
|
|
337
|
+
its own step with nothing else in it. A gate step counts only when:
|
|
338
|
+
|
|
339
|
+
- its workflow triggers on `pull_request`, any `branches` or
|
|
340
|
+
`branches-ignore` filter there keeps the default branch, any `types`
|
|
341
|
+
filter keeps `opened` and `synchronize`, and it sets no `paths` or
|
|
342
|
+
`paths-ignore` filter, which lets some pull requests skip the gate;
|
|
343
|
+
- neither the step nor its job sets `if: false` or
|
|
344
|
+
`continue-on-error: true`, bare or as `${{ false }}` and `${{ true }}`;
|
|
345
|
+
- its job needs no job, directly or through a chain, that sets
|
|
346
|
+
`if: false`, unless a job on that chain has an `if:` calling
|
|
347
|
+
`always()`, `failure()` or `cancelled()`. GitHub prefixes every other
|
|
348
|
+
`if:`, including `true` and `success()`, with `success()`, so a job
|
|
349
|
+
whose needed job was skipped is skipped too.
|
|
350
|
+
|
|
351
|
+
A job calling a local reusable workflow (`uses: ./.github/workflows/x.yml`)
|
|
352
|
+
passes its own trigger and `if:` down to the called workflow's steps.
|
|
353
|
+
A remote reusable workflow (`uses: owner/repo/...@ref`) is not a
|
|
354
|
+
supported way to wire a gate: it is not read, so a gate must run as a
|
|
355
|
+
`run:` step, such as `bunx checks-comment-gate`, in the repo's own
|
|
356
|
+
workflows.
|
|
357
|
+
|
|
358
|
+
The default branch is `main`; a repo with another one sets
|
|
359
|
+
`"defaultBranch"` beside `"gates"`.
|
|
360
|
+
|
|
361
|
+
It exits 1 naming each gap, with every step that runs the gate and why
|
|
362
|
+
that step does not count:
|
|
363
|
+
|
|
364
|
+
```
|
|
365
|
+
ci-wiring: 1 of 8 gate(s) do not run on pull requests to main:
|
|
366
|
+
bun run lint
|
|
367
|
+
.github/workflows/release.yml job publish step 7: .github/workflows/release.yml does not trigger on pull_request
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
It exits 2 when `package.json` declares no gates, a gate is not one
|
|
371
|
+
plain command, or a workflow does not parse. Whether a workflow is
|
|
372
|
+
well formed is actionlint's question, not this one's.
|
|
373
|
+
|
|
374
|
+
### Limits
|
|
375
|
+
|
|
376
|
+
The check reads workflow files and never runs them, so it deliberately
|
|
377
|
+
does not evaluate:
|
|
378
|
+
|
|
379
|
+
- an `if:` expression other than a constant `true` or `false`, which
|
|
380
|
+
counts as running;
|
|
381
|
+
- a `strategy.matrix` `include` or `exclude`, so a matrix that drops
|
|
382
|
+
every combination still counts as running its steps;
|
|
383
|
+
- a remote reusable workflow (`uses: owner/repo/...@ref`), whose steps
|
|
384
|
+
are never read;
|
|
385
|
+
- anything that happens at run time on the runner: what the gate
|
|
386
|
+
command itself does, the shell's options, and a step or job that
|
|
387
|
+
fails or times out before the gate step.
|
|
388
|
+
|
|
277
389
|
## Backtest
|
|
278
390
|
|
|
279
391
|
`scripts/backtest.ts` reports what the comment check would have refused
|
|
@@ -376,6 +488,12 @@ The shared Stryker preset's `json` reporter writes
|
|
|
376
488
|
never falls back to the registry the way `bunx` does. The `.ts` checks
|
|
377
489
|
keep a `bun` shebang, which needs no build step and no `dist/`
|
|
378
490
|
entry, unlike the oxlint plugin that node loads.
|
|
491
|
+
- `checks-ci-wiring` runs inside `lint`, not in a workflow of its own:
|
|
492
|
+
deleting the step that runs a check is the violation it catches, so the
|
|
493
|
+
local `lint` is where it has to fail.
|
|
494
|
+
- Workflows are parsed with `Bun.YAML`, which the `bun` shebang already
|
|
495
|
+
provides, so the check adds no dependency. It reads `on` as a string
|
|
496
|
+
key, not as the YAML 1.1 boolean.
|
|
379
497
|
- `bun` counts as a built-in module. Nothing installed resolves it except
|
|
380
498
|
`@types/bun`, which would otherwise make every runtime `bun` import look
|
|
381
499
|
like a dev-only dependency.
|
|
@@ -409,7 +527,7 @@ a tag off `main` and reruns the build, `dist/` check, lint, typecheck
|
|
|
409
527
|
and tests before it publishes:
|
|
410
528
|
|
|
411
529
|
```sh
|
|
412
|
-
git tag v0.
|
|
530
|
+
git tag v0.4.0 && git push origin v0.4.0
|
|
413
531
|
```
|
|
414
532
|
|
|
415
533
|
The `release` workflow publishes the tagged version through npm
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Deterministic checks shared across the captain's TypeScript repos",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"scripts/test-layout.ts",
|
|
20
20
|
"scripts/commit-identity.ts",
|
|
21
21
|
"scripts/mutation-compare.ts",
|
|
22
|
+
"scripts/ci-wiring.ts",
|
|
22
23
|
"scripts/comments.ts",
|
|
23
24
|
"scripts/comment-gate.ts",
|
|
24
25
|
"scripts/backtest.ts",
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
"./scripts/test-layout.ts": "./scripts/test-layout.ts",
|
|
36
37
|
"./scripts/commit-identity.ts": "./scripts/commit-identity.ts",
|
|
37
38
|
"./scripts/mutation-compare.ts": "./scripts/mutation-compare.ts",
|
|
39
|
+
"./scripts/ci-wiring.ts": "./scripts/ci-wiring.ts",
|
|
38
40
|
"./scripts/comments.ts": "./scripts/comments.ts",
|
|
39
41
|
"./scripts/comment-gate.ts": "./scripts/comment-gate.ts",
|
|
40
42
|
"./scripts/backtest.ts": "./scripts/backtest.ts",
|
|
@@ -48,15 +50,28 @@
|
|
|
48
50
|
"checks-test-layout": "scripts/test-layout.ts",
|
|
49
51
|
"checks-commit-identity": "scripts/commit-identity.ts",
|
|
50
52
|
"checks-mutation-compare": "scripts/mutation-compare.ts",
|
|
53
|
+
"checks-ci-wiring": "scripts/ci-wiring.ts",
|
|
51
54
|
"checks-comment-gate": "scripts/comment-gate.ts",
|
|
52
55
|
"checks-backtest": "scripts/backtest.ts"
|
|
53
56
|
},
|
|
54
57
|
"scripts": {
|
|
55
58
|
"build": "bun build effect-channel/index.ts --outdir dist --target node --format esm",
|
|
56
|
-
"lint": "oxlint --type-aware && ./scripts/lint-coverage.sh && bun scripts/test-layout.ts && bun scripts/commit-identity.ts HEAD && bun scripts/comment-gate.ts HEAD && depcruise --config .dependency-cruiser.cjs effect-channel scripts tests commitlint.config.js dependency-cruiser.config.js stryker.preset.js .dependency-cruiser.cjs",
|
|
59
|
+
"lint": "oxlint --type-aware && ./scripts/lint-coverage.sh && bun scripts/test-layout.ts && bun scripts/commit-identity.ts HEAD && bun scripts/comment-gate.ts HEAD && bun scripts/ci-wiring.ts && depcruise --config .dependency-cruiser.cjs effect-channel scripts tests commitlint.config.js dependency-cruiser.config.js stryker.preset.js .dependency-cruiser.cjs",
|
|
57
60
|
"typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
|
|
58
61
|
"test": "bun test --randomize"
|
|
59
62
|
},
|
|
63
|
+
"ciWiring": {
|
|
64
|
+
"gates": [
|
|
65
|
+
"bun run build",
|
|
66
|
+
"git diff --exit-code dist/",
|
|
67
|
+
"bun run lint",
|
|
68
|
+
"bun run typecheck",
|
|
69
|
+
"bun run test",
|
|
70
|
+
"./node_modules/.bin/commitlint",
|
|
71
|
+
"bun .checks/scripts/commit-identity.ts",
|
|
72
|
+
"bun .checks/scripts/comment-gate.ts"
|
|
73
|
+
]
|
|
74
|
+
},
|
|
60
75
|
"peerDependencies": {
|
|
61
76
|
"@swc/core": "1.16.2",
|
|
62
77
|
"dependency-cruiser": "18.4.0",
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type Command = readonly string[];
|
|
6
|
+
|
|
7
|
+
export type Gate = {
|
|
8
|
+
readonly command: string;
|
|
9
|
+
readonly words: Command;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type Declaration = {
|
|
13
|
+
readonly gates: readonly Gate[];
|
|
14
|
+
readonly defaultBranch: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type Workflow = {
|
|
18
|
+
readonly path: string;
|
|
19
|
+
readonly document: unknown;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type BlockedInvocation = {
|
|
23
|
+
readonly location: string;
|
|
24
|
+
readonly blocker: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type Gap = {
|
|
28
|
+
readonly gate: string;
|
|
29
|
+
readonly blocked: readonly BlockedInvocation[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type RunStep = {
|
|
33
|
+
readonly location: string;
|
|
34
|
+
readonly blocker: string | undefined;
|
|
35
|
+
readonly script: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export class WiringError extends Error {}
|
|
39
|
+
|
|
40
|
+
const WORKFLOWS = ".github/workflows";
|
|
41
|
+
const DEFAULT_BRANCH = "main";
|
|
42
|
+
// Without these a pull_request workflow never sees the commits a pull request pushes.
|
|
43
|
+
const GATING_TYPES = ["opened", "synchronize"];
|
|
44
|
+
const CONSTANTS = new Map([
|
|
45
|
+
["true", true],
|
|
46
|
+
["false", false],
|
|
47
|
+
]);
|
|
48
|
+
const STATUS_OVERRIDE = /\b(?:always|failure|cancelled)\s*\(/;
|
|
49
|
+
const VARIABLE = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/;
|
|
50
|
+
const UNPLAIN = new Set(["|", "&", ";", "<", ">", "(", ")", "`", "\\", "#", "\n"]);
|
|
51
|
+
|
|
52
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
53
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function names(value: unknown): readonly string[] | undefined {
|
|
57
|
+
if (typeof value === "string") return [value];
|
|
58
|
+
if (!Array.isArray(value)) return undefined;
|
|
59
|
+
return value.filter((entry): entry is string => typeof entry === "string");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function expansion(text: string, from: number): number | undefined {
|
|
63
|
+
const match = VARIABLE.exec(text.slice(from));
|
|
64
|
+
return match === null ? undefined : from + match[0].length;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A script counts only when it is one line of plain words: any shell control, redirection or
|
|
68
|
+
// substitution can run the gate without its failure failing the step.
|
|
69
|
+
function plainCommand(script: string): Command | undefined {
|
|
70
|
+
const line = script.trim();
|
|
71
|
+
const words: string[] = [];
|
|
72
|
+
let word: string | undefined;
|
|
73
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
74
|
+
const char = line.charAt(index);
|
|
75
|
+
if (char === " " || char === "\t") {
|
|
76
|
+
if (word !== undefined) words.push(word);
|
|
77
|
+
word = undefined;
|
|
78
|
+
} else if (char === "'") {
|
|
79
|
+
const close = line.indexOf("'", index + 1);
|
|
80
|
+
if (close === -1) return undefined;
|
|
81
|
+
word = (word ?? "") + line.slice(index + 1, close);
|
|
82
|
+
index = close;
|
|
83
|
+
} else if (char === '"') {
|
|
84
|
+
let quoted = "";
|
|
85
|
+
for (index += 1; line.charAt(index) !== '"'; index += 1) {
|
|
86
|
+
const inner = line.charAt(index);
|
|
87
|
+
if (inner === "" || inner === "\\" || inner === "`") return undefined;
|
|
88
|
+
if (inner === "$") {
|
|
89
|
+
const end = expansion(line, index);
|
|
90
|
+
if (end === undefined) return undefined;
|
|
91
|
+
quoted += line.slice(index, end);
|
|
92
|
+
index = end - 1;
|
|
93
|
+
} else {
|
|
94
|
+
quoted += inner;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
word = (word ?? "") + quoted;
|
|
98
|
+
} else if (char === "$") {
|
|
99
|
+
const end = expansion(line, index);
|
|
100
|
+
if (end === undefined) return undefined;
|
|
101
|
+
word = (word ?? "") + line.slice(index, end);
|
|
102
|
+
index = end - 1;
|
|
103
|
+
} else if (UNPLAIN.has(char)) {
|
|
104
|
+
return undefined;
|
|
105
|
+
} else {
|
|
106
|
+
word = (word ?? "") + char;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (word !== undefined) words.push(word);
|
|
110
|
+
return words.length === 0 ? undefined : words;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function invokes(command: Command | undefined, gate: Command): boolean {
|
|
114
|
+
return command !== undefined && gate.length <= command.length && gate.every((word, index) => command[index] === word);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function mentions(script: string, gate: Command): boolean {
|
|
118
|
+
const tokens = script.split(/[\s|&;<>()`]+/);
|
|
119
|
+
return tokens.some((_, start) => invokes(tokens.slice(start), gate));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function constant(value: unknown): boolean | undefined {
|
|
123
|
+
if (typeof value === "boolean") return value;
|
|
124
|
+
if (typeof value !== "string") return undefined;
|
|
125
|
+
const expression = value.trim().replace(/^\$\{\{(.*)\}\}$/s, "$1").trim();
|
|
126
|
+
return CONSTANTS.get(expression);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function switchedOff(node: Readonly<Record<string, unknown>>, subject: string): string | undefined {
|
|
130
|
+
if (constant(node["if"]) === false) return `${subject} sets if: false`;
|
|
131
|
+
if (constant(node["continue-on-error"]) === true) return `${subject} sets continue-on-error: true`;
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Last match wins, as GitHub evaluates a branch filter, so one match anywhere is not enough.
|
|
136
|
+
function selects(patterns: readonly string[], branch: string): boolean {
|
|
137
|
+
let selected = false;
|
|
138
|
+
for (const pattern of patterns) {
|
|
139
|
+
const excludes = pattern.startsWith("!");
|
|
140
|
+
if (new Bun.Glob(excludes ? pattern.slice(1) : pattern).match(branch)) selected = !excludes;
|
|
141
|
+
}
|
|
142
|
+
return selected;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function triggerBlocker(workflow: Workflow, branch: string): string | undefined {
|
|
146
|
+
const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
|
|
147
|
+
const events = isRecord(on) ? Object.keys(on) : (names(on) ?? []);
|
|
148
|
+
if (!events.includes("pull_request")) return `${workflow.path} does not trigger on pull_request`;
|
|
149
|
+
|
|
150
|
+
const filters = isRecord(on) ? on["pull_request"] : undefined;
|
|
151
|
+
if (!isRecord(filters)) return undefined;
|
|
152
|
+
const branches = names(filters["branches"]);
|
|
153
|
+
if (branches !== undefined && !selects(branches, branch)) {
|
|
154
|
+
return `${workflow.path} limits pull_request to branches other than ${branch}`;
|
|
155
|
+
}
|
|
156
|
+
const ignored = names(filters["branches-ignore"]);
|
|
157
|
+
if (ignored?.some((pattern) => new Bun.Glob(pattern).match(branch)) === true) {
|
|
158
|
+
return `${workflow.path} ignores pull_request to ${branch}`;
|
|
159
|
+
}
|
|
160
|
+
if (filters["paths"] !== undefined || filters["paths-ignore"] !== undefined) {
|
|
161
|
+
return `${workflow.path} filters pull_request by paths, so some pull requests skip the gate`;
|
|
162
|
+
}
|
|
163
|
+
const types = names(filters["types"]);
|
|
164
|
+
const missing = types === undefined ? [] : GATING_TYPES.filter((type) => !types.includes(type));
|
|
165
|
+
if (missing.length > 0) return `${workflow.path} limits pull_request to types without ${missing.join(", ")}`;
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// GitHub prefixes any other if: with success(), so only a status function overrides a needed job's skip.
|
|
170
|
+
function skippedBy(jobs: Readonly<Record<string, unknown>>, id: string, seen: readonly string[]): string | undefined {
|
|
171
|
+
const job = jobs[id];
|
|
172
|
+
if (!isRecord(job) || seen.includes(id)) return undefined;
|
|
173
|
+
if (constant(job["if"]) === false) return `job ${id} sets if: false`;
|
|
174
|
+
if (typeof job["if"] === "string" && STATUS_OVERRIDE.test(job["if"])) return undefined;
|
|
175
|
+
for (const need of names(job["needs"]) ?? []) {
|
|
176
|
+
const cause = skippedBy(jobs, need, [...seen, id]);
|
|
177
|
+
if (cause !== undefined) return cause;
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function localCall(uses: unknown): string | undefined {
|
|
183
|
+
return typeof uses === "string" && uses.startsWith("./") ? uses.slice(2) : undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function runSteps(workflows: readonly Workflow[], branch: string): readonly RunStep[] {
|
|
187
|
+
const documents = new Map(workflows.map((workflow) => [workflow.path, workflow.document]));
|
|
188
|
+
const steps: RunStep[] = [];
|
|
189
|
+
|
|
190
|
+
const visit = (
|
|
191
|
+
document: unknown,
|
|
192
|
+
location: string,
|
|
193
|
+
blocker: string | undefined,
|
|
194
|
+
walked: readonly string[],
|
|
195
|
+
): void => {
|
|
196
|
+
const jobs = isRecord(document) ? document["jobs"] : undefined;
|
|
197
|
+
if (!isRecord(jobs)) return;
|
|
198
|
+
for (const [id, job] of Object.entries(jobs)) {
|
|
199
|
+
if (!isRecord(job)) continue;
|
|
200
|
+
const jobLocation = `${location} job ${id}`;
|
|
201
|
+
const skipped = skippedBy(jobs, id, []);
|
|
202
|
+
const jobBlocker =
|
|
203
|
+
blocker ??
|
|
204
|
+
switchedOff(job, `job ${id}`) ??
|
|
205
|
+
(skipped === undefined ? undefined : `job ${id} needs a job that never runs: ${skipped}`);
|
|
206
|
+
const called = localCall(job["uses"]);
|
|
207
|
+
if (called !== undefined && !walked.includes(called)) {
|
|
208
|
+
visit(documents.get(called), `${jobLocation} > ${called}`, jobBlocker, [...walked, called]);
|
|
209
|
+
}
|
|
210
|
+
const jobSteps = job["steps"];
|
|
211
|
+
if (!Array.isArray(jobSteps)) continue;
|
|
212
|
+
jobSteps.forEach((step: unknown, index) => {
|
|
213
|
+
if (!isRecord(step) || typeof step["run"] !== "string") return;
|
|
214
|
+
steps.push({
|
|
215
|
+
location: `${jobLocation} step ${index + 1}`,
|
|
216
|
+
blocker: jobBlocker ?? switchedOff(step, "the step"),
|
|
217
|
+
script: step["run"],
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
for (const workflow of workflows) {
|
|
224
|
+
visit(workflow.document, workflow.path, triggerBlocker(workflow, branch), [workflow.path]);
|
|
225
|
+
}
|
|
226
|
+
return steps;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function findGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
|
|
230
|
+
const steps = runSteps(workflows, declaration.defaultBranch);
|
|
231
|
+
return declaration.gates.flatMap((gate) => {
|
|
232
|
+
const alone = `the step runs more than ${gate.command}; give it its own step with nothing else in it`;
|
|
233
|
+
const invoking = steps.flatMap(({ location, blocker, script }) => {
|
|
234
|
+
if (invokes(plainCommand(script), gate.words)) return [{ location, blocker }];
|
|
235
|
+
return mentions(script, gate.words) ? [{ location, blocker: blocker ?? alone }] : [];
|
|
236
|
+
});
|
|
237
|
+
if (invoking.some((step) => step.blocker === undefined)) return [];
|
|
238
|
+
const blocked = invoking.flatMap(({ location, blocker }) =>
|
|
239
|
+
blocker === undefined ? [] : [{ location, blocker }],
|
|
240
|
+
);
|
|
241
|
+
return [{ gate: gate.command, blocked }];
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function formatReport(declaration: Declaration, gaps: readonly Gap[]): string {
|
|
246
|
+
const target = `pull requests to ${declaration.defaultBranch}`;
|
|
247
|
+
if (gaps.length === 0) return `ci-wiring: ${declaration.gates.length} gate(s) run on ${target}`;
|
|
248
|
+
const lines = [`ci-wiring: ${gaps.length} of ${declaration.gates.length} gate(s) do not run on ${target}:`];
|
|
249
|
+
for (const gap of gaps) {
|
|
250
|
+
lines.push(` ${gap.gate}`);
|
|
251
|
+
if (gap.blocked.length === 0) lines.push(" no run step invokes it");
|
|
252
|
+
for (const { location, blocker } of gap.blocked) lines.push(` ${location}: ${blocker}`);
|
|
253
|
+
}
|
|
254
|
+
return lines.join("\n");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function parseDeclaration(manifest: unknown, source: string): Declaration {
|
|
258
|
+
const configured = isRecord(manifest) && isRecord(manifest["ciWiring"]) ? manifest["ciWiring"] : {};
|
|
259
|
+
const listed = configured["gates"];
|
|
260
|
+
if (!Array.isArray(listed) || listed.length === 0) {
|
|
261
|
+
throw new WiringError(`ci-wiring: ${source} sets no ciWiring.gates, a non-empty array of commands`);
|
|
262
|
+
}
|
|
263
|
+
const gates = listed.map((command: unknown): Gate => {
|
|
264
|
+
const words = typeof command === "string" ? plainCommand(command) : undefined;
|
|
265
|
+
if (typeof command !== "string" || words === undefined) {
|
|
266
|
+
throw new WiringError(`ci-wiring: ${source} ciWiring gate ${JSON.stringify(command)} is not one plain command`);
|
|
267
|
+
}
|
|
268
|
+
return { command, words };
|
|
269
|
+
});
|
|
270
|
+
const defaultBranch = configured["defaultBranch"] ?? DEFAULT_BRANCH;
|
|
271
|
+
if (typeof defaultBranch !== "string" || defaultBranch === "") {
|
|
272
|
+
throw new WiringError(`ci-wiring: ${source} ciWiring.defaultBranch is not a branch name`);
|
|
273
|
+
}
|
|
274
|
+
return { gates, defaultBranch };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function parseWorkflow(path: string, text: string): Workflow {
|
|
278
|
+
try {
|
|
279
|
+
return { path, document: Bun.YAML.parse(text) };
|
|
280
|
+
} catch (error) {
|
|
281
|
+
throw new WiringError(`ci-wiring: cannot parse ${path}: ${String(error)}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function readDeclaration(root: string): Declaration {
|
|
286
|
+
const path = join(root, "package.json");
|
|
287
|
+
let manifest: unknown;
|
|
288
|
+
try {
|
|
289
|
+
manifest = JSON.parse(readFileSync(path, "utf8"));
|
|
290
|
+
} catch {
|
|
291
|
+
throw new WiringError(`ci-wiring: cannot read ${path} as JSON`);
|
|
292
|
+
}
|
|
293
|
+
return parseDeclaration(manifest, path);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function readWorkflows(root: string): readonly Workflow[] {
|
|
297
|
+
const directory = join(root, WORKFLOWS);
|
|
298
|
+
if (!existsSync(directory)) return [];
|
|
299
|
+
return readdirSync(directory)
|
|
300
|
+
.filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"))
|
|
301
|
+
.sort()
|
|
302
|
+
.map((name) => parseWorkflow(`${WORKFLOWS}/${name}`, readFileSync(join(directory, name), "utf8")));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function repositoryRoot(): string {
|
|
306
|
+
const result = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { stdout: "pipe", stderr: "pipe" });
|
|
307
|
+
if (!result.success) {
|
|
308
|
+
throw new WiringError(`ci-wiring: git rev-parse --show-toplevel: ${result.stderr.toString().trim()}`);
|
|
309
|
+
}
|
|
310
|
+
return result.stdout.toString().trim();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (import.meta.main) {
|
|
314
|
+
try {
|
|
315
|
+
const root = repositoryRoot();
|
|
316
|
+
const declaration = readDeclaration(root);
|
|
317
|
+
const gaps = findGaps(declaration, readWorkflows(root));
|
|
318
|
+
if (gaps.length > 0) {
|
|
319
|
+
console.error(formatReport(declaration, gaps));
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
console.log(formatReport(declaration, gaps));
|
|
323
|
+
} catch (error) {
|
|
324
|
+
console.error(error instanceof WiringError ? error.message : `ci-wiring: ${String(error)}`);
|
|
325
|
+
process.exit(2);
|
|
326
|
+
}
|
|
327
|
+
}
|
package/scripts/test-layout.ts
CHANGED
|
@@ -39,6 +39,9 @@ const BANNED_BUN_NAMES: readonly string[] = [
|
|
|
39
39
|
const BANNED_GLOBAL_CALLS: readonly string[] = ["fetch"];
|
|
40
40
|
|
|
41
41
|
export const REQUIRED_TEST_SCRIPT = "bun test --randomize";
|
|
42
|
+
const REQUIRED_TEST_TABLE: Record<string, unknown> = {
|
|
43
|
+
pathIgnorePatterns: ["**/tests/quarantine/**"],
|
|
44
|
+
};
|
|
42
45
|
export const LAYOUT_CHECK_MARK = "scripts/test-layout.ts";
|
|
43
46
|
export const LAYOUT_CHECK_BIN = "checks-test-layout";
|
|
44
47
|
|
|
@@ -236,9 +239,10 @@ export function bunfigViolations(consumer: unknown, preset: unknown): readonly V
|
|
|
236
239
|
if (!isRecord(presetTest)) {
|
|
237
240
|
return [{ file, line: undefined, message: "the shipped bunfig preset has no [test] table" }];
|
|
238
241
|
}
|
|
242
|
+
const expected = { ...presetTest, ...REQUIRED_TEST_TABLE };
|
|
239
243
|
const consumerTest = isRecord(consumer) ? consumer["test"] : undefined;
|
|
240
244
|
const violations: Violation[] = [];
|
|
241
|
-
for (const [key, value] of Object.entries(
|
|
245
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
242
246
|
const found = isRecord(consumerTest) ? consumerTest[key] : undefined;
|
|
243
247
|
if (!Bun.deepEquals(found, value)) {
|
|
244
248
|
violations.push({
|