@avi2dg/checks 0.65.2 → 0.66.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/CHANGELOG.md +16 -0
- package/docs/bins.md +1 -1
- package/docs/gates/checks-advisories.md +3 -2
- package/docs/gates/checks-ci-wiring.md +20 -1
- package/docs/gates/checks-docs.md +24 -2
- package/docs/gates/checks-setup.md +4 -1
- package/docs/how-to/set-up-a-repository.md +21 -0
- package/docs/how-to/upgrade-the-kit.md +2 -0
- package/package.json +2 -1
- package/src/delivery/ci-wiring.ts +9 -39
- package/src/delivery/mutation-job.ts +52 -13
- package/src/delivery/night-step.ts +79 -0
- package/src/delivery/release-schedule.ts +2 -7
- package/src/delivery/setup-workflow.ts +25 -0
- package/src/delivery/setup.ts +2 -1
- package/src/delivery/workflow-references.ts +36 -0
- package/src/docs/doc-agents.ts +171 -0
- package/src/docs/doc-versions.ts +1 -1
- package/src/docs/docs.ts +15 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.66.0
|
|
6
|
+
|
|
7
|
+
Released 2026-11-05.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **docs:** cap READMEs at 6,000 characters and table cells at 300 [#46](https://github.com/avi2d/checks/pull/46)
|
|
12
|
+
|
|
13
|
+
## 0.65.3
|
|
14
|
+
|
|
15
|
+
Released 2026-11-04.
|
|
16
|
+
|
|
17
|
+
### Fixes
|
|
18
|
+
|
|
19
|
+
- **ci-wiring:** require the night run step in TypeScript repositories [#45](https://github.com/avi2d/checks/pull/45)
|
|
20
|
+
|
|
5
21
|
## 0.65.2
|
|
6
22
|
|
|
7
23
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
```
|
|
@@ -16,6 +16,7 @@ It fails when such a line copies a version `package.json` or `.bun-version` pins
|
|
|
16
16
|
It fails when a living doc or an agent file names a path, link or command that does not resolve, and the range added or broke it.
|
|
17
17
|
It fails when an agent file holds more than 3,000 characters or a `## Maintaining this file` section, whatever the range touches, as [Agent files](#agent-files) says.
|
|
18
18
|
It fails when an entry in an agent file names no tracked path, link or `bun run` command, whatever the range touches.
|
|
19
|
+
It fails when a range edits a README that is over 6,000 characters or holds a table cell over 300, as [README ceilings](#readme-ceilings) says.
|
|
19
20
|
It fails when a living doc or an agent file names a code span the range removed from every file outside the docs, on any line.
|
|
20
21
|
[Paths, links and commands](#paths-links-and-commands) says how each reference resolves.
|
|
21
22
|
The package ships one template per kind under `dist/templates/`, and a repository starts a new doc file by copying one:
|
|
@@ -188,6 +189,23 @@ An entry is any list item a reader sees as CommonMark renders the file, the item
|
|
|
188
189
|
A list item inside an HTML comment, an HTML block or an indented code block is not an entry.
|
|
189
190
|
A fresh file passes the ceiling, and its entries pass once each names the file that holds its detail.
|
|
190
191
|
|
|
192
|
+
## README ceilings
|
|
193
|
+
|
|
194
|
+
The root `README.md` and every `README.md` beside a `package.json` take two ceilings, since npm shows a package's README as its front page:
|
|
195
|
+
|
|
196
|
+
- The file holds at most 6,000 characters, counted as the agent file ceiling counts them.
|
|
197
|
+
The failure names the file's size and its largest section, and says to move that section to a page under `docs/`.
|
|
198
|
+
- A table cell holds at most 300 characters.
|
|
199
|
+
A table follows the GitHub table rules, with a header row above a delimiter row with the same cell count.
|
|
200
|
+
Every line after the delimiter counts until a blank line.
|
|
201
|
+
Tables indented by four spaces or a tab, and tables inside blockquotes, are left unchecked.
|
|
202
|
+
Lines in fenced or indented code never count as table rows.
|
|
203
|
+
An escaped `\|` never ends a cell.
|
|
204
|
+
|
|
205
|
+
No line in a complete same-named generated block counts toward either ceiling, from its `<!-- generated <name>: ... -->` line to its `<!-- end generated <name> -->` line, so a generated index sizes itself.
|
|
206
|
+
A README fails only in a range that edits it.
|
|
207
|
+
A README the range leaves alone is listed as advisory, so the first change that edits an oversize README shrinks it.
|
|
208
|
+
|
|
191
209
|
## What it reads
|
|
192
210
|
|
|
193
211
|
It reads each Markdown file at the head commit, and uses its path or front matter to choose its kind.
|
|
@@ -198,6 +216,7 @@ It compares each code span a living doc or an agent file names with the text git
|
|
|
198
216
|
It reads the repository's own name and its direct dependencies from the root `package.json` at the head commit.
|
|
199
217
|
It reads the versions the root `package.json` and `.bun-version` pin at the head commit.
|
|
200
218
|
It reads each agent file at the head commit for the ceiling, its sections and its entries, whatever the range touches.
|
|
219
|
+
It reads the root `README.md` and each `README.md` beside a tracked `package.json` at the head commit for the README ceilings.
|
|
201
220
|
A name that an installed direct dependency still holds counts as present.
|
|
202
221
|
From the working tree it reads the ignore files git reads, `node_modules/.bin`, and the directory of each direct dependency under `node_modules`.
|
|
203
222
|
|
|
@@ -215,8 +234,8 @@ With one it is that commit against its parent, or against the empty tree for a r
|
|
|
215
234
|
|
|
216
235
|
| Code | When |
|
|
217
236
|
| --- | --- |
|
|
218
|
-
| 0 | every doc file the range touches holds to its template, every line it adds to a living doc or an agent file holds to the prose rules and copies no pinned version, it adds or breaks no reference that does not resolve, every agent file holds to the ceiling and holds no `## Maintaining this file`, every entry in an agent file names a tracked path, a link or a command, and no code span a living doc or an agent file names vanished from every file outside the docs |
|
|
219
|
-
| 1 | a doc file the range touches does not hold to its template, a line the range adds to a living doc or an agent file breaks a prose rule or copies a pinned version, the range adds or breaks a reference that does not resolve, an agent file is over the ceiling or holds `## Maintaining this file`, an entry in an agent file names no tracked path, link or command, or the range removes a name a living doc or an agent file still carries |
|
|
237
|
+
| 0 | every doc file the range touches holds to its template, every line it adds to a living doc or an agent file holds to the prose rules and copies no pinned version, it adds or breaks no reference that does not resolve, every agent file holds to the ceiling and holds no `## Maintaining this file`, every entry in an agent file names a tracked path, a link or a command, every README the range edits holds to the README ceilings, and no code span a living doc or an agent file names vanished from every file outside the docs |
|
|
238
|
+
| 1 | a doc file the range touches does not hold to its template, a line the range adds to a living doc or an agent file breaks a prose rule or copies a pinned version, the range adds or breaks a reference that does not resolve, an agent file is over the ceiling or holds `## Maintaining this file`, an entry in an agent file names no tracked path, link or command, a README the range edits is over a README ceiling, or the range removes a name a living doc or an agent file still carries |
|
|
220
239
|
| 2 | a `package.json` does not decode, a ref does not resolve, or `grep` cannot read an installed direct dependency |
|
|
221
240
|
|
|
222
241
|
## Sample output
|
|
@@ -232,6 +251,8 @@ docs: 7 violation(s):
|
|
|
232
251
|
docs/parts.md:9: names `gates.lint`, which the range removed from every file outside the docs. Say what holds now, or drop the line
|
|
233
252
|
docs: advisory, 1 doc file(s) the range leaves alone do not hold to their templates yet:
|
|
234
253
|
docs/adr/0001-quality-gates.md: 5 violation(s)
|
|
254
|
+
docs: advisory, 1 README ceiling finding(s) in README(s) the range leaves alone:
|
|
255
|
+
packages/run/README.md: is 6412 characters, over the 6,000-character ceiling for a README. Its largest section is `## Configure a run` at 2310 characters. Move that section to a page under docs/ and link it from the README in one line
|
|
235
256
|
docs: advisory, 1 sentence(s) over the trial length caps:
|
|
236
257
|
README.md:16: carries a 27-word descriptive sentence, over the 25-word cap (procedural means an ordered list item, capped at 20 words)
|
|
237
258
|
docs: advisory, 1 path(s), link(s) or command(s) the living docs or agent files name were broken before the range:
|
|
@@ -243,6 +264,7 @@ docs: advisory, 1 path(s), link(s) or command(s) the living docs or agent files
|
|
|
243
264
|
`checks-lint` runs it over each pull request's range in every repository, as [checks-lint](checks-lint.md) says.
|
|
244
265
|
A repository adopts the templates as its files change, and the prose rules as its lines change, because an untouched file or line never fails those checks.
|
|
245
266
|
It reshapes its agent files when it adopts the gate, because [Agent files](#agent-files) judges each one whatever the range touches.
|
|
267
|
+
It shrinks an oversize README in the first change that edits it, because [README ceilings](#readme-ceilings) judges only a README the range edits.
|
|
246
268
|
A name the range removes fails wherever a doc still carries it, because the removal is what turned the line stale.
|
|
247
269
|
|
|
248
270
|
## Related topics
|
|
@@ -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.
|
|
3
|
+
"version": "0.66.0",
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
|
|
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
|
-
|
|
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)
|
|
124
|
-
|
|
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
|
|
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",
|
package/src/delivery/setup.ts
CHANGED
|
@@ -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 };
|
package/src/docs/doc-agents.ts
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { Parser, type NodeType } from "commonmark";
|
|
2
2
|
import { commandNames, type Snapshot } from "./doc-references.ts";
|
|
3
|
+
import { handWritten } from "./doc-versions.ts";
|
|
3
4
|
import { AGENT_NAMES, scanMarkdown, type MarkdownLine } from "./prose-matchers.ts";
|
|
4
5
|
|
|
5
6
|
export const AGENT_CEILING = 3000;
|
|
7
|
+
export const README_CEILING = 6000;
|
|
8
|
+
export const CELL_CEILING = 300;
|
|
9
|
+
|
|
10
|
+
const README = "README.md";
|
|
11
|
+
const MANIFEST = "package.json";
|
|
12
|
+
const SECOND_LEVEL = /^\s{0,3}##(?:\s+|$)(.*?)(?:\s+#+)?\s*$/;
|
|
13
|
+
const SETEXT_SECOND = /^\s{0,3}-+\s*$/;
|
|
14
|
+
const CELL_SEPARATOR = /(?<!\\)\|/;
|
|
15
|
+
const ROW_EDGES = /^\||(?<!\\)\|$/g;
|
|
6
16
|
|
|
7
17
|
const INLINE_LINK =
|
|
8
18
|
/(?<![!\\])\[(?:[^[\]\\]|\\.|\[[^\]]*\])*\]\(\s*(?:<[^<>\n]+>|[^\s()<>]+(?:\([^\s()]*\)[^\s()<>]*)*)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g;
|
|
@@ -25,6 +35,167 @@ export function ceilingFinding(text: string): AgentFinding | undefined {
|
|
|
25
35
|
};
|
|
26
36
|
}
|
|
27
37
|
|
|
38
|
+
export function isCeiledReadme(repositoryPath: string, files: ReadonlySet<string>): boolean {
|
|
39
|
+
if (repositoryPath === README) return true;
|
|
40
|
+
return repositoryPath.endsWith(`/${README}`) && files.has(`${repositoryPath.slice(0, -README.length)}${MANIFEST}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Part = { readonly title: string; readonly lines: readonly MarkdownLine[] };
|
|
44
|
+
|
|
45
|
+
function endingLengths(text: string): readonly number[] {
|
|
46
|
+
const pieces = text.split("\n");
|
|
47
|
+
return pieces.map((piece, index) => (index < pieces.length - 1 ? (piece.endsWith("\r") ? 2 : 1) : 0));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sizeOf(lines: readonly MarkdownLine[], endings: readonly number[]): number {
|
|
51
|
+
return lines.reduce((sum, { line, raw }) => sum + raw.length + (endings[line - 1] ?? 0), 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sectionTitle(line: MarkdownLine, next: MarkdownLine | undefined): string | undefined {
|
|
55
|
+
if (line.kind !== "heading") return undefined;
|
|
56
|
+
const atx = SECOND_LEVEL.exec(line.raw);
|
|
57
|
+
if (atx !== null) return atx[1] ?? "";
|
|
58
|
+
return next !== undefined && SETEXT_SECOND.test(next.raw) ? line.raw.trim() : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function partsOf(lines: readonly MarkdownLine[]): readonly Part[] {
|
|
62
|
+
const starts = lines.flatMap((line, index) => {
|
|
63
|
+
const title = sectionTitle(line, lines[index + 1]);
|
|
64
|
+
return title === undefined ? [] : [{ index, title: `\`## ${title}\`` }];
|
|
65
|
+
});
|
|
66
|
+
const bounds = [{ index: 0, title: "the text before the first section" }, ...starts];
|
|
67
|
+
return bounds.map(({ index, title }, at) => ({ title, lines: lines.slice(index, bounds[at + 1]?.index ?? lines.length) }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function largestPart(lines: readonly MarkdownLine[], endings: readonly number[]): { readonly title: string; readonly size: number } {
|
|
71
|
+
return partsOf(lines)
|
|
72
|
+
.map(({ title, lines: held }) => ({ title, size: sizeOf(held, endings) }))
|
|
73
|
+
.reduce((largest, part) => (part.size > largest.size ? part : largest));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function cellsOf(raw: string): readonly string[] {
|
|
77
|
+
return raw.trim().replaceAll(ROW_EDGES, "").split(CELL_SEPARATOR).map((cell) => cell.trim());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const DELIMITER_CELL = /^\s*:?-+:?\s*$/;
|
|
81
|
+
|
|
82
|
+
function isDelimiterRow(raw: string): boolean {
|
|
83
|
+
if (/^\s*>/.test(raw)) return false;
|
|
84
|
+
if (/^(?: {4}|\t)/.test(raw)) return false;
|
|
85
|
+
const trimmed = raw.trim();
|
|
86
|
+
if (!trimmed.includes("|") || !trimmed.includes("-")) return false;
|
|
87
|
+
const inner = trimmed.replace(/^\|/, "").replace(/\|$/, "");
|
|
88
|
+
return inner.split("|").every((cell) => DELIMITER_CELL.test(cell));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function codeLinesIn(scanned: readonly MarkdownLine[]): ReadonlySet<number> {
|
|
92
|
+
const read = scanned.map(({ kind, raw }) => (kind === "front-matter" ? "" : raw)).join("\n");
|
|
93
|
+
const walker = new Parser().parse(read).walker();
|
|
94
|
+
const excluded = new Set<number>();
|
|
95
|
+
for (let step = walker.next(); step !== null; step = walker.next()) {
|
|
96
|
+
if (step.entering && step.node.type === "code_block") {
|
|
97
|
+
const start = step.node.sourcepos[0][0];
|
|
98
|
+
const end = step.node.sourcepos[1][0];
|
|
99
|
+
for (let line = start; line <= end; line += 1) excluded.add(line);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return excluded;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isTableCandidate(line: MarkdownLine, code: ReadonlySet<number>): boolean {
|
|
106
|
+
if (line.kind !== "prose" && line.kind !== "table") return false;
|
|
107
|
+
if (code.has(line.line)) return false;
|
|
108
|
+
if (/^\s*>/.test(line.raw)) return false;
|
|
109
|
+
if (/^(?: {4}|\t)/.test(line.raw)) return false;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type TableRow = { readonly line: number; readonly raw: string; readonly width: number };
|
|
114
|
+
|
|
115
|
+
function isBodyLine(line: MarkdownLine, code: ReadonlySet<number>): boolean {
|
|
116
|
+
if (line.raw.trim() === "") return false;
|
|
117
|
+
if (code.has(line.line)) return false;
|
|
118
|
+
if (/^\s*>/.test(line.raw)) return false;
|
|
119
|
+
if (/^(?: {4}|\t)/.test(line.raw)) return false;
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type HeaderMatch = { readonly header: MarkdownLine; readonly delimiterLine: number; readonly width: number };
|
|
124
|
+
|
|
125
|
+
function matchHeader(lines: readonly MarkdownLine[], at: number, code: ReadonlySet<number>): HeaderMatch | undefined {
|
|
126
|
+
const delimiter = lines[at];
|
|
127
|
+
if (delimiter === undefined || !isTableCandidate(delimiter, code)) return undefined;
|
|
128
|
+
if (!isDelimiterRow(delimiter.raw) || !CELL_SEPARATOR.test(delimiter.raw)) return undefined;
|
|
129
|
+
const header = lines[at - 1];
|
|
130
|
+
if (header === undefined || header.line + 1 !== delimiter.line) return undefined;
|
|
131
|
+
if (!isTableCandidate(header, code) || !CELL_SEPARATOR.test(header.raw)) return undefined;
|
|
132
|
+
const width = cellsOf(header.raw).length;
|
|
133
|
+
if (width !== cellsOf(delimiter.raw).length) return undefined;
|
|
134
|
+
return { header, delimiterLine: delimiter.line, width };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function collectBody(
|
|
138
|
+
lines: readonly MarkdownLine[],
|
|
139
|
+
from: number,
|
|
140
|
+
delimiterLine: number,
|
|
141
|
+
width: number,
|
|
142
|
+
code: ReadonlySet<number>,
|
|
143
|
+
): { readonly rows: readonly TableRow[]; readonly last: number } {
|
|
144
|
+
const rows: TableRow[] = [];
|
|
145
|
+
let expected = delimiterLine + 1;
|
|
146
|
+
let last = from - 1;
|
|
147
|
+
for (let next = from; next < lines.length; next += 1) {
|
|
148
|
+
const body = lines[next];
|
|
149
|
+
if (body === undefined || body.line !== expected) break;
|
|
150
|
+
if (!isBodyLine(body, code)) break;
|
|
151
|
+
rows.push({ line: body.line, raw: body.raw, width });
|
|
152
|
+
expected += 1;
|
|
153
|
+
last = next;
|
|
154
|
+
}
|
|
155
|
+
return { rows, last };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function tableRows(lines: readonly MarkdownLine[], code: ReadonlySet<number>): readonly TableRow[] {
|
|
159
|
+
const rows: TableRow[] = [];
|
|
160
|
+
for (let at = 0; at < lines.length; at += 1) {
|
|
161
|
+
const matched = matchHeader(lines, at, code);
|
|
162
|
+
if (matched === undefined) continue;
|
|
163
|
+
rows.push({ line: matched.header.line, raw: matched.header.raw, width: matched.width });
|
|
164
|
+
const body = collectBody(lines, at + 1, matched.delimiterLine, matched.width, code);
|
|
165
|
+
rows.push(...body.rows);
|
|
166
|
+
at = body.last;
|
|
167
|
+
}
|
|
168
|
+
return rows;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function readmeFindings(text: string): readonly AgentFinding[] {
|
|
172
|
+
const scanned = scanMarkdown(text);
|
|
173
|
+
const endings = endingLengths(text);
|
|
174
|
+
const lines = handWritten(scanned);
|
|
175
|
+
const size = sizeOf(lines, endings);
|
|
176
|
+
const largest = largestPart(lines, endings);
|
|
177
|
+
const ceiling: readonly AgentFinding[] =
|
|
178
|
+
size <= README_CEILING
|
|
179
|
+
? []
|
|
180
|
+
: [
|
|
181
|
+
{
|
|
182
|
+
line: undefined,
|
|
183
|
+
message: `is ${size} characters, over the 6,000-character ceiling for a README. Its largest section is ${largest.title} at ${largest.size} characters. Move that section to a page under docs/ and link it from the README in one line`,
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
const rows = tableRows(lines, codeLinesIn(scanned));
|
|
187
|
+
const cells = rows.flatMap(({ line, raw, width }) =>
|
|
188
|
+
cellsOf(raw)
|
|
189
|
+
.slice(0, width)
|
|
190
|
+
.filter((cell) => cell.length > CELL_CEILING)
|
|
191
|
+
.map((cell) => ({
|
|
192
|
+
line,
|
|
193
|
+
message: `holds a ${cell.length}-character table cell, over the 300-character ceiling for a README table cell. Move the detail to a page under docs/ and keep the cell to what a reader scans`,
|
|
194
|
+
})),
|
|
195
|
+
);
|
|
196
|
+
return [...ceiling, ...cells];
|
|
197
|
+
}
|
|
198
|
+
|
|
28
199
|
function blockStarts(lines: readonly MarkdownLine[], type: NodeType): ReadonlySet<number> {
|
|
29
200
|
const read = lines.map(({ kind, raw }) => (kind === "front-matter" ? "" : raw)).join("\n");
|
|
30
201
|
const walker = new Parser().parse(read).walker();
|
package/src/docs/doc-versions.ts
CHANGED
|
@@ -64,7 +64,7 @@ function patternOf({ name, version }: Pin): RegExp {
|
|
|
64
64
|
const GENERATED_OPEN = /^\s*<!-- generated ([\w-]+):/;
|
|
65
65
|
const GENERATED_CLOSE = /^\s*<!-- end generated ([\w-]+) -->/;
|
|
66
66
|
|
|
67
|
-
function handWritten(lines: readonly MarkdownLine[]): readonly MarkdownLine[] {
|
|
67
|
+
export function handWritten(lines: readonly MarkdownLine[]): readonly MarkdownLine[] {
|
|
68
68
|
const exempt = new Set<number>();
|
|
69
69
|
let at = 0;
|
|
70
70
|
while (at < lines.length) {
|
package/src/docs/docs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect } from "effect";
|
|
3
|
-
import { ceilingFinding, entryFindings, isAgentFile, maintainingFinding } from "./doc-agents.ts";
|
|
3
|
+
import { ceilingFinding, entryFindings, isAgentFile, isCeiledReadme, maintainingFinding, readmeFindings } from "./doc-agents.ts";
|
|
4
4
|
import { rootsOf, snapshotOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
|
|
5
5
|
import { ADR_DIRECTORY, judge, placementOf, placementProblem, recordsOf, speaksToConsumers, type Placement, type Records } from "./doc-rules.ts";
|
|
6
6
|
import { vanishedNames } from "./doc-names.ts";
|
|
@@ -22,8 +22,10 @@ type Judged = {
|
|
|
22
22
|
readonly edited: { readonly docs: number; readonly lines: number };
|
|
23
23
|
readonly named: number;
|
|
24
24
|
readonly agents: number;
|
|
25
|
+
readonly readmes: number;
|
|
25
26
|
readonly findings: readonly Finding[];
|
|
26
27
|
readonly advisory: ReadonlyMap<string, number>;
|
|
28
|
+
readonly readmeAdvisory: readonly Finding[];
|
|
27
29
|
readonly sentenceAdvisory: readonly Finding[];
|
|
28
30
|
readonly brokenBefore: readonly Finding[];
|
|
29
31
|
};
|
|
@@ -127,6 +129,8 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
|
|
|
127
129
|
[ceilingFinding(text(path)), maintainingFinding(text(path))].flatMap((finding) => (finding === undefined ? [] : [{ path, ...finding }])),
|
|
128
130
|
);
|
|
129
131
|
const entries = agents.flatMap(({ path }) => entryFindings(path, text(path), tracked).map((finding) => ({ path, ...finding })));
|
|
132
|
+
const readmes = proseDocs.filter(({ path }) => isCeiledReadme(path, tracked.files));
|
|
133
|
+
const oversize = readmes.flatMap(({ path }) => readmeFindings(text(path)).map((finding) => ({ path, ...finding })));
|
|
130
134
|
const advisory = new Map<string, number>();
|
|
131
135
|
for (const { path } of templated.filter((finding) => !touched.has(finding.path))) advisory.set(path, (advisory.get(path) ?? 0) + 1);
|
|
132
136
|
return {
|
|
@@ -134,6 +138,7 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
|
|
|
134
138
|
edited: { docs: edited.length, lines: edited.reduce((sum, { path }) => sum + (changed.get(path)?.size ?? 0), 0) },
|
|
135
139
|
named: referenced.size,
|
|
136
140
|
agents: agents.length,
|
|
141
|
+
readmes: readmes.filter(({ path }) => touched.has(path)).length,
|
|
137
142
|
findings: [
|
|
138
143
|
...templated.filter((finding) => touched.has(finding.path)),
|
|
139
144
|
...prose,
|
|
@@ -142,8 +147,10 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
|
|
|
142
147
|
...vanished,
|
|
143
148
|
...shapes,
|
|
144
149
|
...entries,
|
|
150
|
+
...oversize.filter((finding) => touched.has(finding.path)),
|
|
145
151
|
].toSorted(inPathOrder),
|
|
146
152
|
advisory,
|
|
153
|
+
readmeAdvisory: oversize.filter((finding) => !touched.has(finding.path)).toSorted(inPathOrder),
|
|
147
154
|
sentenceAdvisory,
|
|
148
155
|
brokenBefore: references.brokenBefore.toSorted(inPathOrder),
|
|
149
156
|
} satisfies Judged;
|
|
@@ -153,7 +160,7 @@ function describe({ path, line, message }: Finding): string {
|
|
|
153
160
|
return ` ${path}${line === undefined ? "" : `:${line}`}: ${message}`;
|
|
154
161
|
}
|
|
155
162
|
|
|
156
|
-
export function report({ held, edited, named, agents, findings, advisory, sentenceAdvisory, brokenBefore }: Judged): string {
|
|
163
|
+
export function report({ held, edited, named, agents, readmes, findings, advisory, readmeAdvisory, sentenceAdvisory, brokenBefore }: Judged): string {
|
|
157
164
|
const verdict =
|
|
158
165
|
findings.length === 0
|
|
159
166
|
? [
|
|
@@ -162,6 +169,7 @@ export function report({ held, edited, named, agents, findings, advisory, senten
|
|
|
162
169
|
`${NAME}: no line the range adds or edits copies a version package.json or .bun-version pins`,
|
|
163
170
|
`${NAME}: the range breaks no path, link or command the ${named} living doc(s) or agent file(s) name`,
|
|
164
171
|
`${NAME}: the ${agents} agent file(s) hold to the ceiling, and every entry names a tracked path, a link or a command`,
|
|
172
|
+
`${NAME}: the ${readmes} README(s) the range edits hold to the 6,000-character ceiling and the 300-character table cell ceiling`,
|
|
165
173
|
]
|
|
166
174
|
: [`${NAME}: ${findings.length} violation(s):`, ...findings.map(describe)];
|
|
167
175
|
const unconformed =
|
|
@@ -171,6 +179,10 @@ export function report({ held, edited, named, agents, findings, advisory, senten
|
|
|
171
179
|
`${NAME}: advisory, ${advisory.size} doc file(s) the range leaves alone do not hold to their templates yet:`,
|
|
172
180
|
...[...advisory].map(([path, count]) => ` ${path}: ${count} violation(s)`),
|
|
173
181
|
];
|
|
182
|
+
const oversize =
|
|
183
|
+
readmeAdvisory.length === 0
|
|
184
|
+
? []
|
|
185
|
+
: [`${NAME}: advisory, ${readmeAdvisory.length} README ceiling finding(s) in README(s) the range leaves alone:`, ...readmeAdvisory.map(describe)];
|
|
174
186
|
const sentences =
|
|
175
187
|
sentenceAdvisory.length === 0
|
|
176
188
|
? []
|
|
@@ -185,7 +197,7 @@ export function report({ held, edited, named, agents, findings, advisory, senten
|
|
|
185
197
|
`${NAME}: advisory, ${brokenBefore.length} path(s), link(s) or command(s) the living docs or agent files name were broken before the range:`,
|
|
186
198
|
...brokenBefore.map(describe),
|
|
187
199
|
];
|
|
188
|
-
return [...verdict, ...unconformed, ...sentences, ...broken].join("\n");
|
|
200
|
+
return [...verdict, ...unconformed, ...oversize, ...sentences, ...broken].join("\n");
|
|
189
201
|
}
|
|
190
202
|
|
|
191
203
|
const docs = Effect.gen(function* () {
|