@avi2dg/checks 0.69.0 → 0.70.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,14 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.70.0
6
+
7
+ Released 2026-11-07.
8
+
9
+ ### Features
10
+
11
+ - **docs:** require root AGENTS.md and CLAUDE.md with package.json [#58](https://github.com/avi2d/checks/pull/58)
12
+
5
13
  ## 0.69.0
6
14
 
7
15
  Released 2026-11-06.
@@ -17,6 +17,7 @@ It fails when a living doc or an agent file names a path, link or command that d
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
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.
20
+ It fails when a range edits a doc file and a repository with a `package.json` is missing `AGENTS.md` or `CLAUDE.md` at its root, as [Root agent files](#root-agent-files) says.
20
21
  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.
21
22
  [Paths, links and commands](#paths-links-and-commands) says how each reference resolves.
22
23
  The package ships one template per kind under `dist/templates/`, and a repository starts a new doc file by copying one:
@@ -206,6 +207,21 @@ No line in a complete same-named generated block counts toward either ceiling, f
206
207
  A README fails only in a range that edits it.
207
208
  A README the range leaves alone is listed as advisory, so the first change that edits an oversize README shrinks it.
208
209
 
210
+ ## Root agent files
211
+
212
+ A repository with a `package.json` keeps `AGENTS.md` and `CLAUDE.md` at its root, since Claude Code reads `CLAUDE.md` and every other agent reads `AGENTS.md`.
213
+ The failure names the missing file and the template to copy:
214
+
215
+ - `AGENTS.md` starts from `dist/templates/agents.md`, and [Agent files](#agent-files) holds its shape.
216
+ - `CLAUDE.md` is `dist/templates/claude.md` word for word, the import that points Claude Code at `AGENTS.md`.
217
+ A symlink to `AGENTS.md` or any other text fails that template once a range touches the file.
218
+
219
+ A `package.json` anywhere in the repository asks for both files.
220
+ A repository with no `package.json` needs neither.
221
+ A missing file fails only in a range that adds, changes, renames or deletes a doc file, an ADR or the changelog included.
222
+ Markdown the gate does not judge, such as a changeset entry or a pull request template, does not end the advisory period.
223
+ Any other range lists the missing file as advisory, so the first change that edits a doc file adds both files.
224
+
209
225
  ## What it reads
210
226
 
211
227
  It reads each Markdown file at the head commit, and uses its path or front matter to choose its kind.
@@ -217,6 +233,8 @@ It reads the repository's own name and its direct dependencies from the root `pa
217
233
  It reads the versions the root `package.json` and `.bun-version` pin at the head commit.
218
234
  It reads each agent file at the head commit for the ceiling, its sections and its entries, whatever the range touches.
219
235
  It reads the root `README.md` and each `README.md` beside a tracked `package.json` at the head commit for the README ceilings.
236
+ It reads whether any `package.json` and the root `AGENTS.md` and `CLAUDE.md` are tracked at the head commit for the root agent files.
237
+ When a root agent file is missing, it reads a deleted or renamed Markdown file at the base commit to find its front matter kind.
220
238
  A name that an installed direct dependency still holds counts as present.
221
239
  From the working tree it reads the ignore files git reads, `node_modules/.bin`, and the directory of each direct dependency under `node_modules`.
222
240
 
@@ -234,14 +252,15 @@ With one it is that commit against its parent, or against the empty tree for a r
234
252
 
235
253
  | Code | When |
236
254
  | --- | --- |
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 |
255
+ | 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, a range that edits a doc file leaves `AGENTS.md` and `CLAUDE.md` at the root of a repository with a `package.json`, and no code span a living doc or an agent file names vanished from every file outside the docs |
256
+ | 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, a range that edits a doc file leaves either `AGENTS.md` or `CLAUDE.md` missing from the root in a repository with a `package.json`, or the range removes a name a living doc or an agent file still carries |
239
257
  | 2 | a `package.json` does not decode, a ref does not resolve, or `grep` cannot read an installed direct dependency |
240
258
 
241
259
  ## Sample output
242
260
 
243
261
  ```
244
- docs: 7 violation(s):
262
+ docs: 8 violation(s):
263
+ CLAUDE.md: is missing from the repository root, which a repository with a `package.json` keeps. Copy `node_modules/@avi2dg/checks/dist/templates/claude.md` to `CLAUDE.md` word for word, so Claude Code reads `AGENTS.md`
245
264
  README.md:1: lacks `## Before you begin`
246
265
  README.md:12: carries `;`, a semicolon. Use two sentences
247
266
  README.md:13: carries `1.3.5`, a version .bun-version pins. Name .bun-version instead, or let a build write the line between generated markers
@@ -265,6 +284,7 @@ docs: advisory, 1 path(s), link(s) or command(s) the living docs or agent files
265
284
  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.
266
285
  It reshapes its agent files when it adopts the gate, because [Agent files](#agent-files) judges each one whatever the range touches.
267
286
  It shrinks an oversize README in the first change that edits it, because [README ceilings](#readme-ceilings) judges only a README the range edits.
287
+ It adds `AGENTS.md` and `CLAUDE.md` at its root in the first change that edits a doc file, because [Root agent files](#root-agent-files) judges only such a range.
268
288
  A name the range removes fails wherever a doc still carries it, because the removal is what turned the line stale.
269
289
 
270
290
  ## Related topics
@@ -65,6 +65,20 @@ To give each tool the config it reads:
65
65
  export default defineConfig({ orphans: ["^src/bin[.]ts$"] });
66
66
  ```
67
67
 
68
+ ## Write the agent files
69
+
70
+ To give each agent the file it reads:
71
+
72
+ 1. Copy the agent templates to the repository root:
73
+
74
+ ```sh
75
+ cp node_modules/@avi2dg/checks/dist/templates/agents.md AGENTS.md
76
+ cp node_modules/@avi2dg/checks/dist/templates/claude.md CLAUDE.md
77
+ ```
78
+
79
+ 1. Fill `AGENTS.md` with what an agent cannot infer from the code.
80
+ 1. Keep `CLAUDE.md` word for word, so Claude Code reads `AGENTS.md`.
81
+
68
82
  ## Run the checks on every pull request
69
83
 
70
84
  To run the gates in CI:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.69.0",
3
+ "version": "0.70.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -1,5 +1,5 @@
1
1
  import { Parser, type NodeType } from "commonmark";
2
- import { commandNames, type Snapshot } from "./doc-references.ts";
2
+ import { commandNames, PACKAGE_MANIFEST, type Snapshot } from "./doc-references.ts";
3
3
  import { handWritten } from "./doc-versions.ts";
4
4
  import { AGENT_NAMES, scanMarkdown, type MarkdownLine } from "./prose-matchers.ts";
5
5
 
@@ -8,7 +8,6 @@ export const README_CEILING = 6000;
8
8
  export const CELL_CEILING = 300;
9
9
 
10
10
  const README = "README.md";
11
- const MANIFEST = "package.json";
12
11
  const SECOND_LEVEL = /^\s{0,3}##(?:\s+|$)(.*?)(?:\s+#+)?\s*$/;
13
12
  const SETEXT_SECOND = /^\s{0,3}-+\s*$/;
14
13
  const CELL_SEPARATOR = /(?<!\\)\|/;
@@ -37,7 +36,31 @@ export function ceilingFinding(text: string): AgentFinding | undefined {
37
36
 
38
37
  export function isCeiledReadme(repositoryPath: string, files: ReadonlySet<string>): boolean {
39
38
  if (repositoryPath === README) return true;
40
- return repositoryPath.endsWith(`/${README}`) && files.has(`${repositoryPath.slice(0, -README.length)}${MANIFEST}`);
39
+ return repositoryPath.endsWith(`/${README}`) && files.has(`${repositoryPath.slice(0, -README.length)}${PACKAGE_MANIFEST}`);
40
+ }
41
+
42
+ const KIT_TEMPLATES = "node_modules/@avi2dg/checks/dist/templates";
43
+
44
+ const ROOT_AGENT_FILES = [
45
+ { path: "AGENTS.md", fix: `Copy \`${KIT_TEMPLATES}/agents.md\` to \`AGENTS.md\` and fill it with what an agent cannot infer from the code` },
46
+ { path: "CLAUDE.md", fix: `Copy \`${KIT_TEMPLATES}/claude.md\` to \`CLAUDE.md\` word for word, so Claude Code reads \`AGENTS.md\`` },
47
+ ] as const;
48
+
49
+ export type RootAgentFinding = {
50
+ readonly path: (typeof ROOT_AGENT_FILES)[number]["path"];
51
+ readonly message: string;
52
+ };
53
+
54
+ export function hasManifest(files: ReadonlySet<string>): boolean {
55
+ return [...files].some((file) => file === PACKAGE_MANIFEST || file.endsWith(`/${PACKAGE_MANIFEST}`));
56
+ }
57
+
58
+ export function missingAgentFiles(files: ReadonlySet<string>): readonly RootAgentFinding[] {
59
+ if (!hasManifest(files)) return [];
60
+ return ROOT_AGENT_FILES.filter(({ path }) => !files.has(path)).map(({ path, fix }) => ({
61
+ path,
62
+ message: `is missing from the repository root, which a repository with a \`package.json\` keeps. ${fix}`,
63
+ }));
41
64
  }
42
65
 
43
66
  type Part = { readonly title: string; readonly lines: readonly MarkdownLine[] };
package/src/docs/docs.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect } from "effect";
3
- import { ceilingFinding, entryFindings, isAgentFile, isCeiledReadme, maintainingFinding, readmeFindings } from "./doc-agents.ts";
4
- import { rootsOf, snapshotOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
3
+ import { ceilingFinding, entryFindings, hasManifest, isAgentFile, isCeiledReadme, maintainingFinding, missingAgentFiles, readmeFindings, type RootAgentFinding } from "./doc-agents.ts";
4
+ import { rootsOf, snapshotOf, type Snapshot, 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";
7
7
  import { pinsAt, versionFindings } from "./doc-versions.ts";
8
8
  import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
9
- import { changedLines, changedPaths, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
9
+ import { changedLines, changedPaths, git, pathsAt, pathsOf, rangeEnds, refArgs, type Change } from "../core/git.ts";
10
10
  import { runMain } from "../core/main.ts";
11
11
  import { proseFindings, readerOf } from "./prose-matchers.ts";
12
12
  import { longSentences } from "./sentence-length.ts";
@@ -23,9 +23,11 @@ type Judged = {
23
23
  readonly named: number;
24
24
  readonly agents: number;
25
25
  readonly readmes: number;
26
+ readonly hasManifest: boolean;
26
27
  readonly findings: readonly Finding[];
27
28
  readonly advisory: ReadonlyMap<string, number>;
28
29
  readonly readmeAdvisory: readonly Finding[];
30
+ readonly agentFileAdvisory: readonly Finding[];
29
31
  readonly sentenceAdvisory: readonly Finding[];
30
32
  readonly brokenBefore: readonly Finding[];
31
33
  };
@@ -45,6 +47,27 @@ const NAME = "docs";
45
47
  const USAGE = "usage: docs.ts <ref> | <base-ref> <head-ref>";
46
48
  const MARKDOWN = [":(glob)**/*.md"];
47
49
 
50
+ function agentFileFindings(path: string, text: string, tracked: Snapshot): readonly Finding[] {
51
+ const shaped = [ceilingFinding(text), maintainingFinding(text)].flatMap((finding) => (finding === undefined ? [] : [finding]));
52
+ return [...shaped, ...entryFindings(path, text, tracked)].map((finding) => ({ path, ...finding }));
53
+ }
54
+
55
+ const rangeEditsDocs = Effect.fn("rangeEditsDocs")(function* (root: string, base: string, changes: readonly Change[], text: (path: string) => string) {
56
+ const paths = changes.flatMap((change) => pathsOf(change));
57
+ if (paths.some((path) => placementOf(path, text(path)).type !== "unjudged" || readerOf(path) !== undefined)) return true;
58
+ const gone = [...new Set(changes.flatMap((change) => (change.kind === "deleted" ? [change.path] : change.kind === "renamed" ? [change.from] : [])))].filter(
59
+ (path) => readerOf(path) === undefined && placementOf(path).type === "unjudged",
60
+ );
61
+ if (gone.length === 0) return false;
62
+ const before = yield* readTexts(root, base, gone);
63
+ return gone.some((path) => placementOf(path, before.get(path) ?? "").type !== "unjudged");
64
+ });
65
+
66
+ function rootAgentFindings(absent: readonly RootAgentFinding[], editsDocs: boolean): { readonly failing: readonly Finding[]; readonly advisory: readonly Finding[] } {
67
+ const listed = absent.map((finding) => ({ ...finding, line: undefined }));
68
+ return editsDocs ? { failing: listed, advisory: [] } : { failing: [], advisory: listed };
69
+ }
70
+
48
71
  function templateFindings(path: string, text: string, placement: Placement, records: Records): readonly Finding[] {
49
72
  const misplaced = placementProblem(placement);
50
73
  if (misplaced !== undefined) return [{ path, line: undefined, message: misplaced }];
@@ -114,9 +137,6 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
114
137
  const versions = edited.flatMap(({ path }) =>
115
138
  versionFindings(text(path), pins, changed.get(path)).map(({ line, message }) => ({ path, line, message })),
116
139
  );
117
- const sentenceAdvisory = edited
118
- .flatMap(({ path }) => longSentences(text(path), changed.get(path)).map(({ line, message }) => ({ path, line, message })))
119
- .toSorted(inPathOrder);
120
140
  const judging = (path: string): Judging => ({ commands: !speaksToConsumers(text(path)) });
121
141
  // A directory the range deletes still belongs to this repository, so a path under it is stale rather than another repository's.
122
142
  const roots = rootsOf(yield* pathsAt(base, [], root));
@@ -125,12 +145,11 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
125
145
  const vanished = yield* vanishedNames(root, base, head, referenced, references.failed);
126
146
  const agents = proseDocs.filter(({ path }) => isAgentFile(path));
127
147
  const tracked = snapshotOf(yield* pathsAt(head, [], root), new Map(), new Map());
128
- const shapes = agents.flatMap(({ path }) =>
129
- [ceilingFinding(text(path)), maintainingFinding(text(path))].flatMap((finding) => (finding === undefined ? [] : [{ path, ...finding }])),
130
- );
131
- const entries = agents.flatMap(({ path }) => entryFindings(path, text(path), tracked).map((finding) => ({ path, ...finding })));
148
+ const shapes = agents.flatMap(({ path }) => agentFileFindings(path, text(path), tracked));
132
149
  const readmes = proseDocs.filter(({ path }) => isCeiledReadme(path, tracked.files));
133
150
  const oversize = readmes.flatMap(({ path }) => readmeFindings(text(path)).map((finding) => ({ path, ...finding })));
151
+ const absent = missingAgentFiles(tracked.files);
152
+ const rootAgents = rootAgentFindings(absent, absent.length === 0 ? false : yield* rangeEditsDocs(root, base, changes, text));
134
153
  const advisory = new Map<string, number>();
135
154
  for (const { path } of templated.filter((finding) => !touched.has(finding.path))) advisory.set(path, (advisory.get(path) ?? 0) + 1);
136
155
  return {
@@ -139,6 +158,7 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
139
158
  named: referenced.size,
140
159
  agents: agents.length,
141
160
  readmes: readmes.filter(({ path }) => touched.has(path)).length,
161
+ hasManifest: hasManifest(tracked.files),
142
162
  findings: [
143
163
  ...templated.filter((finding) => touched.has(finding.path)),
144
164
  ...prose,
@@ -146,12 +166,15 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
146
166
  ...references.failing,
147
167
  ...vanished,
148
168
  ...shapes,
149
- ...entries,
150
169
  ...oversize.filter((finding) => touched.has(finding.path)),
170
+ ...rootAgents.failing,
151
171
  ].toSorted(inPathOrder),
152
172
  advisory,
153
173
  readmeAdvisory: oversize.filter((finding) => !touched.has(finding.path)).toSorted(inPathOrder),
154
- sentenceAdvisory,
174
+ agentFileAdvisory: rootAgents.advisory,
175
+ sentenceAdvisory: edited
176
+ .flatMap(({ path }) => longSentences(text(path), changed.get(path)).map(({ line, message }) => ({ path, line, message })))
177
+ .toSorted(inPathOrder),
155
178
  brokenBefore: references.brokenBefore.toSorted(inPathOrder),
156
179
  } satisfies Judged;
157
180
  });
@@ -160,7 +183,24 @@ function describe({ path, line, message }: Finding): string {
160
183
  return ` ${path}${line === undefined ? "" : `:${line}`}: ${message}`;
161
184
  }
162
185
 
163
- export function report({ held, edited, named, agents, readmes, findings, advisory, readmeAdvisory, sentenceAdvisory, brokenBefore }: Judged): string {
186
+ export function report({
187
+ held,
188
+ edited,
189
+ named,
190
+ agents,
191
+ readmes,
192
+ hasManifest: manifestHeld,
193
+ findings,
194
+ advisory,
195
+ readmeAdvisory,
196
+ agentFileAdvisory,
197
+ sentenceAdvisory,
198
+ brokenBefore,
199
+ }: Judged): string {
200
+ const rootAgentFiles =
201
+ manifestHeld && agentFileAdvisory.length === 0
202
+ ? [`${NAME}: the repository root holds the AGENTS.md and CLAUDE.md a repository with a \`package.json\` keeps`]
203
+ : [];
164
204
  const verdict =
165
205
  findings.length === 0
166
206
  ? [
@@ -170,6 +210,7 @@ export function report({ held, edited, named, agents, readmes, findings, advisor
170
210
  `${NAME}: the range breaks no path, link or command the ${named} living doc(s) or agent file(s) name`,
171
211
  `${NAME}: the ${agents} agent file(s) hold to the ceiling, and every entry names a tracked path, a link or a command`,
172
212
  `${NAME}: the ${readmes} README(s) the range edits hold to the 6,000-character ceiling and the 300-character table cell ceiling`,
213
+ ...rootAgentFiles,
173
214
  ]
174
215
  : [`${NAME}: ${findings.length} violation(s):`, ...findings.map(describe)];
175
216
  const unconformed =
@@ -183,6 +224,13 @@ export function report({ held, edited, named, agents, readmes, findings, advisor
183
224
  readmeAdvisory.length === 0
184
225
  ? []
185
226
  : [`${NAME}: advisory, ${readmeAdvisory.length} README ceiling finding(s) in README(s) the range leaves alone:`, ...readmeAdvisory.map(describe)];
227
+ const missing =
228
+ agentFileAdvisory.length === 0
229
+ ? []
230
+ : [
231
+ `${NAME}: advisory, ${agentFileAdvisory.length} agent file(s) missing from the repository root, which the first range that edits a doc adds:`,
232
+ ...agentFileAdvisory.map(describe),
233
+ ];
186
234
  const sentences =
187
235
  sentenceAdvisory.length === 0
188
236
  ? []
@@ -197,7 +245,7 @@ export function report({ held, edited, named, agents, readmes, findings, advisor
197
245
  `${NAME}: advisory, ${brokenBefore.length} path(s), link(s) or command(s) the living docs or agent files name were broken before the range:`,
198
246
  ...brokenBefore.map(describe),
199
247
  ];
200
- return [...verdict, ...unconformed, ...oversize, ...sentences, ...broken].join("\n");
248
+ return [...verdict, ...unconformed, ...oversize, ...missing, ...sentences, ...broken].join("\n");
201
249
  }
202
250
 
203
251
  const docs = Effect.gen(function* () {