@avi2dg/checks 0.3.0 → 0.5.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 +168 -8
- package/dist/index.js +43 -1
- 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
|
|
|
@@ -80,6 +80,48 @@ export default {
|
|
|
80
80
|
The registry version is pinned by the consumer's lockfile; bump
|
|
81
81
|
`@avi2dg/checks` to adopt a new release.
|
|
82
82
|
|
|
83
|
+
## Effect rules
|
|
84
|
+
|
|
85
|
+
The base config loads the `effect-channel` plugin and turns on
|
|
86
|
+
`effect-channel/no-error-channel-escape`, which refuses `Effect.ignore`,
|
|
87
|
+
`Effect.ignoreCause`, the `Effect.catchCause` family, and an
|
|
88
|
+
`Effect.catch` whose handler takes no error or names it `_`.
|
|
89
|
+
|
|
90
|
+
Two more rules ship off, because a repository writes only some of its
|
|
91
|
+
paths in Effect: code a host loads without `node_modules`, such as a
|
|
92
|
+
hook bundle or this oxlint plugin, cannot import it.
|
|
93
|
+
|
|
94
|
+
- `effect-channel/no-throw` refuses a `throw` statement.
|
|
95
|
+
- `effect-channel/no-try-catch` refuses a `try` statement with a
|
|
96
|
+
`catch` clause. `try`/`finally` stays allowed.
|
|
97
|
+
|
|
98
|
+
Each refusal says what to write instead: a `Schema.TaggedError` failed
|
|
99
|
+
through `Effect.fail`, a throwing call wrapped in `Effect.try` or
|
|
100
|
+
`Effect.tryPromise`, and recovery by tag with `Effect.catchTag`.
|
|
101
|
+
|
|
102
|
+
A repository turns them on for the paths it writes in Effect through an
|
|
103
|
+
`overrides` entry in its root `.oxlintrc.json`:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"extends": ["./node_modules/@avi2dg/checks/oxlintrc.json"],
|
|
108
|
+
"plugins": ["typescript", "oxc", "eslint", "import"],
|
|
109
|
+
"overrides": [
|
|
110
|
+
{
|
|
111
|
+
"files": ["src/**"],
|
|
112
|
+
"rules": {
|
|
113
|
+
"effect-channel/no-throw": "error",
|
|
114
|
+
"effect-channel/no-try-catch": "error"
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
oxlint resolves `files` against the directory of the config that holds
|
|
122
|
+
the override, so a config passed with `-c` from outside the repository
|
|
123
|
+
matches nothing and reports nothing.
|
|
124
|
+
|
|
83
125
|
## Test layout
|
|
84
126
|
|
|
85
127
|
`checks-test-layout` fails unless the repo holds this shape, and names the
|
|
@@ -105,7 +147,10 @@ file and the path to move it to when it does not:
|
|
|
105
147
|
- `scripts.test` is exactly `bun test --randomize` and `scripts.lint` runs
|
|
106
148
|
this check.
|
|
107
149
|
- `bunfig.toml` carries every `[test]` key of the shipped preset with the
|
|
108
|
-
same value
|
|
150
|
+
same value, and `[test].pathIgnorePatterns` is always
|
|
151
|
+
`["**/tests/quarantine/**"]`: the check pins it itself, so this repo,
|
|
152
|
+
whose bunfig is the preset, cannot drift it either. Other tables, and
|
|
153
|
+
extra `[test]` keys, are the repo's own.
|
|
109
154
|
|
|
110
155
|
The in-process half is what a mutation run can mutate; `tests/e2e/**` is
|
|
111
156
|
excluded from a mutate scope by construction, because a subprocess kills
|
|
@@ -183,7 +228,15 @@ on:
|
|
|
183
228
|
types: [opened, edited, synchronize, reopened]
|
|
184
229
|
jobs:
|
|
185
230
|
commitlint:
|
|
186
|
-
|
|
231
|
+
runs-on: ubuntu-latest
|
|
232
|
+
steps:
|
|
233
|
+
- uses: actions/checkout@v5
|
|
234
|
+
- uses: oven-sh/setup-bun@v2
|
|
235
|
+
- run: bun install --frozen-lockfile
|
|
236
|
+
- run: printf '%s' "$PR_TITLE (#0000)" > "$RUNNER_TEMP/pr-title"
|
|
237
|
+
env:
|
|
238
|
+
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
239
|
+
- run: ./node_modules/.bin/commitlint --config ./node_modules/@avi2dg/checks/commitlint.config.js --edit "$RUNNER_TEMP/pr-title"
|
|
187
240
|
```
|
|
188
241
|
|
|
189
242
|
It lints the pull request title and nothing else. The title is the
|
|
@@ -234,10 +287,20 @@ on:
|
|
|
234
287
|
types: [opened, edited, synchronize, reopened]
|
|
235
288
|
jobs:
|
|
236
289
|
commit-identity:
|
|
237
|
-
|
|
290
|
+
runs-on: ubuntu-latest
|
|
291
|
+
steps:
|
|
292
|
+
- uses: actions/checkout@v5
|
|
293
|
+
with:
|
|
294
|
+
fetch-depth: 0
|
|
295
|
+
- uses: oven-sh/setup-bun@v2
|
|
296
|
+
- run: bun install --frozen-lockfile
|
|
297
|
+
- run: bunx checks-commit-identity "origin/$BASE_REF" "$HEAD_SHA"
|
|
298
|
+
env:
|
|
299
|
+
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
300
|
+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
238
301
|
```
|
|
239
302
|
|
|
240
|
-
The
|
|
303
|
+
The checkout fetches the full history and the range starts at the
|
|
241
304
|
fetched base branch, not the event's recorded base sha, which GitHub
|
|
242
305
|
leaves stale once the base branch advances after the pull request opens.
|
|
243
306
|
|
|
@@ -271,9 +334,100 @@ on:
|
|
|
271
334
|
types: [opened, edited, synchronize, reopened]
|
|
272
335
|
jobs:
|
|
273
336
|
comment-gate:
|
|
274
|
-
|
|
337
|
+
runs-on: ubuntu-latest
|
|
338
|
+
steps:
|
|
339
|
+
- uses: actions/checkout@v5
|
|
340
|
+
with:
|
|
341
|
+
fetch-depth: 0
|
|
342
|
+
- uses: oven-sh/setup-bun@v2
|
|
343
|
+
- run: bun install --frozen-lockfile
|
|
344
|
+
- run: bunx checks-comment-gate "origin/$BASE_REF" "$HEAD_SHA"
|
|
345
|
+
env:
|
|
346
|
+
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
347
|
+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## CI wiring
|
|
351
|
+
|
|
352
|
+
`checks-ci-wiring` fails when a command the repository's CI must run no
|
|
353
|
+
longer runs on pull requests to the default branch. No local check sees
|
|
354
|
+
that: a workflow whose lint step became a no-op leaves `bun run lint`
|
|
355
|
+
green.
|
|
356
|
+
|
|
357
|
+
The repository declares its gates once, in `package.json`, and `lint`
|
|
358
|
+
runs the check:
|
|
359
|
+
|
|
360
|
+
```json
|
|
361
|
+
"ciWiring": {
|
|
362
|
+
"gates": ["bun run lint", "bun run typecheck", "bun run test"]
|
|
363
|
+
},
|
|
364
|
+
"scripts": {
|
|
365
|
+
"lint": "oxlint --type-aware && checks-lint-coverage && checks-test-layout && checks-ci-wiring"
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
It parses every `.github/workflows/*.yml` and `*.yaml` and looks, for
|
|
370
|
+
each gate, for a `run:` step that is the gate command alone on one line,
|
|
371
|
+
optionally followed by plain arguments: words, quoted strings, and
|
|
372
|
+
`$VAR` or `${VAR}` expansions. `bun run lint --quiet` and
|
|
373
|
+
`bunx checks-comment-gate "origin/$BASE_REF" "$HEAD_SHA"` count;
|
|
374
|
+
`bun run lint:deps`, `echo bun run lint` and a step `name:` do not. A
|
|
375
|
+
step whose script has a second line, or any `|`, `||`, `&&`, `;`, `&`,
|
|
376
|
+
`$(...)`, backticks, `<` or `>` redirection, a comment or a leading
|
|
377
|
+
`NAME=value`, never counts, because each can run the gate without its
|
|
378
|
+
failure failing the step; the report names the gate and says to give it
|
|
379
|
+
its own step with nothing else in it. A gate step counts only when:
|
|
380
|
+
|
|
381
|
+
- its workflow triggers on `pull_request`, any `branches` or
|
|
382
|
+
`branches-ignore` filter there keeps the default branch, any `types`
|
|
383
|
+
filter keeps `opened` and `synchronize`, and it sets no `paths` or
|
|
384
|
+
`paths-ignore` filter, which lets some pull requests skip the gate;
|
|
385
|
+
- neither the step nor its job sets `if: false` or
|
|
386
|
+
`continue-on-error: true`, bare or as `${{ false }}` and `${{ true }}`;
|
|
387
|
+
- its job needs no job, directly or through a chain, that sets
|
|
388
|
+
`if: false`, unless a job on that chain has an `if:` calling
|
|
389
|
+
`always()`, `failure()` or `cancelled()`. GitHub prefixes every other
|
|
390
|
+
`if:`, including `true` and `success()`, with `success()`, so a job
|
|
391
|
+
whose needed job was skipped is skipped too.
|
|
392
|
+
|
|
393
|
+
A job calling a local reusable workflow (`uses: ./.github/workflows/x.yml`)
|
|
394
|
+
passes its own trigger and `if:` down to the called workflow's steps.
|
|
395
|
+
A remote reusable workflow (`uses: owner/repo/...@ref`) is not a
|
|
396
|
+
supported way to wire a gate: it is not read, so a gate must run as a
|
|
397
|
+
`run:` step, such as `bunx checks-comment-gate`, in the repo's own
|
|
398
|
+
workflows.
|
|
399
|
+
|
|
400
|
+
The default branch is `main`; a repo with another one sets
|
|
401
|
+
`"defaultBranch"` beside `"gates"`.
|
|
402
|
+
|
|
403
|
+
It exits 1 naming each gap, with every step that runs the gate and why
|
|
404
|
+
that step does not count:
|
|
405
|
+
|
|
406
|
+
```
|
|
407
|
+
ci-wiring: 1 of 8 gate(s) do not run on pull requests to main:
|
|
408
|
+
bun run lint
|
|
409
|
+
.github/workflows/release.yml job publish step 7: .github/workflows/release.yml does not trigger on pull_request
|
|
275
410
|
```
|
|
276
411
|
|
|
412
|
+
It exits 2 when `package.json` declares no gates, a gate is not one
|
|
413
|
+
plain command, or a workflow does not parse. Whether a workflow is
|
|
414
|
+
well formed is actionlint's question, not this one's.
|
|
415
|
+
|
|
416
|
+
### Limits
|
|
417
|
+
|
|
418
|
+
The check reads workflow files and never runs them, so it deliberately
|
|
419
|
+
does not evaluate:
|
|
420
|
+
|
|
421
|
+
- an `if:` expression other than a constant `true` or `false`, which
|
|
422
|
+
counts as running;
|
|
423
|
+
- a `strategy.matrix` `include` or `exclude`, so a matrix that drops
|
|
424
|
+
every combination still counts as running its steps;
|
|
425
|
+
- a remote reusable workflow (`uses: owner/repo/...@ref`), whose steps
|
|
426
|
+
are never read;
|
|
427
|
+
- anything that happens at run time on the runner: what the gate
|
|
428
|
+
command itself does, the shell's options, and a step or job that
|
|
429
|
+
fails or times out before the gate step.
|
|
430
|
+
|
|
277
431
|
## Backtest
|
|
278
432
|
|
|
279
433
|
`scripts/backtest.ts` reports what the comment check would have refused
|
|
@@ -376,6 +530,12 @@ The shared Stryker preset's `json` reporter writes
|
|
|
376
530
|
never falls back to the registry the way `bunx` does. The `.ts` checks
|
|
377
531
|
keep a `bun` shebang, which needs no build step and no `dist/`
|
|
378
532
|
entry, unlike the oxlint plugin that node loads.
|
|
533
|
+
- `checks-ci-wiring` runs inside `lint`, not in a workflow of its own:
|
|
534
|
+
deleting the step that runs a check is the violation it catches, so the
|
|
535
|
+
local `lint` is where it has to fail.
|
|
536
|
+
- Workflows are parsed with `Bun.YAML`, which the `bun` shebang already
|
|
537
|
+
provides, so the check adds no dependency. It reads `on` as a string
|
|
538
|
+
key, not as the YAML 1.1 boolean.
|
|
379
539
|
- `bun` counts as a built-in module. Nothing installed resolves it except
|
|
380
540
|
`@types/bun`, which would otherwise make every runtime `bun` import look
|
|
381
541
|
like a dev-only dependency.
|
|
@@ -409,7 +569,7 @@ a tag off `main` and reruns the build, `dist/` check, lint, typecheck
|
|
|
409
569
|
and tests before it publishes:
|
|
410
570
|
|
|
411
571
|
```sh
|
|
412
|
-
git tag v0.
|
|
572
|
+
git tag v0.5.0 && git push origin v0.5.0
|
|
413
573
|
```
|
|
414
574
|
|
|
415
575
|
The `release` workflow publishes the tagged version through npm
|
package/dist/index.js
CHANGED
|
@@ -69,10 +69,52 @@ var rule = {
|
|
|
69
69
|
};
|
|
70
70
|
var no_error_channel_escape_default = rule;
|
|
71
71
|
|
|
72
|
+
// effect-channel/no-throw.ts
|
|
73
|
+
var rule2 = {
|
|
74
|
+
meta: {
|
|
75
|
+
type: "problem",
|
|
76
|
+
docs: { description: "Disallow throw, which fails outside Effect's error channel" }
|
|
77
|
+
},
|
|
78
|
+
create(context) {
|
|
79
|
+
return {
|
|
80
|
+
ThrowStatement(node) {
|
|
81
|
+
context.report({
|
|
82
|
+
node,
|
|
83
|
+
message: "throw escapes the error channel: no type records the failure, so no caller has to answer for it. Define the failure with Schema.TaggedError and fail with it through Effect.fail, so it stays in E for Effect.catchTag to handle"
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
var no_throw_default = rule2;
|
|
90
|
+
|
|
91
|
+
// effect-channel/no-try-catch.ts
|
|
92
|
+
var rule3 = {
|
|
93
|
+
meta: {
|
|
94
|
+
type: "problem",
|
|
95
|
+
docs: { description: "Disallow a try statement with a catch clause, which recovers outside Effect's error channel" }
|
|
96
|
+
},
|
|
97
|
+
create(context) {
|
|
98
|
+
return {
|
|
99
|
+
CatchClause(node) {
|
|
100
|
+
context.report({
|
|
101
|
+
node,
|
|
102
|
+
message: "catch recovers outside the error channel: it takes whatever was thrown as unknown, bugs included. Wrap the throwing call in Effect.try or Effect.tryPromise, whose catch maps the cause to a Schema.TaggedError, and recover by tag with Effect.catchTag"
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
var no_try_catch_default = rule3;
|
|
109
|
+
|
|
72
110
|
// effect-channel/index.ts
|
|
73
111
|
var plugin = {
|
|
74
112
|
meta: { name: "effect-channel" },
|
|
75
|
-
rules: {
|
|
113
|
+
rules: {
|
|
114
|
+
"no-error-channel-escape": no_error_channel_escape_default,
|
|
115
|
+
"no-throw": no_throw_default,
|
|
116
|
+
"no-try-catch": no_try_catch_default
|
|
117
|
+
}
|
|
76
118
|
};
|
|
77
119
|
var effect_channel_default = plugin;
|
|
78
120
|
export {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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({
|