@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 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 error-channel plugin compiled
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. Other tables, and extra `[test]` keys, are the repo's own.
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
- uses: avi2d/checks/.github/workflows/commitlint.yml@main
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
- uses: avi2d/checks/.github/workflows/commit-identity.yml@main
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 workflow fetches the consumer's full history and ranges from 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
- uses: avi2d/checks/.github/workflows/comment-gate.yml@main
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.3.0 && git push origin v0.3.0
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: { "no-error-channel-escape": no_error_channel_escape_default }
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.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
+ }
@@ -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(presetTest)) {
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({