@avi2dg/checks 0.65.2 → 0.65.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.65.3
6
+
7
+ Released 2026-11-04.
8
+
9
+ ### Fixes
10
+
11
+ - **ci-wiring:** require the night run step in TypeScript repositories [#45](https://github.com/avi2d/checks/pull/45)
12
+
5
13
  ## 0.65.2
6
14
 
7
15
  Released 2026-11-04.
package/docs/bins.md CHANGED
@@ -65,7 +65,7 @@ These bins run on their own:
65
65
  - [`checks-commit-title`](gates/checks-commit-title.md) lints the pull request title in one step of the `ci` job.
66
66
  - [`checks-pr-text`](gates/checks-pr-text.md) scans the title, body and comments of a pull request in a public repository for a secret or a term of the private pack.
67
67
  - [`checks-docs-only`](gates/checks-docs-only.md) tells whether a change touches only top-level Markdown and Markdown under `docs/`, so a docs-only change skips the full run.
68
- - [`checks-setup`](gates/checks-setup.md) writes the kit config, the CI workflow and the doc templates into a new repository.
68
+ - [`checks-setup`](gates/checks-setup.md) writes the kit config, the CI workflow, the night-run workflow and the doc templates into a new repository.
69
69
 
70
70
  `checks-lint` has [its own page](gates/checks-lint.md), which says which range it resolves.
71
71
  oxlint runs through its own tool, as [Native settings](configs/native-settings.md) says.
@@ -116,7 +116,7 @@ on:
116
116
  workflow_dispatch:
117
117
  jobs:
118
118
  advisories:
119
- runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}
119
+ runs-on: ubuntu-latest
120
120
  timeout-minutes: 10
121
121
  steps:
122
122
  - uses: actions/checkout@v5
@@ -127,7 +127,8 @@ jobs:
127
127
  - run: ./node_modules/.bin/checks-advisories --all
128
128
  ```
129
129
 
130
- The job runs on a hosted runner unless `CI_RUNS_ON` names another, as [checks-ci-wiring](checks-ci-wiring.md#runners) requires.
130
+ A public repository keeps `runs-on: ubuntu-latest`.
131
+ A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on the job, as [checks-ci-wiring](checks-ci-wiring.md#runners) requires.
131
132
  A hosted runner starts each run with an empty cache, so each run downloads both the scanner and the database.
132
133
  A self-hosted runner keeps `~/.cache/avi2dg-checks/` between runs, so it downloads the scanner once per pinned version and the database about once a day.
133
134
 
@@ -32,6 +32,16 @@ A workflow without a `name` answers to its path from the repository root.
32
32
  A local reusable `uses` must name a file under `.github/workflows/` that the repository holds.
33
33
  Each `needs` entry names a job in the same workflow, so every one must match a job id in that file.
34
34
  Each refusal names the file, the line and the missing workflow or job.
35
+ It requires a run step that invokes `checks-lint --night` in a repository that tracks TypeScript, so the four nightly gates run on the night run.
36
+ A call counts directly, through `bunx`, or through `bun` or `bun run` with the bin's path or its source file.
37
+ The call carries only `--night`, since any other argument stops the night run before it runs a gate.
38
+ A call through a `package.json` script counts, as `bun run <script>` where the named script runs the night step with the arguments the step passes.
39
+ A call through a local reusable workflow counts only through a job that uses the file that runs the night step.
40
+ A call through a repository shell script counts, as a path inside the repository whose file runs the night step.
41
+ A shell wrapper forwards its arguments through a standalone `"$@"`, so `exec checks-lint --night "$@"` counts with no arguments passed.
42
+ Shell control flow inside scripts is not evaluated: a night call after `false &&`, in an `if` branch, in a loop body or after `exit 0` counts all the same.
43
+ A call inside a function body counts whether or not the workflow calls the function.
44
+ A step or job with `if: false` or `continue-on-error: true` never counts, nor does a job that waits on one that never runs.
35
45
 
36
46
  ### Runners
37
47
 
@@ -123,6 +133,7 @@ Each refusal names the workflow file, the job and the limit.
123
133
  ## What it reads
124
134
 
125
135
  The bin reads `.github/workflows/*.yml`, `*.yaml`, the `scripts`, `private` and `checks` runners in `package.json` and the shell scripts those steps and scripts run from the working tree.
136
+ It lists the `*.ts` and `*.tsx` files `git ls-files` tracks to decide whether the night run applies.
126
137
  It reads each file a setup-bun step names for its version.
127
138
  It reads the fleet pin from the kit's own `.bun-version`.
128
139
  It reads the target branch from `GITHUB_BASE_REF`, then from `refs/remotes/origin/HEAD` and then from the event file `GITHUB_EVENT_PATH` names.
@@ -138,7 +149,7 @@ It takes no arguments.
138
149
  | Code | Result |
139
150
  | --- | --- |
140
151
  | 0 | Every required command has a reachable step. |
141
- | 1 | A required command is missing or blocked, a job runs on the wrong runner, a workflow names a missing workflow or job, a setup-bun step drifts from the fleet bun version, a workflow that releases runs on a schedule outside a public npm package or more than once a day, a workflow other than a weekly public advisory scan runs on a schedule, or a job can run for more than 60 minutes. |
152
+ | 1 | A required command is missing or blocked, a job runs on the wrong runner, a workflow names a missing workflow or job, a setup-bun step drifts from the fleet bun version, a workflow that releases runs on a schedule outside a public npm package or more than once a day, a workflow other than a weekly public advisory scan runs on a schedule, a job can run for more than 60 minutes, or a repository that tracks TypeScript never runs `checks-lint --night`. |
142
153
  | 2 | A workflow or `package.json` cannot be decoded, or the kit's own `.bun-version` is missing, unreadable or blank. |
143
154
 
144
155
  ## Sample output
@@ -151,6 +162,14 @@ ci-wiring: 1 of 6 gate(s) do not run on pull requests to main:
151
162
  no run step invokes it
152
163
  ```
153
164
 
165
+ A missing night step produces a report like this:
166
+
167
+ ```
168
+ ci-wiring: 1 gate(s) never run on the night run:
169
+ checks-lint --night
170
+ no run step invokes it
171
+ ```
172
+
154
173
  A mutation job that names `CI_RUNS_ON` in `runs-on` produces a report like this:
155
174
 
156
175
  ```
@@ -4,7 +4,7 @@ audience: consumers
4
4
  ---
5
5
  # checks-setup
6
6
 
7
- `checks-setup` writes the kit config, the CI workflow and the doc templates into a repository that does not hold them yet.
7
+ `checks-setup` writes the kit config, the CI workflow, the night-run workflow and the doc templates into a repository that does not hold them yet.
8
8
 
9
9
  ## What it checks
10
10
 
@@ -30,6 +30,9 @@ A newer push to the same pull request cancels the run in progress.
30
30
  A `--private` run pins each job to `${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}`.
31
31
  Setting the `CI_RUNS_ON` variable to a self-hosted runner label then lands the jobs there.
32
32
  A public run pins each job to `ubuntu-latest`.
33
+ It writes `.github/workflows/night-run.yml`, which installs with `bun install --frozen-lockfile` and then runs `bunx checks-lint --night`.
34
+ It runs on manual dispatch alone.
35
+ Its job uses the same runner choice as the CI workflow.
33
36
  It copies each kit template under `dist/templates/` into `templates/`, so a new doc file starts from a copy there.
34
37
  It names every file it wrote and every file it kept.
35
38
  It tells the owner to declare the repository owners in `package.json` `author` or `contributors` before the first commit.
@@ -133,6 +133,27 @@ The same job reports the title step's outcome under that name on every run, whic
133
133
  ```
134
134
 
135
135
  A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on each job, as [checks-ci-wiring](../gates/checks-ci-wiring.md#runners) requires.
136
+ Run the four nightly gates from a night-run workflow once the repository tracks TypeScript:
137
+
138
+ ```yaml
139
+ name: night-run
140
+ on:
141
+ workflow_dispatch:
142
+ jobs:
143
+ gates:
144
+ runs-on: ubuntu-latest
145
+ timeout-minutes: 20
146
+ steps:
147
+ - uses: actions/checkout@v5
148
+ with:
149
+ fetch-depth: 0
150
+ - uses: oven-sh/setup-bun@v2
151
+ with:
152
+ bun-version-file: .bun-version
153
+ - run: bun install --frozen-lockfile
154
+ - run: bunx checks-lint --night
155
+ ```
156
+
136
157
  `checks-effect-scope` writes the Effect paths of `oxlint.config.ts` and the kit's severities into `tsconfig.json`, as [The Effect rules](../configs/effect-rules.md#language-service) says.
137
158
  `bun run lint` then ends with `checks-lint: <count> gate(s) pass`.
138
159
 
@@ -31,6 +31,8 @@ To move a repository to a newer release of the kit:
31
31
  1. Run `bun run build`, whose `checks-effect-scope` rewrites the Effect override and the kit's severities in `tsconfig.json` when a release changes them.
32
32
  A severity key the release drops stays in `tsconfig.json` until the repository removes it.
33
33
  1. Copy `node_modules/@avi2dg/checks/bunfig.toml` over `bunfig.toml` again, since `checks-test-layout` compares the copy with the installed preset.
34
+ 1. When the repository tracks TypeScript, add a night-run workflow with a run step that invokes `checks-lint --night`, as [Set up a repository](set-up-a-repository.md) shows, or add that step to the workflow that already runs nightly.
35
+ Only that step runs the four nightly gates.
34
36
  1. Run `bun run lint`, `bun run typecheck` and `bun run test`.
35
37
 
36
38
  The repository's lockfile pins the kit, so a repository moves only when it runs these steps.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.65.2",
3
+ "version": "0.65.3",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -74,6 +74,7 @@
74
74
  "src/testing/night-run.ts",
75
75
  "src/testing/night-run-findings.ts",
76
76
  "src/delivery/ci-wiring.ts",
77
+ "src/delivery/night-step.ts",
77
78
  "src/delivery/shell-command.ts",
78
79
  "src/delivery/workflow-references.ts",
79
80
  "src/delivery/bun-pin.ts",
@@ -2,7 +2,7 @@
2
2
  import { Config, Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { defaultBranch, git } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
- import { ENTRY_POINT, KIT_GATES, type KitGate } from "../core/gates.ts";
5
+ import { ENTRY_POINT, KIT_GATES, TYPESCRIPT_SOURCE, type KitGate } from "../core/gates.ts";
6
6
  import { findBunPinFaults, formatBunPinReport, readBunPinPolicy } from "./bun-pin.ts";
7
7
  import { findLongJobs, formatLongJobReport } from "./job-timeout.ts";
8
8
  import { readScheduleFiles, readShellFiles } from "./mutation-job.ts";
@@ -10,7 +10,8 @@ import { findScheduledReleases, formatScheduleReport } from "./release-schedule.
10
10
  import { findRunnerFaults, formatRunnerReport, jobRuns, readRunnerConfig } from "./runner-policy.ts";
11
11
  import { findScheduledRuns, formatScheduledRunReport } from "./schedule-policy.ts";
12
12
  import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
13
- import { findMissingWorkflows, formatMissingReport, isRecord, names, workflowJobs, type Workflow } from "./workflow-references.ts";
13
+ import { findNightStepFaults, formatNightStepReport } from "./night-step.ts";
14
+ import { findMissingWorkflows, formatMissingReport, isRecord, localUses, names, skippedBy, switchedOff, workflowJobs, type Workflow } from "./workflow-references.ts";
14
15
 
15
16
  export { BunPinError, findBunPinFaults, formatBunPinReport } from "./bun-pin.ts";
16
17
  export { RunnerPolicyError } from "./runner-policy.ts";
@@ -71,26 +72,8 @@ const ScriptsManifest = Schema.Struct({
71
72
  });
72
73
  // Without these a pull_request workflow never sees the commits a pull request pushes.
73
74
  const GATING_TYPES = ["opened", "synchronize"];
74
- const CONSTANTS = new Map([
75
- ["true", true],
76
- ["false", false],
77
- ]);
78
- const STATUS_OVERRIDE = /\b(?:always|failure|cancelled)\s*\(/;
79
75
  const EventRepository = Schema.fromJsonString(Schema.Struct({ repository: Schema.Struct({ private: Schema.Boolean }) }));
80
76
 
81
- function constant(value: unknown): boolean | undefined {
82
- if (typeof value === "boolean") return value;
83
- if (typeof value !== "string") return undefined;
84
- const expression = value.trim().replace(/^\$\{\{(.*)\}\}$/s, "$1").trim();
85
- return CONSTANTS.get(expression);
86
- }
87
-
88
- function switchedOff(node: Readonly<Record<string, unknown>>, subject: string): string | undefined {
89
- if (constant(node["if"]) === false) return `${subject} sets if: false`;
90
- if (constant(node["continue-on-error"]) === true) return `${subject} sets continue-on-error: true`;
91
- return undefined;
92
- }
93
-
94
77
  // Last match wins, as GitHub evaluates a branch filter, so one match anywhere is not enough.
95
78
  function selects(patterns: readonly string[], branch: string): boolean {
96
79
  let selected = false;
@@ -125,23 +108,6 @@ const pullRequestTrigger = (branch: string): Trigger => (workflow) => {
125
108
  return undefined;
126
109
  };
127
110
 
128
- // GitHub prefixes any other if: with success(), so only a status function overrides a needed job's skip.
129
- function skippedBy(jobs: Readonly<Record<string, unknown>>, id: string, seen: readonly string[]): string | undefined {
130
- const job = jobs[id];
131
- if (!isRecord(job) || seen.includes(id)) return undefined;
132
- if (constant(job["if"]) === false) return `job ${id} sets if: false`;
133
- if (typeof job["if"] === "string" && STATUS_OVERRIDE.test(job["if"])) return undefined;
134
- for (const need of names(job["needs"]) ?? []) {
135
- const cause = skippedBy(jobs, need, [...seen, id]);
136
- if (cause !== undefined) return cause;
137
- }
138
- return undefined;
139
- }
140
-
141
- function localCall(uses: unknown): string | undefined {
142
- return typeof uses === "string" && uses.startsWith("./") ? uses.slice(2) : undefined;
143
- }
144
-
145
111
  function runSteps(workflows: readonly Workflow[], trigger: Trigger): readonly RunStep[] {
146
112
  const documents = new Map(workflows.map((workflow) => [workflow.path, workflow.document]));
147
113
  const steps: RunStep[] = [];
@@ -162,7 +128,7 @@ function runSteps(workflows: readonly Workflow[], trigger: Trigger): readonly Ru
162
128
  blocker ??
163
129
  switchedOff(job, `job ${id}`) ??
164
130
  (skipped === undefined ? undefined : `job ${id} needs a job that never runs: ${skipped}`);
165
- const called = localCall(job["uses"]);
131
+ const called = localUses(job["uses"]);
166
132
  if (called !== undefined && !walked.includes(called)) {
167
133
  visit(documents.get(called), `${jobLocation} > ${called}`, jobBlocker, [...walked, called]);
168
134
  }
@@ -345,7 +311,11 @@ const wiring = Effect.gen(function* () {
345
311
  if (scheduledRuns.length > 0) yield* Console.error(formatScheduledRunReport(scheduledRuns));
346
312
  const longJobs = findLongJobs(workflows);
347
313
  if (longJobs.length > 0) yield* Console.error(formatLongJobReport(longJobs));
348
- return [gaps, faults, missing, bunFaults, scheduled, scheduledRuns, longJobs].every((found) => found.length === 0);
314
+ const tracksTypeScript =
315
+ (yield* git(["ls-files", "--", ...TYPESCRIPT_SOURCE.pathspecs], root)).trim() !== "";
316
+ const nightFaults = findNightStepFaults(tracksTypeScript, workflows, { scripts, files: scheduleFiles });
317
+ if (nightFaults.length > 0) yield* Console.error(formatNightStepReport(nightFaults));
318
+ return [gaps, faults, missing, bunFaults, scheduled, scheduledRuns, longJobs, nightFaults].every((found) => found.length === 0);
349
319
  });
350
320
 
351
321
  if (import.meta.main) runMain("ci-wiring", wiring);
@@ -1,5 +1,5 @@
1
1
  import { Effect, FileSystem, Path } from "effect";
2
- import { fromProgram, SHELLS, shellCommands, withoutOptions, type Command } from "./shell-command.ts";
2
+ import { fromProgram, SHELLS, shellWords, withoutOptions, type Command, type Word } from "./shell-command.ts";
3
3
 
4
4
  // An undefined directory is one known only at run time.
5
5
  export type Run = {
@@ -9,7 +9,7 @@ export type Run = {
9
9
 
10
10
  type Invocation =
11
11
  | { readonly script: string; readonly body: string }
12
- | { readonly program: Command; readonly file: string | undefined; readonly dir: string | undefined };
12
+ | { readonly program: Command; readonly file: string | undefined; readonly dir: string | undefined; readonly argv: Command };
13
13
 
14
14
  type Walked = {
15
15
  readonly scripts: readonly string[];
@@ -53,7 +53,31 @@ export function runsBin(
53
53
  isBin: BinCheck,
54
54
  followShellOperands = false,
55
55
  ): boolean {
56
- return scriptRunsBin(run.script, run.dir, { scripts, files }, { scripts: [], files: [] }, isBin, followShellOperands);
56
+ return scriptRunsBin(run.script, run.dir, { scripts, files }, { scripts: [], files: [] }, isBin, followShellOperands, []);
57
+ }
58
+
59
+
60
+ function isPositionalForward(script: string, word: Word): boolean {
61
+ if (word.text !== "$@") return false;
62
+ if (script[word.start] === '"') return script.slice(word.start, word.start + 4) === '"$@"';
63
+ return script[word.start] === "$" && script[word.start + 1] === "@";
64
+ }
65
+
66
+ function expandArgv(script: string, words: readonly Word[], argv: Command): Command {
67
+ return words.flatMap((word) => (isPositionalForward(script, word) ? argv : [word.text]));
68
+ }
69
+
70
+ function runsExpanded(
71
+ expanded: Command,
72
+ cwd: string | undefined,
73
+ context: Context,
74
+ walked: Walked,
75
+ isBin: BinCheck,
76
+ followShellOperands: boolean,
77
+ ): { readonly matched: boolean; readonly cwd: string | undefined } {
78
+ const [program = "", ...args] = fromProgram(expanded);
79
+ if (program === "cd") return { matched: false, cwd: changedDirectory(cwd, withoutOptions(args, new Set())) };
80
+ return { matched: binCommand(expanded, cwd, context, walked, isBin, followShellOperands), cwd };
57
81
  }
58
82
 
59
83
  function scriptRunsBin(
@@ -63,12 +87,14 @@ function scriptRunsBin(
63
87
  walked: Walked,
64
88
  isBin: BinCheck,
65
89
  followShellOperands: boolean,
90
+ argv: Command,
66
91
  ): boolean {
92
+ const parsed = shellWords(script);
67
93
  let cwd = dir;
68
- for (const command of shellCommands(script)) {
69
- const [program = "", ...args] = fromProgram(command);
70
- if (program === "cd") cwd = changedDirectory(cwd, withoutOptions(args, new Set()));
71
- else if (binCommand(command, cwd, context, walked, isBin, followShellOperands)) return true;
94
+ for (const words of parsed) {
95
+ const next = runsExpanded(expandArgv(script, words, argv), cwd, context, walked, isBin, followShellOperands);
96
+ if (next.matched) return true;
97
+ cwd = next.cwd;
72
98
  }
73
99
  return false;
74
100
  }
@@ -92,13 +118,20 @@ function fileAt(dir: string | undefined, path: string): string | undefined {
92
118
  return within(dir, path) || undefined;
93
119
  }
94
120
 
121
+ const PLAIN_WORD = /^[A-Za-z0-9_@%+=:,./-]+$/;
122
+
123
+ function quotedWord(word: string): string {
124
+ if (word !== "" && PLAIN_WORD.test(word)) return word;
125
+ return `'${word.replace(/'/g, "'\\''")}'`;
126
+ }
127
+
95
128
  function invocation(command: Command, dir: string | undefined, context: Context, walked: Walked, followShellOperands: boolean): Invocation | undefined {
96
129
  const [first = "", ...rest] = fromProgram(command);
97
130
  if (first !== "bun") {
98
131
  if (followShellOperands && rest[0] !== undefined && !rest[0].startsWith("-") && SHELLS.includes(first.slice(first.lastIndexOf("/") + 1))) {
99
- return { program: [first, ...rest], file: fileAt(dir, rest[0]), dir };
132
+ return { program: [first, ...rest], file: fileAt(dir, rest[0]), dir, argv: rest.slice(1) };
100
133
  }
101
- return { program: [first, ...rest], file: first.includes("/") ? fileAt(dir, first) : undefined, dir };
134
+ return { program: [first, ...rest], file: first.includes("/") ? fileAt(dir, first) : undefined, dir, argv: rest };
102
135
  }
103
136
  const [subcommand = "", ...operands] = withoutOptions(rest, BUN_OPERANDS);
104
137
  if (subcommand === "x") return invocation(["bunx", ...operands], dir, context, walked, followShellOperands);
@@ -106,7 +139,8 @@ function invocation(command: Command, dir: string | undefined, context: Context,
106
139
  const [target = "", ...targetArgs] = subcommand === "run" ? withoutOptions(operands, BUN_OPERANDS) : [subcommand, ...operands];
107
140
  const cwd = bunDirectory([...bunOptions(rest), ...(subcommand === "run" ? bunOptions(operands) : [])], dir);
108
141
  const body = walked.scripts.includes(target) ? undefined : context.scripts.get(target);
109
- return body === undefined ? { program: [target, ...targetArgs], file: fileAt(cwd, target), dir: cwd } : { script: target, body };
142
+ if (body === undefined) return { program: [target, ...targetArgs], file: fileAt(cwd, target), dir: cwd, argv: targetArgs };
143
+ return { script: target, body: targetArgs.length === 0 ? body : `${body} ${targetArgs.map(quotedWord).join(" ")}` };
110
144
  }
111
145
 
112
146
  function binCommand(
@@ -120,14 +154,16 @@ function binCommand(
120
154
  const call = invocation(command, dir, context, walked, followShellOperands);
121
155
  if (call === undefined) return false;
122
156
  // bun runs a script from the directory of the package.json that names it.
123
- if ("body" in call) return scriptRunsBin(call.body, ROOT, context, { ...walked, scripts: [...walked.scripts, call.script] }, isBin, followShellOperands);
124
- return isBin(call.program) || fileRunsBin(call.file, call.dir, context, walked, isBin, followShellOperands);
157
+ if ("body" in call)
158
+ return scriptRunsBin(call.body, ROOT, context, { ...walked, scripts: [...walked.scripts, call.script] }, isBin, followShellOperands, []);
159
+ return isBin(call.program) || fileRunsBin(call.file, call.dir, call.argv, context, walked, isBin, followShellOperands);
125
160
  }
126
161
 
127
162
  // A script file runs in its caller's directory, not its own.
128
163
  function fileRunsBin(
129
164
  file: string | undefined,
130
165
  dir: string | undefined,
166
+ argv: Command,
131
167
  context: Context,
132
168
  walked: Walked,
133
169
  isBin: BinCheck,
@@ -135,7 +171,10 @@ function fileRunsBin(
135
171
  ): boolean {
136
172
  if (file === undefined || walked.files.includes(file)) return false;
137
173
  const body = context.files(file);
138
- return body !== undefined && scriptRunsBin(body, dir, context, { ...walked, files: [...walked.files, file] }, isBin, followShellOperands);
174
+ return (
175
+ body !== undefined &&
176
+ scriptRunsBin(body, dir, context, { ...walked, files: [...walked.files, file] }, isBin, followShellOperands, argv)
177
+ );
139
178
  }
140
179
 
141
180
  function mutationBin([program = "", subcommand]: Command): boolean {
@@ -0,0 +1,79 @@
1
+ import { ENTRY_POINT } from "../core/gates.ts";
2
+ import { runsBin } from "./mutation-job.ts";
3
+ import type { ScheduleContext } from "./release-schedule.ts";
4
+ import { jobRuns } from "./runner-policy.ts";
5
+ import type { Command } from "./shell-command.ts";
6
+ import { isRecord, localUses, names, skippedBy, switchedOff, type Workflow } from "./workflow-references.ts";
7
+
8
+ const NIGHT_STEP = `${ENTRY_POINT.bin} --night`;
9
+
10
+ export type NightStepFault = {
11
+ readonly gate: string;
12
+ };
13
+
14
+ const NIGHT_BINS = [ENTRY_POINT.bin, ENTRY_POINT.script];
15
+
16
+ function isNightLint([head = "", ...rest]: Command): boolean {
17
+ return NIGHT_BINS.some((name) => head === name || head.endsWith("/" + name)) && rest.length === 1 && rest[0] === "--night";
18
+ }
19
+
20
+ function rootBlocker(workflow: Workflow): string | undefined {
21
+ const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
22
+ const events = isRecord(on) ? Object.keys(on) : (names(on) ?? []);
23
+ if (events.length > 0 && events.every((event) => event.toLowerCase() === "workflow_call")) {
24
+ return `${workflow.path} runs only when called`;
25
+ }
26
+ return undefined;
27
+ }
28
+
29
+ function workflowRunsNightStep(
30
+ workflow: Workflow,
31
+ context: ScheduleContext,
32
+ byPath: ReadonlyMap<string, Workflow>,
33
+ walkedWorkflows: readonly string[],
34
+ inherited: string | undefined,
35
+ ): boolean {
36
+ const files = (file: string) => context.files.get(file);
37
+ const jobs = isRecord(workflow.document) ? workflow.document["jobs"] : undefined;
38
+ if (!isRecord(jobs)) return false;
39
+ return Object.entries(jobs).some(([id, job]) => {
40
+ if (!isRecord(job)) return false;
41
+ const skipped = skippedBy(jobs, id, []);
42
+ const jobBlocker =
43
+ inherited ?? switchedOff(job, `job ${id}`) ?? (skipped === undefined ? undefined : `job ${id} needs a job that never runs: ${skipped}`);
44
+ const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
45
+ const runRecords: readonly Readonly<Record<string, unknown>>[] = steps.flatMap((step: unknown) =>
46
+ isRecord(step) && typeof step["run"] === "string" ? [step] : [],
47
+ );
48
+ const unblocked = jobRuns(workflow, job).some(
49
+ (run, index) =>
50
+ (jobBlocker ?? switchedOff(runRecords[index] ?? {}, "the step")) === undefined &&
51
+ runsBin(run, context.scripts, files, isNightLint, true),
52
+ );
53
+ if (unblocked) return true;
54
+ const called = localUses(job["uses"]);
55
+ if (called === undefined || walkedWorkflows.includes(called)) return false;
56
+ const next = byPath.get(called);
57
+ return next !== undefined && workflowRunsNightStep(next, context, byPath, [...walkedWorkflows, called], jobBlocker);
58
+ });
59
+ }
60
+
61
+ function runsNightStep(workflows: readonly Workflow[], context: ScheduleContext): boolean {
62
+ const byPath = new Map(workflows.map((workflow) => [workflow.path, workflow] as const));
63
+ return workflows.some((workflow) => workflowRunsNightStep(workflow, context, byPath, [workflow.path], rootBlocker(workflow)));
64
+ }
65
+
66
+ export function findNightStepFaults(
67
+ tracksTypeScript: boolean,
68
+ workflows: readonly Workflow[],
69
+ context: ScheduleContext,
70
+ ): readonly NightStepFault[] {
71
+ return tracksTypeScript && !runsNightStep(workflows, context) ? [{ gate: NIGHT_STEP }] : [];
72
+ }
73
+
74
+ export function formatNightStepReport(faults: readonly NightStepFault[]): string {
75
+ return [
76
+ "ci-wiring: " + faults.length + " gate(s) never run on the night run:",
77
+ ...faults.flatMap(({ gate }) => [" " + gate, " no run step invokes it"]),
78
+ ].join("\n");
79
+ }
@@ -1,7 +1,7 @@
1
1
  import { runsBin } from "./mutation-job.ts";
2
2
  import { jobRuns } from "./runner-policy.ts";
3
3
  import type { Command } from "./shell-command.ts";
4
- import { isRecord, locate, names, workflowJobs, type Workflow } from "./workflow-references.ts";
4
+ import { isRecord, localUses, locate, names, workflowJobs, type Workflow } from "./workflow-references.ts";
5
5
 
6
6
  export type ScheduledRelease = {
7
7
  readonly location: string;
@@ -35,11 +35,6 @@ function isReleaseCommand(program: Command): boolean {
35
35
  return isPublishCommand(program) || isBinIn(RELEASE_BINS, program[0] ?? "");
36
36
  }
37
37
 
38
- function localUses(job: Readonly<Record<string, unknown>>): string | undefined {
39
- const uses = job["uses"];
40
- return typeof uses === "string" && uses.startsWith("./") ? uses.slice(2) : undefined;
41
- }
42
-
43
38
  function workflowRuns(
44
39
  workflow: Workflow,
45
40
  context: ScheduleContext,
@@ -55,7 +50,7 @@ function workflowRuns(
55
50
  )
56
51
  return true;
57
52
  return workflowJobs(workflow).some(([, job]) => {
58
- const called = localUses(job);
53
+ const called = localUses(job["uses"]);
59
54
  if (called === undefined || walkedWorkflows.includes(called)) return false;
60
55
  const next = byPath.get(called);
61
56
  return next !== undefined && workflowRuns(next, context, isBin, byPath, [...walkedWorkflows, called]);
@@ -1,6 +1,31 @@
1
1
  const HOSTED_FALLBACK = "ubuntu-latest";
2
2
  const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
3
3
 
4
+ export function nightWorkflow(isPrivate: boolean): string {
5
+ const lines = [
6
+ "name: night-run",
7
+ "",
8
+ "on:",
9
+ " workflow_dispatch:",
10
+ "",
11
+ "jobs:",
12
+ " gates:",
13
+ " runs-on: " + (isPrivate ? HOSTED_DEFAULT : HOSTED_FALLBACK),
14
+ " timeout-minutes: 20",
15
+ " steps:",
16
+ " - uses: actions/checkout@v5",
17
+ " with:",
18
+ " fetch-depth: 0",
19
+ " - uses: oven-sh/setup-bun@v2",
20
+ " with:",
21
+ " bun-version-file: .bun-version",
22
+ " - run: bun install --frozen-lockfile",
23
+ " - run: bunx checks-lint --night",
24
+ "",
25
+ ];
26
+ return lines.join("\n");
27
+ }
28
+
4
29
  export function ciWorkflow(isPrivate: boolean, hasBuild: boolean): string {
5
30
  const lines = [
6
31
  "name: ci",
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { runMain, Usage } from "../core/main.ts";
4
- import { ciWorkflow } from "./setup-workflow.ts";
4
+ import { ciWorkflow, nightWorkflow } from "./setup-workflow.ts";
5
5
  import { TEMPLATES } from "../docs/doc-templates.ts";
6
6
  import { scriptOptions } from "../testing/test.ts";
7
7
  import { withServiceOverrides, type ServiceOverride } from "../quality/effect-scope.ts";
@@ -349,6 +349,7 @@ const check = Effect.gen(function* () {
349
349
  yield* setupTsconfig(root, oxlintText);
350
350
  yield* setupFile(root, "knip.config.ts", KNIP_CONFIG);
351
351
  yield* setupFile(root, ".github/workflows/ci.yml", ciWorkflow(isPrivate, outcome.hasBuild));
352
+ yield* setupFile(root, ".github/workflows/night-run.yml", nightWorkflow(isPrivate));
352
353
  for (const kind of Object.keys(TEMPLATES)) {
353
354
  yield* setupFile(root, "templates/" + kind + ".md", yield* kitText("dist/templates/" + kind + ".md"));
354
355
  }
@@ -26,6 +26,42 @@ export function names(value: unknown): readonly string[] | undefined {
26
26
  return value.filter((entry): entry is string => typeof entry === "string");
27
27
  }
28
28
 
29
+ const CONSTANTS = new Map([
30
+ ["true", true],
31
+ ["false", false],
32
+ ]);
33
+ const STATUS_OVERRIDE = /\b(?:always|failure|cancelled)\s*\(/;
34
+
35
+ function constant(value: unknown): boolean | undefined {
36
+ if (typeof value === "boolean") return value;
37
+ if (typeof value !== "string") return undefined;
38
+ const expression = value.trim().replace(/^\$\{\{(.*)\}\}$/s, "$1").trim();
39
+ return CONSTANTS.get(expression);
40
+ }
41
+
42
+ export function switchedOff(node: Readonly<Record<string, unknown>>, subject: string): string | undefined {
43
+ if (constant(node["if"]) === false) return `${subject} sets if: false`;
44
+ if (constant(node["continue-on-error"]) === true) return `${subject} sets continue-on-error: true`;
45
+ return undefined;
46
+ }
47
+
48
+ // GitHub prefixes any other if: with success(), so only a status function overrides a needed job's skip.
49
+ export function skippedBy(jobs: Readonly<Record<string, unknown>>, id: string, seen: readonly string[]): string | undefined {
50
+ const job = jobs[id];
51
+ if (!isRecord(job) || seen.includes(id)) return undefined;
52
+ if (constant(job["if"]) === false) return `job ${id} sets if: false`;
53
+ if (typeof job["if"] === "string" && STATUS_OVERRIDE.test(job["if"])) return undefined;
54
+ for (const need of names(job["needs"]) ?? []) {
55
+ const cause = skippedBy(jobs, need, [...seen, id]);
56
+ if (cause !== undefined) return cause;
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ export function localUses(uses: unknown): string | undefined {
62
+ return typeof uses === "string" && uses.startsWith("./") ? uses.slice(2) : undefined;
63
+ }
64
+
29
65
  type Key = string | number;
30
66
 
31
67
  type Target = { readonly name: string; readonly invocation: number };