@avi2dg/checks 0.65.3 → 0.67.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 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.67.0
6
+
7
+ Released 2026-11-06.
8
+
9
+ ### Features
10
+
11
+ - **ci-wiring:** accept shared runner pick and setup action [#48](https://github.com/avi2d/checks/pull/48)
12
+
13
+ ## 0.66.0
14
+
15
+ Released 2026-11-05.
16
+
17
+ ### Features
18
+
19
+ - **docs:** cap READMEs at 6,000 characters and table cells at 300 [#46](https://github.com/avi2d/checks/pull/46)
20
+
5
21
  ## 0.65.3
6
22
 
7
23
  Released 2026-11-04.
@@ -68,6 +68,10 @@ Any other expression refuses as unresolvable.
68
68
  A job on `push` or another trigger no fork reaches may still name self-hosted labels.
69
69
  In a private repository each mutation job carries exactly the labels the consumer declares in `package.json` under `checks.mutationRunner`.
70
70
  Every other job there carries exactly the hosted-default expression, or exactly one of the label sets the consumer declares under `checks.selfHostedRunners`.
71
+ A job there also carries `runs-on: ${{ needs.<job>.outputs.runs-on }}` when the same workflow needs that job and that job calls the runner pick.
72
+ The pick is the repository file `runner.yml` under `.github/workflows/` or `avi2d/workflows/.github/workflows/runner.yml` at a pinned full SHA.
73
+ A public repository still refuses that expression on a job a fork can reach.
74
+ The resolved label cannot be shown to be a hosted runner before the run.
71
75
  A repository without those declarations fails each job they would pin until it declares them.
72
76
  The check knows a repository is private only from GitHub's event, so a run outside CI judges only the rules that hold in either.
73
77
 
@@ -90,7 +94,9 @@ The fleet version is the version the kit ships in its own `.bun-version`.
90
94
  The step fails the check when the version is missing or differs from the fleet bun version.
91
95
  A range fails because it differs from the fleet version.
92
96
  A version built from an expression fails because it cannot be shown equal before the run.
93
- This rule skips a repository with no setup-bun step.
97
+ A step that uses `avi2d/workflows/actions/setup` reads `.bun-version`.
98
+ The step fails the check when `.bun-version` is missing or differs from the fleet bun version.
99
+ This rule skips a repository with no setup-bun step and no shared setup step.
94
100
  A kit release that moves the fleet version turns each consumer red until its pin follows.
95
101
  Each refusal names the workflow file and line, the version found and where it was read, and the fleet version.
96
102
 
@@ -133,8 +139,10 @@ Each refusal names the workflow file, the job and the limit.
133
139
  ## What it reads
134
140
 
135
141
  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.
142
+ It reads each shell script that a shell script it reads runs, until none is left.
136
143
  It lists the `*.ts` and `*.tsx` files `git ls-files` tracks to decide whether the night run applies.
137
144
  It reads each file a setup-bun step names for its version.
145
+ It reads `.bun-version` for each step that uses the shared setup action.
138
146
  It reads the fleet pin from the kit's own `.bun-version`.
139
147
  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.
140
148
  It reads whether the repository is private from `repository.private` in that event file.
@@ -149,7 +157,7 @@ It takes no arguments.
149
157
  | Code | Result |
150
158
  | --- | --- |
151
159
  | 0 | Every required command has a reachable step. |
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`. |
160
+ | 1 | A required command is missing or blocked, a job runs on the wrong runner, a workflow names a missing workflow or job, a bun setup 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`. |
153
161
  | 2 | A workflow or `package.json` cannot be decoded, or the kit's own `.bun-version` is missing, unreadable or blank. |
154
162
 
155
163
  ## Sample output
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.65.3",
3
+ "version": "0.67.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -81,6 +81,7 @@
81
81
  "src/delivery/mutation-job.ts",
82
82
  "src/delivery/job-timeout.ts",
83
83
  "src/delivery/runner-policy.ts",
84
+ "src/delivery/runner-pick.ts",
84
85
  "src/delivery/secrets.ts",
85
86
  "src/delivery/secrets-pack.ts",
86
87
  "src/delivery/pr-text.ts",
@@ -17,12 +17,19 @@ export type BunPinFault = {
17
17
 
18
18
  const SETUP_BUN = /^oven-sh\/setup-bun(?:@(?<ref>.*))?$/i;
19
19
  const V2_REF = /^v2(?:\.\d+){0,2}$/;
20
+ const SHARED_SETUP = /^avi2d\/workflows\/actions\/setup(?:@(?<ref>.*))?$/i;
21
+ const SETUP_VERSION_FILE = ".bun-version";
20
22
 
21
23
  function setupBunRef(uses: unknown): string | undefined {
22
24
  const match = typeof uses === "string" ? SETUP_BUN.exec(uses.trim()) : null;
23
25
  return match === null ? undefined : (match.groups?.["ref"] ?? "");
24
26
  }
25
27
 
28
+ function sharedSetupRef(uses: unknown): string | undefined {
29
+ const match = typeof uses === "string" ? SHARED_SETUP.exec(uses.trim()) : null;
30
+ return match === null ? undefined : (match.groups?.["ref"] ?? "");
31
+ }
32
+
26
33
  function isExpression(value: string): boolean {
27
34
  return value.includes("${{");
28
35
  }
@@ -106,6 +113,15 @@ function unknownRefFault(ref: string): string {
106
113
  return `uses oven-sh/setup-bun at ${ref === "" ? "no ref" : ref}, but the check knows only setup-bun v2's version resolution, so the step must use oven-sh/setup-bun@v2`;
107
114
  }
108
115
 
116
+ function sharedSetupFault(policy: BunPinPolicy, ref: string): string | undefined {
117
+ const version = nonEmpty(policy.files.get(SETUP_VERSION_FILE));
118
+ const uses = ref === "" ? "avi2d/workflows/actions/setup" : `avi2d/workflows/actions/setup@${ref}`;
119
+ if (version === undefined) {
120
+ return `uses ${uses}, which reads ${SETUP_VERSION_FILE}, but the repository holds no ${SETUP_VERSION_FILE} file, want ${policy.fleet}`;
121
+ }
122
+ return version === policy.fleet ? undefined : `requests bun ${version} from ${SETUP_VERSION_FILE}, want ${policy.fleet}`;
123
+ }
124
+
109
125
  export function findBunPinFaults(policy: BunPinPolicy, workflows: readonly Workflow[]): readonly BunPinFault[] {
110
126
  return workflows.flatMap((workflow) => {
111
127
  const at = locate(workflow.path, workflow.text.split("\n"));
@@ -113,17 +129,25 @@ export function findBunPinFaults(policy: BunPinPolicy, workflows: readonly Workf
113
129
  const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
114
130
  return steps.flatMap((step: unknown, index: number) => {
115
131
  if (!isRecord(step)) return [];
132
+ const atStep = (fault: string) => [
133
+ { location: at(["jobs", id, "steps", index, "uses"], String(step["uses"]), `job ${id} step ${index + 1}`), fault },
134
+ ];
116
135
  const ref = setupBunRef(step["uses"]);
117
- if (ref === undefined) return [];
118
- const fault = V2_REF.test(ref) ? bunPinFault(policy, step) : unknownRefFault(ref);
119
- return fault === undefined ? [] : [{ location: at(["jobs", id, "steps", index, "uses"], String(step["uses"]), `job ${id} step ${index + 1}`), fault }];
136
+ if (ref !== undefined) {
137
+ const fault = V2_REF.test(ref) ? bunPinFault(policy, step) : unknownRefFault(ref);
138
+ return fault === undefined ? [] : atStep(fault);
139
+ }
140
+ const sharedRef = sharedSetupRef(step["uses"]);
141
+ if (sharedRef === undefined) return [];
142
+ const fault = sharedSetupFault(policy, sharedRef);
143
+ return fault === undefined ? [] : atStep(fault);
120
144
  });
121
145
  });
122
146
  });
123
147
  }
124
148
 
125
149
  export function formatBunPinReport(faults: readonly BunPinFault[]): string {
126
- return [`ci-wiring: ${faults.length} setup-bun step(s) drift from the fleet bun version:`, ...faults.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
150
+ return [`ci-wiring: ${faults.length} bun setup step(s) drift from the fleet bun version:`, ...faults.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
127
151
  }
128
152
 
129
153
  function versionFileKeys(job: Readonly<Record<string, unknown>>): readonly string[] {
@@ -139,11 +163,17 @@ function versionFileKeys(job: Readonly<Record<string, unknown>>): readonly strin
139
163
  return keys;
140
164
  }
141
165
 
166
+ function usesSharedSetup(job: Readonly<Record<string, unknown>>): boolean {
167
+ const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
168
+ return steps.some((step) => isRecord(step) && sharedSetupRef(step["uses"]) !== undefined);
169
+ }
170
+
142
171
  function wantedBunPinFiles(workflows: readonly Workflow[]): ReadonlySet<string> {
143
172
  const wanted = new Set<string>([ROOT_MANIFEST]);
144
173
  for (const workflow of workflows) {
145
174
  for (const [, job] of workflowJobs(workflow)) {
146
175
  for (const key of versionFileKeys(job)) wanted.add(key);
176
+ if (usesSharedSetup(job)) wanted.add(SETUP_VERSION_FILE);
147
177
  }
148
178
  }
149
179
  return wanted;
@@ -0,0 +1,29 @@
1
+ import { names, type Workflow } from "./workflow-references.ts";
2
+
3
+ const NEEDS_RUNS_ON = /^\$\{\{\s*needs\.([\w-]+)\.outputs\.runs-on\s*\}\}$/i;
4
+ const SHARED_RUNNER = /^avi2d\/workflows\/\.github\/workflows\/runner\.yml@[0-9a-f]{40}$/i;
5
+ const LOCAL_RUNNER = ".github/workflows/runner.yml";
6
+
7
+ function callsRunnerPick(uses: unknown, workflows: readonly Workflow[]): boolean {
8
+ if (typeof uses !== "string") return false;
9
+ const target = uses.trim();
10
+ if (target.startsWith("./")) {
11
+ const path = target.slice(2);
12
+ return path === LOCAL_RUNNER && workflows.some((workflow) => workflow.path === path);
13
+ }
14
+ return SHARED_RUNNER.test(target);
15
+ }
16
+
17
+ // GitHub fills needs.<job> only for jobs the current job needs.
18
+ export function readsRunnerPick(
19
+ job: Readonly<Record<string, unknown>>,
20
+ runsOn: unknown,
21
+ jobs: ReadonlyMap<string, Readonly<Record<string, unknown>>>,
22
+ workflows: readonly Workflow[],
23
+ ): boolean {
24
+ if (typeof runsOn !== "string") return false;
25
+ const need = NEEDS_RUNS_ON.exec(runsOn.trim())?.[1];
26
+ if (need === undefined) return false;
27
+ if (!(names(job["needs"]) ?? []).includes(need)) return false;
28
+ return callsRunnerPick(jobs.get(need)?.["uses"], workflows);
29
+ }
@@ -1,5 +1,6 @@
1
1
  import { Effect, FileSystem, Path, Schema } from "effect";
2
2
  import { runsMutation, within, type Run } from "./mutation-job.ts";
3
+ import { readsRunnerPick } from "./runner-pick.ts";
3
4
  import { isRecord, names, workflowJobs, workflowTitle, type Workflow } from "./workflow-references.ts";
4
5
 
5
6
  export type RunnerConfig = {
@@ -314,11 +315,20 @@ function watchedWorkflows(workflow: Workflow): readonly string[] {
314
315
  return isRecord(watched) ? (names(watched["workflows"]) ?? []) : [];
315
316
  }
316
317
 
317
- function runnerFault(job: Readonly<Record<string, unknown>>, runsOn: unknown, mutation: boolean, policy: RunnerPolicy, reachable: boolean): string | undefined {
318
+ function runnerFault(
319
+ job: Readonly<Record<string, unknown>>,
320
+ runsOn: unknown,
321
+ mutation: boolean,
322
+ policy: RunnerPolicy,
323
+ reachable: boolean,
324
+ jobs: ReadonlyMap<string, Readonly<Record<string, unknown>>>,
325
+ workflows: readonly Workflow[],
326
+ ): string | undefined {
318
327
  if (policy.visibility === "public" && reachable) {
319
328
  const fault = publicFault(job, runsOn, policy.runners);
320
329
  if (fault !== undefined) return fault;
321
330
  }
331
+ if (readsRunnerPick(job, runsOn, jobs, workflows)) return undefined;
322
332
  return mutation ? mutationFault(runsOn, policy.runners.mutation, policy.visibility, reachable) : otherFault(runsOn, policy.runners, policy.visibility);
323
333
  }
324
334
 
@@ -359,14 +369,16 @@ export function findRunnerFaults(policy: RunnerPolicy, workflows: readonly Workf
359
369
  }
360
370
  }
361
371
  }
362
- return workflows.flatMap((workflow) =>
363
- workflowJobs(workflow).flatMap(([id, job]) => {
372
+ return workflows.flatMap((workflow) => {
373
+ const entries = workflowJobs(workflow);
374
+ const jobs = new Map(entries);
375
+ return entries.flatMap(([id, job]) => {
364
376
  if (job["runs-on"] === undefined) return [];
365
377
  const mutation = jobRuns(workflow, job).some((run) => runsMutation(run, policy.scripts, (file) => policy.files?.get(file)));
366
- const fault = runnerFault(job, job["runs-on"], mutation, policy, reachable.get(workflow.path) ?? forkReachable(workflow));
378
+ const fault = runnerFault(job, job["runs-on"], mutation, policy, reachable.get(workflow.path) ?? forkReachable(workflow), jobs, workflows);
367
379
  return fault === undefined ? [] : [{ location: workflow.path + " job " + id, fault }];
368
- }),
369
- );
380
+ });
381
+ });
370
382
  }
371
383
 
372
384
  export function formatRunnerReport(faults: readonly RunnerFault[]): string {
@@ -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();
@@ -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* () {