@avi2dg/checks 0.28.0 → 0.29.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,18 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.29.0
6
+
7
+ Released 2026-09-28.
8
+
9
+ ### Breaking changes
10
+
11
+ - **docs:** hold agent files to a 3,000-character router where every entry points [#101](https://github.com/avi2d/checks/pull/101)
12
+
13
+ ### Fixes
14
+
15
+ - **testing:** run checks-test with CI=true so focused tests fail locally [#100](https://github.com/avi2d/checks/pull/100)
16
+
5
17
  ## 0.28.0
6
18
 
7
19
  Released 2026-09-28.
package/README.md CHANGED
@@ -124,7 +124,7 @@ The table groups the gates by vector, the part of a repository each one judges.
124
124
  | quality | [`checks-comment-gate`](docs/gates/checks-comment-gate.md) | the range | every repository |
125
125
  | testing | [`checks-test-layout`](docs/gates/checks-test-layout.md) | the working tree | a repository tracking `*.ts` or `*.tsx` |
126
126
  | testing | [`checks-quarantine-clock`](docs/gates/checks-quarantine-clock.md) | the range | every repository |
127
- | docs | [`checks-docs`](docs/gates/checks-docs.md) | the range | every repository |
127
+ | docs | [`checks-docs`](docs/gates/checks-docs.md) | the range, and every agent file at the head commit | every repository |
128
128
  | delivery | [`checks-commit-identity`](docs/gates/checks-commit-identity.md) | the range | every repository |
129
129
  | delivery | [`checks-ci-wiring`](docs/gates/checks-ci-wiring.md) | the working tree | every repository |
130
130
  | dependencies | [`checks-advisories`](docs/gates/checks-advisories.md) | the range | a repository tracking `bun.lock` |
@@ -7,11 +7,3 @@
7
7
  <Leave this section out when the lead holds every constraint.>
8
8
 
9
9
  - <A constraint an agent cannot infer from the code, and the file that holds its detail.>
10
-
11
- ## Maintaining this file
12
-
13
- Keep this file for knowledge useful to almost every future agent session in this project.
14
- Do not repeat what the codebase already shows.
15
- Point to the authoritative file or command instead.
16
- Prefer rewriting or pruning existing entries over appending new ones.
17
- When updating this file, preserve this bar for all agents and keep entries concise.
package/docs/design.md CHANGED
@@ -86,6 +86,7 @@ npm adds `package.json`, `README.md` and `LICENSE` whatever `files` says.
86
86
  `bun pm pack` builds the same tarball the registry serves, and the consumer e2e test installs that tarball.
87
87
 
88
88
  Each oxlint plugin ships compiled under `dist/`, because Node refuses to strip types from a `.ts` file under `node_modules`.
89
+ `@oxlint/plugins` ships no RuleTester, so each `effect-channel`, `readability` and `data-shape` rule is proven red and green against an installed consumer in `tests/e2e/consumer.test.ts`.
89
90
  `dist/` is committed, with the doc templates in `dist/templates/`, and so is `CHANGELOG.md`, which the same build writes.
90
91
  No `prepack` or `prepublishOnly` script rebuilds them, so a publish ships the committed files.
91
92
  CI runs `git diff --exit-code` over the whole tree after `bun run build`.
@@ -140,7 +141,8 @@ That flag reports a repeated block as new once its text changes, so a change tha
140
141
  ## checks-test runs the suite itself
141
142
 
142
143
  `checks-test` runs bun itself rather than reading a report that another run left.
143
- A skip taken only on CI shows only in CI's own run, and an earlier run's report may be stale or narrowed.
144
+ A `"ci"` skip gated on a daemon or tool that CI lacks shows in CI's own run, and an earlier run's report may be stale or narrowed.
145
+ When the same resource is also absent locally, the test skips there too, and its `"ci"` declaration leaves that skip undeclared, so the local run fails.
144
146
  It reads the JUnit report bun writes to a temporary directory, because bun has no other per-test output meant for a program.
145
147
 
146
148
  ## Quarantine has one limit
@@ -175,7 +177,8 @@ A repository adopts the templates as its files change, and an untouched file is
175
177
  The prose rules judge only the lines a change adds or edits.
176
178
  A report about the past is refused the way a promise about the future is, because history on a living page reads as current fact.
177
179
  Text nobody touched never breaks the templates or the prose rules, and a record keeps the words it was written in.
178
- A repository needs no cleanup pass before the gate runs.
180
+ A repository needs no cleanup pass before the gate runs, except on its agent files.
181
+ The ceiling, the rule against a `## Maintaining this file` section and the entry rule judge every agent file at the head commit, because an agent reads the whole file every session, touched or not.
179
182
  Review, not the check, keeps a task heading verb first.
180
183
  No word list tells `Test layout` from `Test the layout`, and a check that passes the noun would be worse than none.
181
184
 
@@ -13,6 +13,8 @@ It holds each doc file a change touches to the template for its kind.
13
13
  It lists every other doc file that does not match its template yet, and does not fail on it.
14
14
  It holds each line a change adds or edits in a living doc or an agent file to the prose rules, as [The prose rules](#the-prose-rules) says.
15
15
  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.
16
+ 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.
17
+ It fails when an entry in an agent file names no tracked path, link or `bun run` command, whatever the range touches.
16
18
  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.
17
19
  [Paths, links and commands](#paths-links-and-commands) says how each reference resolves.
18
20
  The package ships one template per kind under `dist/templates/`, and a repository starts a new doc file by copying one:
@@ -142,6 +144,24 @@ audience: consumers
142
144
  ---
143
145
  ```
144
146
 
147
+ ## Agent files
148
+
149
+ An agent file holds the router its template sketches, and the rules below hold its shape whatever the range touches.
150
+ A file over 3,000 characters fails.
151
+ Move each part's notes into the people doc that covers that part, and delete what a check or the code already holds.
152
+ A file that holds a `## Maintaining this file` section fails, because this gate holds the shape the section asked for.
153
+ Each entry names at least one of these, or it fails:
154
+
155
+ - A path in inline code that git tracks at the head commit, a file or a directory, such as `package.json`, `LICENSE` or `.gitignore`.
156
+ It resolves from the root or from the file's directory, `./` and `../` included, and a path that ends in `/` or `/.` names a directory, never a file.
157
+ - A Markdown link written `[text](target)` with a destination and a closing parenthesis, and not an image.
158
+ - A `bun run` command.
159
+
160
+ Whether the link or the command resolves is the reference rule's call, as [Paths, links and commands](#paths-links-and-commands) says, and it fails when the range adds or breaks one.
161
+ An entry is any list item a reader sees, the items above the first section included.
162
+ A list item inside an HTML comment, an HTML block or an indented code block is not an entry.
163
+ A fresh file passes the ceiling, and its entries pass once each names the file that holds its detail.
164
+
145
165
  ## What it reads
146
166
 
147
167
  It reads each Markdown file at the head commit, and uses its path or front matter to choose its kind.
@@ -150,6 +170,7 @@ It reads the lines the range adds or edits from the diff, with renames detected,
150
170
  It reads the files tracked at both ends of the range, and the `scripts` of each `package.json` a living doc or an agent file sits under.
151
171
  It compares each code span a living doc or an agent file names with the text git tracks outside the docs at both ends of the range.
152
172
  It reads the repository's own name and its direct dependencies from the root `package.json` at the head commit.
173
+ It reads each agent file at the head commit for the ceiling, its sections and its entries, whatever the range touches.
153
174
  A name that an installed direct dependency still holds counts as present.
154
175
  From the working tree it reads the ignore files git reads, `node_modules/.bin`, and the directory of each direct dependency under `node_modules`.
155
176
 
@@ -167,8 +188,8 @@ With one it is that commit against its parent, or against the empty tree for a r
167
188
 
168
189
  | Code | When |
169
190
  | --- | --- |
170
- | 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, it adds or breaks no reference that does not resolve, and no code span a living doc or an agent file names vanished from every file outside the docs |
171
- | 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, the range adds or breaks a reference that does not resolve, or the range removes a name a living doc or an agent file still carries |
191
+ | 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, 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 |
192
+ | 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, 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 |
172
193
  | 2 | a `package.json` does not decode, a ref does not resolve, or `grep` cannot read an installed direct dependency |
173
194
 
174
195
  ## Sample output
@@ -191,6 +212,7 @@ docs: advisory, 1 path(s), link(s) or command(s) the living docs or agent files
191
212
 
192
213
  `checks-lint` runs it over each pull request's range in every repository, as [checks-lint](checks-lint.md) says.
193
214
  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.
215
+ It reshapes its agent files when it adopts the gate, because [Agent files](#agent-files) judges each one whatever the range touches.
194
216
  A name the range removes fails wherever a doc still carries it, because the removal is what turned the line stale.
195
217
 
196
218
  ## Related topics
@@ -12,6 +12,7 @@ The gate runs jscpd at 50 tokens and 5 lines against the base and head revisions
12
12
  It compares repeated lines for each file, so a decrease in another file never offsets a rise.
13
13
  It follows an edited rename back to the original file.
14
14
  A file that repeats lines without a rise is advisory.
15
+ A block two bins need goes into a module both import, as `rangeFromArgs` and `checkoutFiles` in `src/core/git.ts` show.
15
16
 
16
17
  ## What it reads
17
18
 
@@ -9,6 +9,7 @@ audience: consumers
9
9
  ## What it checks
10
10
 
11
11
  It runs the default suite with `bun test --randomize` and reads Bun's JUnit report from that run.
12
+ It runs Bun with `CI=true`, so `test.only` fails the run in every environment.
12
13
  Bun exits zero when tests skip, so `checks-test` checks every skipped test against its source declaration.
13
14
  A test that `test.skip`, `test.skipIf`, `test.if`, `test.todo` or an enclosing `describe.skip` skips fails unless the test declares its reason.
14
15
 
@@ -33,6 +34,8 @@ A skip is declared on the test itself and only there.
33
34
  Add `"ci"` or `"local"` as the third `skipReason` argument when a declaration applies to one environment.
34
35
  Omit the third argument when it applies in both environments.
35
36
  A declaration for the other environment is not judged in the current run.
37
+ Bun sees `CI` set in every `checks-test` run, so a `"ci"` skip whose condition reads `process.env.CI` also skips in a local run and fails there as undeclared.
38
+ Gate a `"ci"` skip on what CI lacks, such as a daemon or a tool, and never on `process.env.CI`.
36
39
 
37
40
  A declaration whose test passes or does not register fails a CI run.
38
41
  A local run warns about the same declaration because a condition can depend on the machine.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -57,6 +57,7 @@
57
57
  "src/dependencies/advisories.ts",
58
58
  "src/dependencies/advisory-rules.ts",
59
59
  "src/dependencies/osv-scanner.ts",
60
+ "src/docs/doc-agents.ts",
60
61
  "src/docs/doc-outline.ts",
61
62
  "src/docs/doc-rules.ts",
62
63
  "src/docs/doc-references.ts",
@@ -27,7 +27,7 @@ const hasKnipConfig = Effect.fn("hasKnipConfig")(function* (root: string) {
27
27
  const manifest: unknown = yield* fs.readFileString(path.join(root, "package.json")).pipe(
28
28
  Effect.flatMap((text) =>
29
29
  Effect.try({
30
- try: () => JSON.parse(text),
30
+ try: (): unknown => JSON.parse(text),
31
31
  catch: () => new KnipError({ message: "package.json does not parse as JSON" }),
32
32
  })
33
33
  ),
package/src/core/gates.ts CHANGED
@@ -19,6 +19,7 @@ export type KitGate = {
19
19
  readonly vector: Vector;
20
20
  readonly file: string;
21
21
  readonly reads: "tree" | "range";
22
+ readonly alsoReads?: string;
22
23
  readonly args?: readonly string[];
23
24
  readonly appliesTo: typeof EVERY_REPOSITORY | TrackedContent;
24
25
  };
@@ -40,7 +41,7 @@ export const KIT_GATES = [
40
41
  { bin: "checks-comment-gate", vector: "quality", file: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
41
42
  { bin: "checks-suppressions-ratchet", vector: "complexity", file: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
42
43
  { bin: "checks-ci-wiring", vector: "delivery", file: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
43
- { bin: "checks-docs", vector: "docs", file: "docs.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
44
+ { bin: "checks-docs", vector: "docs", file: "docs.ts", reads: "range", alsoReads: "every agent file at the head commit", appliesTo: EVERY_REPOSITORY },
44
45
  { bin: "checks-repetition", vector: "complexity", file: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
45
46
  { bin: "checks-unused", vector: "complexity", file: "unused.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
46
47
  { bin: "checks-exports", vector: "complexity", file: "exports.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
@@ -0,0 +1,106 @@
1
+ import { commandNames, type Snapshot } from "./doc-references.ts";
2
+ import { AGENT_NAMES, scanMarkdown, type MarkdownLine } from "./prose-matchers.ts";
3
+
4
+ export const AGENT_CEILING = 3000;
5
+
6
+ const LIST_ITEM = /^(?:\s*>)*\s*(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
7
+ const INDENT = /^[ \t]*/;
8
+ const CODE_INDENT = 4;
9
+ const INLINE_LINK =
10
+ /(?<![!\\])\[(?:[^[\]\\]|\\.|\[[^\]]*\])*\]\(\s*(?:<[^<>\n]+>|[^\s()<>]+(?:\([^\s()]*\)[^\s()<>]*)*)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g;
11
+ const MAINTAINING = /^\s{0,3}#{1,6}\s+Maintaining this file(?:\s+#+)?\s*$/;
12
+
13
+ export type AgentFinding = {
14
+ readonly line: number | undefined;
15
+ readonly message: string;
16
+ };
17
+
18
+ export function isAgentFile(repositoryPath: string): boolean {
19
+ return AGENT_NAMES.includes(repositoryPath.slice(repositoryPath.lastIndexOf("/") + 1));
20
+ }
21
+
22
+ export function ceilingFinding(text: string): AgentFinding | undefined {
23
+ if (text.length <= AGENT_CEILING) return undefined;
24
+ return {
25
+ line: undefined,
26
+ message: `is ${text.length} characters, over the 3,000-character ceiling for agent files. Keep what nearly every session needs plus one pointer per part, move each part's notes into the people doc that covers that part, and delete what a check already holds`,
27
+ };
28
+ }
29
+
30
+ export function maintainingFinding(text: string): AgentFinding | undefined {
31
+ const heading = scanMarkdown(text).find(({ kind, raw }) => kind === "heading" && MAINTAINING.test(raw));
32
+ if (heading === undefined) return undefined;
33
+ return {
34
+ line: heading.line,
35
+ message: "holds `## Maintaining this file`, which a router leaves out. Delete the section, since checks-docs holds the file's shape",
36
+ };
37
+ }
38
+
39
+ function indentOf(raw: string): number {
40
+ return (INDENT.exec(raw)?.[0] ?? "").replaceAll("\t", " ").length;
41
+ }
42
+
43
+ export function entries(text: string): readonly MarkdownLine[] {
44
+ let inList = false;
45
+ let afterBlank = true;
46
+ return scanMarkdown(text).filter((line) => {
47
+ const blank = line.raw.trim() === "";
48
+ const indented = !blank && indentOf(line.raw) >= CODE_INDENT && !line.raw.trimStart().startsWith(">");
49
+ const listItem = line.kind === "prose" && LIST_ITEM.test(line.prose) && (inList || !indented);
50
+ if (listItem) inList = true;
51
+ else if (!blank && !indented && (afterBlank || line.kind !== "prose")) inList = false;
52
+ afterBlank = blank;
53
+ return listItem;
54
+ });
55
+ }
56
+
57
+ type Tracked = Pick<Snapshot, "files" | "directories">;
58
+
59
+ const STAYS = new Set(["", "."]);
60
+
61
+ function joined(directory: string, segment: string): string {
62
+ return directory === "" ? segment : `${directory}/${segment}`;
63
+ }
64
+
65
+ function enter(directory: string, segment: string, tracked: Tracked): string | undefined {
66
+ if (STAYS.has(segment)) return directory;
67
+ if (segment === "..") return directory === "" ? undefined : directory.slice(0, Math.max(directory.lastIndexOf("/"), 0));
68
+ const next = joined(directory, segment);
69
+ return tracked.directories.has(next) ? next : undefined;
70
+ }
71
+
72
+ function resolvesFrom(directory: string, span: string, tracked: Tracked): boolean {
73
+ const segments = span.split("/");
74
+ const last = segments.pop() ?? "";
75
+ const walked = segments.reduce<string | undefined>((at, segment) => (at === undefined ? undefined : enter(at, segment, tracked)), directory);
76
+ if (walked === undefined) return false;
77
+ if (!STAYS.has(last) && last !== ".." && tracked.files.has(joined(walked, last))) return true;
78
+ const end = enter(walked, last, tracked);
79
+ return end !== undefined && end !== "" && end !== directory;
80
+ }
81
+
82
+ function namesTrackedPath(agentFile: string, span: string, tracked: Tracked): boolean {
83
+ const directory = agentFile.includes("/") ? agentFile.slice(0, agentFile.lastIndexOf("/")) : "";
84
+ const fromFile = resolvesFrom(directory, span, tracked);
85
+ return fromFile || (!span.startsWith("./") && !span.startsWith("../") && resolvesFrom("", span, tracked));
86
+ }
87
+
88
+ function namesLink({ raw, prose }: MarkdownLine): boolean {
89
+ return [...raw.matchAll(INLINE_LINK)].some(({ index }) => prose.charAt(index) === "[");
90
+ }
91
+
92
+ function points(agentFile: string, line: MarkdownLine, tracked: Tracked): boolean {
93
+ return line.code.some((span) => namesTrackedPath(agentFile, span, tracked)) || namesLink(line) || commandNames(line).length > 0;
94
+ }
95
+
96
+ export function entryFindings(agentFile: string, text: string, tracked: Tracked): readonly AgentFinding[] {
97
+ return entries(text).flatMap((line) => {
98
+ if (points(agentFile, line, tracked)) return [];
99
+ return [
100
+ {
101
+ line: line.line,
102
+ message: "names no tracked path, link or `bun run` command. Name the file, link or command that holds the detail",
103
+ },
104
+ ];
105
+ });
106
+ }
@@ -131,6 +131,10 @@ function commandsOn({ kind, raw, code }: MarkdownLine): readonly string[] {
131
131
  return texts.flatMap((text) => [...text.matchAll(RUN)].map(([, name = ""]) => name.replace(/[),.;:]+$/, "")));
132
132
  }
133
133
 
134
+ export function commandNames(line: MarkdownLine): readonly string[] {
135
+ return commandsOn(line).filter((name) => !NOT_A_NAME.test(name));
136
+ }
137
+
134
138
  export type Judging = { readonly commands: boolean };
135
139
 
136
140
  export function unresolvedIn(doc: string, text: string, snapshot: Snapshot, { commands }: Judging): readonly Unresolved[] {
@@ -57,14 +57,6 @@ const BEFORE_YOU_BEGIN = fixed("Before you begin", REQUIRED, ["- <each prerequis
57
57
 
58
58
  const STEPS = ["To <do the task>:", "", "1. <step>", "1. <step>"];
59
59
 
60
- const MAINTAINING = [
61
- "Keep this file for knowledge useful to almost every future agent session in this project.",
62
- "Do not repeat what the codebase already shows.",
63
- "Point to the authoritative file or command instead.",
64
- "Prefer rewriting or pruning existing entries over appending new ones.",
65
- "When updating this file, preserve this bar for all agents and keep entries concise.",
66
- ];
67
-
68
60
  export const CHANGE_GROUPS = ["Breaking changes", "Features", "Fixes", "Performance", "Reverts"] as const;
69
61
  export type ChangeGroup = (typeof CHANGE_GROUPS)[number];
70
62
 
@@ -122,7 +114,6 @@ export const TEMPLATES: Readonly<Record<Kind, Template>> = {
122
114
  open("<A topic an agent needs>", "any", optional("the lead holds every constraint"), [
123
115
  "- <A constraint an agent cannot infer from the code, and the file that holds its detail.>",
124
116
  ]),
125
- fixed("Maintaining this file", REQUIRED, MAINTAINING),
126
117
  ],
127
118
  },
128
119
  claude: {
package/src/docs/docs.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect } from "effect";
3
- import { rootsOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
3
+ import { ceilingFinding, entryFindings, isAgentFile, maintainingFinding } from "./doc-agents.ts";
4
+ import { rootsOf, snapshotOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
4
5
  import { ADR_DIRECTORY, judge, placementOf, placementProblem, speaksToConsumers, type Placement } from "./doc-rules.ts";
5
6
  import { vanishedNames } from "./doc-names.ts";
6
7
  import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
@@ -18,6 +19,7 @@ type Judged = {
18
19
  readonly held: readonly string[];
19
20
  readonly edited: { readonly docs: number; readonly lines: number };
20
21
  readonly named: number;
22
+ readonly agents: number;
21
23
  readonly findings: readonly Finding[];
22
24
  readonly advisory: ReadonlyMap<string, number>;
23
25
  readonly brokenBefore: readonly Finding[];
@@ -109,13 +111,27 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
109
111
  const referenced = new Map(proseDocs.map(({ path }) => [path, text(path)]));
110
112
  const references = yield* referenceFindings({ root, base, head, roots, changed, renamedFrom }, referenced, judging);
111
113
  const vanished = yield* vanishedNames(root, base, head, referenced, references.failed);
114
+ const agents = proseDocs.filter(({ path }) => isAgentFile(path));
115
+ const tracked = snapshotOf(yield* pathsAt(head, [], root), new Map(), new Map());
116
+ const shapes = agents.flatMap(({ path }) =>
117
+ [ceilingFinding(text(path)), maintainingFinding(text(path))].flatMap((finding) => (finding === undefined ? [] : [{ path, ...finding }])),
118
+ );
119
+ const entries = agents.flatMap(({ path }) => entryFindings(path, text(path), tracked).map((finding) => ({ path, ...finding })));
112
120
  const advisory = new Map<string, number>();
113
121
  for (const { path } of templated.filter((finding) => !touched.has(finding.path))) advisory.set(path, (advisory.get(path) ?? 0) + 1);
114
122
  return {
115
123
  held: judged.map(({ path }) => path).filter((path) => touched.has(path)),
116
124
  edited: { docs: edited.length, lines: edited.reduce((sum, { path }) => sum + (changed.get(path)?.size ?? 0), 0) },
117
125
  named: referenced.size,
118
- findings: [...templated.filter((finding) => touched.has(finding.path)), ...prose, ...references.failing, ...vanished].toSorted(inPathOrder),
126
+ agents: agents.length,
127
+ findings: [
128
+ ...templated.filter((finding) => touched.has(finding.path)),
129
+ ...prose,
130
+ ...references.failing,
131
+ ...vanished,
132
+ ...shapes,
133
+ ...entries,
134
+ ].toSorted(inPathOrder),
119
135
  advisory,
120
136
  brokenBefore: references.brokenBefore.toSorted(inPathOrder),
121
137
  } satisfies Judged;
@@ -125,13 +141,14 @@ function describe({ path, line, message }: Finding): string {
125
141
  return ` ${path}${line === undefined ? "" : `:${line}`}: ${message}`;
126
142
  }
127
143
 
128
- export function report({ held, edited, named, findings, advisory, brokenBefore }: Judged): string {
144
+ export function report({ held, edited, named, agents, findings, advisory, brokenBefore }: Judged): string {
129
145
  const verdict =
130
146
  findings.length === 0
131
147
  ? [
132
148
  `${NAME}: ${held.length} doc file(s) the range touches hold to their templates`,
133
149
  `${NAME}: ${edited.lines} line(s) the range adds or edits in ${edited.docs} living doc(s) or agent file(s) hold to the prose rules`,
134
150
  `${NAME}: the range breaks no path, link or command the ${named} living doc(s) or agent file(s) name`,
151
+ `${NAME}: the ${agents} agent file(s) hold to the ceiling, and every entry names a tracked path, a link or a command`,
135
152
  ]
136
153
  : [`${NAME}: ${findings.length} violation(s):`, ...findings.map(describe)];
137
154
  const unconformed =
@@ -118,7 +118,13 @@ const runSuite = Effect.fn("runSuite")(function* (outfile: string, tier: TestTie
118
118
  const tierArgs = tier === undefined ? [] : ["--path-ignore-patterns", "", `./tests/${tier}`];
119
119
  const args = ["test", "--randomize", ...tierArgs, ...reporterArgs(outfile)];
120
120
  return yield* spawner.exitCode(
121
- ChildProcess.make(process.execPath, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit" }),
121
+ ChildProcess.make(process.execPath, args, {
122
+ stdin: "ignore",
123
+ stdout: "inherit",
124
+ stderr: "inherit",
125
+ env: { CI: "true" },
126
+ extendEnv: true,
127
+ }),
122
128
  );
123
129
  });
124
130