@jterrazz/typescript 9.1.1 → 9.2.1

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/README.md CHANGED
@@ -11,14 +11,15 @@ npm install @jterrazz/typescript --save-dev
11
11
  ## The CLI
12
12
 
13
13
  ```bash
14
- npx typescript build # Build application (ESM + types)
15
- npx typescript bundle # Bundle library (ESM + CJS + types)
16
- npx typescript start # Run the built application
17
- npx typescript dev # Build, run, and rebuild on changes
18
- npx typescript docs # Compile the committed docs/reference tree from source
19
- npx typescript check # Type-check, lint, format-check, and unused-code in parallel
20
- npx typescript fix # Auto-fix lint and formatting issues
21
- npx typescript clean # Remove .artifacts/ (dist/ stays — it is the product)
14
+ npx typescript build # Build application (ESM + types)
15
+ npx typescript bundle # Bundle library (ESM + CJS + types)
16
+ npx typescript start # Run the built application
17
+ npx typescript dev # Build, run, and rebuild on changes
18
+ npx typescript docs # Compile the committed docs/reference tree from source
19
+ npx typescript docs-layout . # Check a repository's docs/ against the manual spine
20
+ npx typescript check # Type-check, lint, format-check, and unused-code in parallel
21
+ npx typescript fix # Auto-fix lint and formatting issues
22
+ npx typescript clean # Remove .artifacts/ (dist/ stays — it is the product)
22
23
  ```
23
24
 
24
25
  ## How it works
@@ -40,12 +41,15 @@ Fully compiled — no JavaScript in the hot path:
40
41
 
41
42
  The full corpus lives in [`docs/`](docs/):
42
43
 
43
- - [Getting started](docs/01-getting-started.md) — install and configure a project.
44
- - [Building](docs/02-building.md) — `build`, `bundle`, `start`, `dev`.
45
- - [Quality checks](docs/03-quality-checks.md) — `check` / `fix` and their passes.
46
- - [Lint presets](docs/04-lint-presets.md) — oxlint presets, `compose`, architecture, knip.
47
- - [Docs pipeline](docs/05-docs-pipeline.md) — the `typescript docs` compiler.
48
- - [Repo structure](docs/06-repo-structure.md) — pointer to the shared doctrine; what's TypeScript-specific here.
44
+ - [Architecture](docs/01-architecture.md) — the four layers and what each one holds.
45
+ - [Developing](docs/02-developing.md) — install and configure a project.
46
+ - [Testing](docs/03-testing.md) — how this toolchain proves itself.
47
+ - [Operating](docs/04-operating.md) — what publishes it, and which number moves.
48
+ - [Building](docs/05-building.md) — `build`, `bundle`, `start`, `dev`.
49
+ - [Quality checks](docs/06-quality-checks.md) — `check` / `fix` and their passes.
50
+ - [Lint presets](docs/07-lint-presets.md) — oxlint presets, `compose`, architecture, knip.
51
+ - [Docs pipeline](docs/08-docs-pipeline.md) — the `typescript docs` compiler.
52
+ - [Repo structure](docs/09-repo-structure.md) — pointer to the shared doctrine; what's TypeScript-specific here.
49
53
 
50
54
  For agents: read the chapters and the generated [`docs/reference/`](docs/reference/) tree straight from the repo, plus the [`skills/jterrazz-typescript`](skills/jterrazz-typescript/SKILL.md) Claude Code skill (the toolchain). The repo-structure doctrine itself is a Claude Code skill too — `jterrazz-repo-structure`, shipped from [`jterrazz-studio`](https://github.com/jterrazz/jterrazz-studio).
51
55
 
@@ -314,6 +314,21 @@ run_checks() {
314
314
  gitignore_pid=$!
315
315
  fi
316
316
 
317
+ # Docs (layout): the manual's shape — the map, the spine, the numbering, the
318
+ # three subfolders, the decision mold. Its unit is the REPOSITORY, not the
319
+ # package: a manual answers for the whole tree, and only its root carries the
320
+ # AGENTS.md that routes into it. So the pass asks its question exactly where a
321
+ # repository is — `.git` here, a file in a worktree and a directory in a clone.
322
+ # Nothing else gates it: a repository with NO docs/ is the population the rule
323
+ # exists for, and it fails on `docs-absent`. Check-only, like every read-only
324
+ # gate — there is no rewrite that can author a chapter.
325
+ local docs_layout_pid=""
326
+ local docs_layout_status=0
327
+ if [ "$FIX_MODE" = false ] && [ -e ".git" ]; then
328
+ node "$PACKAGE_ROOT/lib/check-docs.js" > "$tmp_dir/docs-layout.log" 2>&1 &
329
+ docs_layout_pid=$!
330
+ fi
331
+
317
332
  # Conventions checker: only in check mode, once per specs root the workspace
318
333
  # owns, gated by the package that OWNS that root — a member may depend on
319
334
  # @jterrazz/test while the root does not, and the reverse.
@@ -359,6 +374,7 @@ run_checks() {
359
374
  wait $format_pid; local format_status=$?
360
375
  [ -n "$knip_pid" ] && { wait $knip_pid; knip_status=$?; }
361
376
  [ -n "$gitignore_pid" ] && { wait $gitignore_pid; gitignore_status=$?; }
377
+ [ -n "$docs_layout_pid" ] && { wait $docs_layout_pid; docs_layout_status=$?; }
362
378
 
363
379
  # One pass, N runs: the pass fails if any run failed, and only the logs of
364
380
  # the runs that FAILED are printed — a green member stays silent.
@@ -447,6 +463,16 @@ run_checks() {
447
463
  fi
448
464
  fi
449
465
 
466
+ if [ -n "$docs_layout_pid" ]; then
467
+ printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Docs (layout)\n\n"
468
+ if [ $docs_layout_status -ne 0 ]; then
469
+ [ -s "$tmp_dir/docs-layout.log" ] && cat "$tmp_dir/docs-layout.log"
470
+ printf "${RED}✗ Failed with exit code %d${NC}\n" $docs_layout_status
471
+ else
472
+ printf "${GREEN}✓ Passed${NC}\n"
473
+ fi
474
+ fi
475
+
450
476
  if [ ${#docs_pids[@]} -gt 0 ]; then
451
477
  printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Docs (sync)\n\n"
452
478
  if [ $docs_status -ne 0 ]; then
@@ -467,7 +493,7 @@ run_checks() {
467
493
  printf "\n${CYAN_BG}${BRIGHT_WHITE} END ${NC} Finalizing quality checks\n\n"
468
494
  fi
469
495
 
470
- if [ $type_status -eq 0 ] && [ $lint_status -eq 0 ] && [ $format_status -eq 0 ] && [ $knip_status -eq 0 ] && [ $gitignore_status -eq 0 ] && [ $checker_status -eq 0 ] && [ $docs_status -eq 0 ]; then
496
+ if [ $type_status -eq 0 ] && [ $lint_status -eq 0 ] && [ $format_status -eq 0 ] && [ $knip_status -eq 0 ] && [ $gitignore_status -eq 0 ] && [ $checker_status -eq 0 ] && [ $docs_layout_status -eq 0 ] && [ $docs_status -eq 0 ]; then
471
497
  printf "${GREEN}✓ All checks passed${NC}\n"
472
498
  exit 0
473
499
  else
package/bin/typescript.sh CHANGED
@@ -160,6 +160,14 @@ case "$COMMAND" in
160
160
  fi
161
161
  ;;
162
162
 
163
+ docs-layout)
164
+ # The manual's shape, on its own — the same gate `check` runs as its
165
+ # Docs (layout) pass, reachable by a repository that has no npm project
166
+ # to hang it on (a Go, Rust or Ansible tree wiring it into `make lint`
167
+ # through npx). It takes the repository root and needs nothing installed.
168
+ exec node "$PACKAGE_ROOT/lib/check-docs.js" "${1:-$PROJECT_ROOT}"
169
+ ;;
170
+
163
171
  check|fix)
164
172
  exec bash "$SCRIPT_DIR/commands/check.sh" "$COMMAND" "$@"
165
173
  ;;
@@ -168,14 +176,15 @@ case "$COMMAND" in
168
176
  printf "${CYAN_BG}${BRIGHT_WHITE} TYPESCRIPT ${NC} TypeScript toolchain\n\n"
169
177
  printf "Usage: typescript <command>\n\n"
170
178
  printf "Commands:\n"
171
- printf " build Build application (ESM + types)\n"
172
- printf " bundle Bundle library (ESM + CJS + types)\n"
173
- printf " start Run the built application\n"
174
- printf " dev Build, run, and rebuild on changes\n"
175
- printf " docs Generate the committed docs/reference tree; --check verifies sync\n"
176
- printf " check Check types, lint, formatting, and unused code\n"
177
- printf " fix Auto-fix lint and formatting issues\n"
178
- printf " clean Remove .artifacts/ dist/ stays, it is the build's product\n\n"
179
+ printf " build Build application (ESM + types)\n"
180
+ printf " bundle Bundle library (ESM + CJS + types)\n"
181
+ printf " start Run the built application\n"
182
+ printf " dev Build, run, and rebuild on changes\n"
183
+ printf " docs Generate the committed docs/reference tree; --check verifies sync\n"
184
+ printf " docs-layout Check a repository's docs/ against the manual spine\n"
185
+ printf " check Check types, lint, formatting, and unused code\n"
186
+ printf " fix Auto-fix lint and formatting issues\n"
187
+ printf " clean Remove .artifacts/ — dist/ stays, it is the build's product\n\n"
179
188
  printf "Examples:\n"
180
189
  printf " typescript build\n"
181
190
  printf " typescript bundle\n"
@@ -183,6 +192,7 @@ case "$COMMAND" in
183
192
  printf " typescript dev\n"
184
193
  printf " typescript docs\n"
185
194
  printf " typescript docs --check\n"
195
+ printf " typescript docs-layout .\n"
186
196
  printf " typescript check\n"
187
197
  printf " typescript fix\n"
188
198
  printf " typescript clean\n"
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * The shape of a repository's manual, read off the filesystem and judged by
5
+ * `@jterrazz/typescript/docs`.
6
+ *
7
+ * Every repository carries the same `docs/`: a map, the fixed spine —
8
+ * architecture, developing, testing, and operating when the repository ships
9
+ * something that runs — and its own chapters numbered contiguously after it.
10
+ * This file is the READER: it builds the plain tree the rule engine takes
11
+ * (`src/docs.js`) and prints what comes back. Every sentence, every rule id and
12
+ * the journal-word roster live in the engine, so a second reader — terra,
13
+ * sweeping clones it never installed — judges by the same copy.
14
+ *
15
+ * Usage: node check-docs.js [root]
16
+ *
17
+ * One line per violation, `<rule> <path> <message>`. Exit code: 0 when the
18
+ * repository holds the shape, 1 otherwise.
19
+ *
20
+ * The unit is the REPOSITORY, not the package: a manual answers for the whole
21
+ * tree, and only the root carries the `AGENTS.md` that routes into it. The one
22
+ * place the workspace shows through is the `04-operating.md` presence test,
23
+ * whose Dockerfile and `.infrastructure/` clauses are read at the root AND at
24
+ * every workspace member's root — a monorepo deploys from a member as readily
25
+ * as from its root (ADR-007, the toolchain's unit is the workspace package).
26
+ * The publishable clause stays on the ROOT manifest: a private root that holds
27
+ * a publishable member is a question for its owner, not a verdict for a gate.
28
+ */
29
+
30
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
31
+ import { join, resolve } from 'node:path';
32
+ import { argv, exit, stdout } from 'node:process';
33
+
34
+ import { auditDocs, HEAD_LINES } from '../src/docs.js';
35
+ import { workspaceMembers } from './workspace-members.js';
36
+
37
+ /** Every markdown link target of a page, in the order the page carries them. */
38
+ const LINK = /!?\[[^\]]*]\(\s*(?<target>[^\s)]+)/g;
39
+
40
+ /** Where a repository declares a deployment it owns. */
41
+ const INFRASTRUCTURE = '.infrastructure';
42
+
43
+ /**
44
+ * Every path under `docs/`, repository-relative, directories carrying a
45
+ * trailing slash. `docs/` itself is the first entry when it exists — its
46
+ * absence is the whole verdict.
47
+ */
48
+ function listDocs(root) {
49
+ const paths = [];
50
+
51
+ /** One directory, depth-first, sorted — a listing a machine can diff. */
52
+ function walk(relativePath) {
53
+ paths.push(`${relativePath}/`);
54
+
55
+ const entries = readdirSync(join(root, relativePath), { withFileTypes: true });
56
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
57
+ const child = `${relativePath}/${entry.name}`;
58
+ if (entry.isDirectory()) {
59
+ walk(child);
60
+ } else {
61
+ paths.push(child);
62
+ }
63
+ }
64
+ }
65
+
66
+ if (existsSync(join(root, 'docs'))) {
67
+ walk('docs');
68
+ }
69
+
70
+ return paths;
71
+ }
72
+
73
+ /** A file's text, or null when it is not there — an unreadable file is an absent one. */
74
+ function read(path) {
75
+ try {
76
+ return readFileSync(path, 'utf8');
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /** The head and the links of every markdown page of the tree. */
83
+ function readPages(root, files) {
84
+ const heads = {};
85
+ const links = {};
86
+
87
+ for (const path of files) {
88
+ if (!path.endsWith('.md')) {
89
+ continue;
90
+ }
91
+
92
+ const text = read(join(root, path)) ?? '';
93
+ heads[path] = text.split('\n').slice(0, HEAD_LINES);
94
+ links[path] = [...text.matchAll(LINK)].map((match) => match.groups.target);
95
+ }
96
+
97
+ return { heads, links };
98
+ }
99
+
100
+ /** A `Dockerfile`, or one of its variants, in a directory. */
101
+ function hasDockerfile(dir) {
102
+ try {
103
+ return readdirSync(dir).some(
104
+ (name) => name === 'Dockerfile' || name.startsWith('Dockerfile.'),
105
+ );
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /** A manifest that npm would publish — one that never says it is private. */
112
+ function isPublishable(dir) {
113
+ const manifest = read(join(dir, 'package.json'));
114
+ if (manifest === null) {
115
+ return false;
116
+ }
117
+
118
+ try {
119
+ return JSON.parse(manifest).private !== true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ /** The three facts `04-operating.md` is asked for, read at the root and its members. */
126
+ function readShips(root) {
127
+ const roots = [root, ...workspaceMembers(root).map((member) => join(root, member))];
128
+
129
+ return {
130
+ dockerfile: roots.some((dir) => hasDockerfile(dir)),
131
+ infrastructure: roots.some((dir) => existsSync(join(dir, INFRASTRUCTURE))),
132
+ publishable: isPublishable(root),
133
+ };
134
+ }
135
+
136
+ const root = resolve(argv.slice(2).find((argument) => !argument.startsWith('--')) ?? '.');
137
+ const files = listDocs(root);
138
+
139
+ const violations = auditDocs({
140
+ agents: read(join(root, 'AGENTS.md')),
141
+ files,
142
+ ...readPages(root, files),
143
+ ships: readShips(root),
144
+ });
145
+
146
+ for (const violation of violations) {
147
+ stdout.write(`${violation.rule} ${violation.path} ${violation.message}\n`);
148
+ }
149
+
150
+ exit(violations.length > 0 ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/typescript",
3
- "version": "9.1.1",
3
+ "version": "9.2.1",
4
4
  "author": "Jean-Baptiste Terrazzoni <contact@jterrazz.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,6 +23,10 @@
23
23
  "types": "./src/index.d.ts",
24
24
  "default": "./src/index.js"
25
25
  },
26
+ "./docs": {
27
+ "types": "./src/docs.d.ts",
28
+ "default": "./src/docs.js"
29
+ },
26
30
  "./oxfmt": {
27
31
  "types": "./src/oxfmt.d.ts",
28
32
  "default": "./src/oxfmt.js"
package/src/docs.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ /** The `docs/` tree of one repository, as a rule engine needs to see it. */
2
+ export interface DocsTree {
3
+ /** The root `AGENTS.md`, its whole text, or `null` when the repository has none. */
4
+ readonly agents: null | string;
5
+ /**
6
+ * Every path under `docs/`, repository-relative, sorted, directories carrying
7
+ * a trailing slash — `docs/` itself included, whose absence IS `docs-absent`.
8
+ */
9
+ readonly files: readonly string[];
10
+ /**
11
+ * The opening lines of each file a rule reads — a decision record's head, a
12
+ * reference page's generation stamp. The reader supplies the first
13
+ * `HEAD_LINES` of every markdown file under `docs/`; no rule looks further
14
+ * down a page, so a `**Status:**` below that line is a status the manual
15
+ * does not declare.
16
+ */
17
+ readonly heads: Readonly<Record<string, readonly string[]>>;
18
+ /** Every markdown link target of each file under `docs/`, in the order the page carries them. */
19
+ readonly links: Readonly<Record<string, readonly string[]>>;
20
+ /** The three facts the `04-operating.md` presence test is derived from. */
21
+ readonly ships: {
22
+ /** A `Dockerfile` at the repository root or at a workspace member's. */
23
+ readonly dockerfile: boolean;
24
+ /** An `.infrastructure/` directory at either. */
25
+ readonly infrastructure: boolean;
26
+ /** A `package.json` at either that is not `"private": true`. */
27
+ readonly publishable: boolean;
28
+ };
29
+ }
30
+
31
+ /** One broken rule, naming the path it is about and the sentence the gate prints. */
32
+ export interface DocsViolation {
33
+ readonly message: string;
34
+ readonly path: string;
35
+ readonly rule: string;
36
+ }
37
+
38
+ /** How many opening lines of a file a rule may read. */
39
+ export declare const HEAD_LINES: number;
40
+
41
+ /** What breaks the shape of `docs/`, in rule order. An empty array is a compliant manual. */
42
+ export declare function auditDocs(tree: DocsTree): readonly DocsViolation[];
package/src/docs.js ADDED
@@ -0,0 +1,446 @@
1
+ /**
2
+ * The shape of a repository's `docs/`, judged as a pure function.
3
+ *
4
+ * Every repository carries the same manual: a map at `docs/README.md`, a fixed
5
+ * spine — architecture, developing, testing, and operating when the repository
6
+ * ships something that runs — and its own chapters numbered contiguously after
7
+ * it. `auditDocs` takes a plain description of that tree and returns what
8
+ * breaks the shape. Nothing here reads the filesystem, so the same rules judge
9
+ * a project running `typescript check` and a clone nobody installed anything
10
+ * into.
11
+ *
12
+ * This module is the single executable copy of the rule ids AND of the sentence
13
+ * each one prints. A page names the ids and what each refuses; it never spells
14
+ * a message, so the two cannot drift.
15
+ */
16
+
17
+ /** How many opening lines of a file a rule may read — the reader supplies them. */
18
+ export const HEAD_LINES = 20;
19
+
20
+ /** The four reserved numbers, in order: position 01 through 04. */
21
+ const SPINE = ['01-architecture.md', '02-developing.md', '03-testing.md', '04-operating.md'];
22
+
23
+ /** The three the spine REQUIRES; 04 is required only by the presence test. */
24
+ const REQUIRED_SPINE = SPINE.slice(0, 3);
25
+
26
+ /** The only subfolders `docs/` may hold. */
27
+ const SUBFOLDERS = new Set(['_assets/', 'decisions/', 'reference/']);
28
+
29
+ /**
30
+ * The closed roster of journal words. A chapter names a SUBJECT the repository
31
+ * has, never the state of a piece of work: the record of a decision is an ADR,
32
+ * and the history is git's. This is the roster's one copy — no page keeps a
33
+ * second one.
34
+ */
35
+ const JOURNAL_WORDS = new Set([
36
+ 'draft',
37
+ 'exploration',
38
+ 'legacy',
39
+ 'misc',
40
+ 'notes',
41
+ 'old',
42
+ 'proposal',
43
+ 'review',
44
+ 'todo',
45
+ 'wip',
46
+ ]);
47
+
48
+ /** The closed status vocabulary of a decision record. */
49
+ const STATUSES = new Set(['Proposed', 'Accepted', 'Deprecated']);
50
+
51
+ /**
52
+ * The fourth status carries the record that replaced it, as a link a reader
53
+ * can follow — a citation is a place a human can look, not just a number.
54
+ */
55
+ const SUPERSEDED = /^Superseded by \[ADR-\d{3}\]\([^)]+\)$/;
56
+
57
+ /** The same status named but not linked — the successor exists, the citation does not. */
58
+ const BARE_SUPERSEDED = /^Superseded by ADR-\d{3}$/;
59
+
60
+ /** What a chapter's file name must be: two digits, lowercase words, single hyphens. */
61
+ const CHAPTER_NAME = /^\d{2}-[a-z\d]+(?:-[a-z\d]+)*\.md$/;
62
+
63
+ /** What a decision record's file name must be: three digits, then the same words. */
64
+ const DECISION_NAME = /^\d{3}-[a-z\d]+(?:-[a-z\d]+)*\.md$/;
65
+
66
+ /** A decision record's first heading, carrying the number the file claims. */
67
+ const DECISION_HEADING = /^# ADR-(?<number>\d{3}): \S/;
68
+
69
+ /** The `**Status:**` line of a decision record, wherever it sits in the head. */
70
+ const DECISION_STATUS = /^\*\*Status:\*\*\s*(?<status>.+?)\s*$/;
71
+
72
+ /** The marker every file under `reference/` carries — it is generated, never authored. */
73
+ const GENERATED = 'GENERATED';
74
+
75
+ /** A link with a scheme (`https:`, `mailto:`) cites; it never reaches into a tree. */
76
+ const SCHEME = /^[a-z][\d+.a-z-]*:/i;
77
+
78
+ /** What each of the three presence facts means, in the sentence `04` is asked for. */
79
+ const SHIPPING_REASONS = [
80
+ ['dockerfile', 'an image (a Dockerfile)'],
81
+ ['infrastructure', 'a deployment (.infrastructure/)'],
82
+ ['publishable', 'a published package (package.json is not private)'],
83
+ ];
84
+
85
+ /** Directly under `docs/` — a file, or a directory with its trailing slash. */
86
+ function directChildren(files) {
87
+ return files.filter((path) => /^docs\/[^/]+\/?$/.test(path));
88
+ }
89
+
90
+ /** Everything under a folder of `docs/`, named relative to that folder. */
91
+ function under(files, folder) {
92
+ const prefix = `docs/${folder}`;
93
+
94
+ return files
95
+ .filter((path) => path.startsWith(prefix) && !path.endsWith('/'))
96
+ .map((path) => ({ name: path.slice(prefix.length), path }));
97
+ }
98
+
99
+ /** A number as a chapter writes it — two digits, more only when it has to. */
100
+ function padded(value) {
101
+ return String(value).padStart(2, '0');
102
+ }
103
+
104
+ /** The link target itself, without the anchor a reader lands on. */
105
+ function targetPath(link) {
106
+ return link.split('#')[0].replace(/^\.\//, '');
107
+ }
108
+
109
+ /** A link that names a chapter of the same folder — `03-testing.md`, no slash. */
110
+ function isChapterLink(target) {
111
+ return /^\d/.test(target) && !target.includes('/');
112
+ }
113
+
114
+ /**
115
+ * A link resolved against the page carrying it, `..` folded. A result that
116
+ * still opens on `..` climbed above the repository root: it reaches into
117
+ * another tree.
118
+ */
119
+ function resolveLink(from, link) {
120
+ const stack = [];
121
+
122
+ for (const segment of [...from.split('/').slice(0, -1), ...link.split('/')]) {
123
+ if (segment === '' || segment === '.') {
124
+ continue;
125
+ }
126
+ if (segment !== '..') {
127
+ stack.push(segment);
128
+ } else if (stack.length === 0 || stack.at(-1) === '..') {
129
+ stack.push('..');
130
+ } else {
131
+ stack.pop();
132
+ }
133
+ }
134
+
135
+ return stack.join('/');
136
+ }
137
+
138
+ /** The map's verdicts: it exists, it is bijective with the chapters, it routes nowhere else. */
139
+ function auditMap(report, { chapters, files, links }) {
140
+ if (!files.includes('docs/README.md')) {
141
+ report(
142
+ 'docs-map-missing',
143
+ 'docs/README.md',
144
+ 'docs/README.md is missing — the map is the one file an outside corpus points at',
145
+ );
146
+
147
+ return;
148
+ }
149
+
150
+ const rows = [...new Set(links.map((link) => targetPath(link)).filter(isChapterLink))];
151
+
152
+ for (const row of rows) {
153
+ if (!chapters.some((chapter) => chapter.name === row)) {
154
+ report(
155
+ 'docs-map-drift',
156
+ 'docs/README.md',
157
+ `docs/README.md and the chapters disagree: ${row} has no file`,
158
+ );
159
+ }
160
+ }
161
+ for (const chapter of chapters) {
162
+ if (!rows.includes(chapter.name)) {
163
+ report(
164
+ 'docs-map-drift',
165
+ 'docs/README.md',
166
+ `docs/README.md and the chapters disagree: ${chapter.name} has no row`,
167
+ );
168
+ }
169
+ }
170
+
171
+ for (const link of links) {
172
+ const target = targetPath(link);
173
+ const routes =
174
+ target === '' ||
175
+ isChapterLink(target) ||
176
+ target.startsWith('decisions/') ||
177
+ target.startsWith('reference/');
178
+
179
+ if (!routes) {
180
+ report(
181
+ 'docs-map-foreign-link',
182
+ 'docs/README.md',
183
+ `docs/README.md links ${link}: the map routes to its own chapters and nothing else`,
184
+ );
185
+ }
186
+ }
187
+ }
188
+
189
+ /** The chapters: their names, their numbering, the four reserved positions, their subjects. */
190
+ function auditChapters(report, { chapters, ships }) {
191
+ for (const chapter of chapters) {
192
+ if (!CHAPTER_NAME.test(chapter.name)) {
193
+ report(
194
+ 'docs-chapter-name',
195
+ chapter.path,
196
+ `${chapter.path} is not NN-kebab.md — two digits, lowercase words, single hyphens`,
197
+ );
198
+ }
199
+ }
200
+
201
+ const numbers = chapters.map((chapter) => chapter.number).sort((a, b) => a - b);
202
+ const hasOperating = numbers.includes(4);
203
+ // 04 is the one number the spine never requires (`docs-operating-missing`
204
+ // Asks for it on its own terms), so a run missing it is still contiguous —
205
+ // Every number from 05 on shifts down one slot to close the gap.
206
+ const expected = (index) => (!hasOperating && index + 1 >= 4 ? index + 2 : index + 1);
207
+ const contiguous = numbers.every((number, index) => number === expected(index));
208
+ if (numbers.length > 0 && !contiguous) {
209
+ report(
210
+ 'docs-chapter-numbering',
211
+ 'docs/',
212
+ `chapter numbers run ${numbers.map(padded).join(', ')}: they are contiguous from 01, one file per number, except that 04 may be absent`,
213
+ );
214
+ }
215
+
216
+ for (const chapter of chapters) {
217
+ const reserved = SPINE[chapter.number - 1];
218
+ if (reserved !== undefined && chapter.name !== reserved) {
219
+ report(
220
+ 'docs-spine-name',
221
+ chapter.path,
222
+ `${chapter.path} takes number ${padded(chapter.number)}, which is reserved for ${reserved}`,
223
+ );
224
+ }
225
+ }
226
+
227
+ for (const name of REQUIRED_SPINE) {
228
+ if (!chapters.some((chapter) => chapter.name === name)) {
229
+ report(
230
+ 'docs-spine-missing',
231
+ `docs/${name}`,
232
+ `${name} is missing — the spine is architecture, developing, testing`,
233
+ );
234
+ }
235
+ }
236
+
237
+ const operating = SPINE[3];
238
+ const reason = SHIPPING_REASONS.find(([fact]) => ships?.[fact])?.[1];
239
+ if (reason !== undefined && !chapters.some((chapter) => chapter.name === operating)) {
240
+ report(
241
+ 'docs-operating-missing',
242
+ `docs/${operating}`,
243
+ `${operating} is missing — this repository ships ${reason}`,
244
+ );
245
+ }
246
+
247
+ for (const chapter of chapters) {
248
+ const word = chapter.name.split(/[.-]/).find((segment) => JOURNAL_WORDS.has(segment));
249
+ if (word !== undefined) {
250
+ report(
251
+ 'docs-journal-chapter',
252
+ chapter.path,
253
+ `${chapter.path} names a journal, not a subject — ${word}; the record is an ADR, the history is git's`,
254
+ );
255
+ }
256
+ }
257
+ }
258
+
259
+ /** What sits directly under `docs/` and is neither the map, a chapter, nor one of the three folders. */
260
+ function auditFolder(report, { children }) {
261
+ for (const path of children) {
262
+ const name = path.slice('docs/'.length);
263
+
264
+ if (name.endsWith('/')) {
265
+ if (!SUBFOLDERS.has(name)) {
266
+ report(
267
+ 'docs-foreign-folder',
268
+ path,
269
+ `${path} is not one of decisions/, reference/, _assets/`,
270
+ );
271
+ }
272
+ } else if (name !== 'README.md' && !/^\d/.test(name)) {
273
+ report(
274
+ 'docs-loose-file',
275
+ path,
276
+ `${path} is neither the map nor a chapter — number it, or move it under _assets/`,
277
+ );
278
+ }
279
+ }
280
+ }
281
+
282
+ /** One decision record: the name it takes, the heading it opens on, the status it declares. */
283
+ function auditRecord(report, record, heads) {
284
+ const number = record.name.slice(0, 3);
285
+ const head = heads[record.path] ?? [];
286
+ const opening = head[0] ?? '';
287
+
288
+ if (DECISION_HEADING.exec(opening)?.groups?.number !== number) {
289
+ report(
290
+ 'docs-decision-heading',
291
+ record.path,
292
+ `${record.path} opens on ${opening === '' ? 'an empty line' : opening}: the mold is "# ADR-${number}: Title"`,
293
+ );
294
+ }
295
+
296
+ const status = head.map((line) => DECISION_STATUS.exec(line)?.groups?.status).find(Boolean);
297
+ if (status === undefined || !(STATUSES.has(status) || SUPERSEDED.test(status))) {
298
+ const message =
299
+ status !== undefined && BARE_SUPERSEDED.test(status)
300
+ ? `${record.path}: **Status:** names a successor but no link — write Superseded by [ADR-NNN](file.md)`
301
+ : `${record.path}: **Status:** is none of Proposed, Accepted, Superseded by [ADR-NNN](file.md), Deprecated`;
302
+ report('docs-decision-status', record.path, message);
303
+ }
304
+ }
305
+
306
+ /** The `decisions/` folder: its records, its numbering, its mold, and the index it never keeps. */
307
+ function auditDecisions(report, { files, heads }) {
308
+ if (!files.includes('docs/decisions/')) {
309
+ return;
310
+ }
311
+
312
+ const entries = under(files, 'decisions/');
313
+
314
+ for (const entry of entries) {
315
+ if (entry.name !== '_template.md' && entry.name !== 'README.md') {
316
+ if (!DECISION_NAME.test(entry.name)) {
317
+ report('docs-decision-name', entry.path, `${entry.path} is not NNN-kebab.md`);
318
+ }
319
+ }
320
+ }
321
+
322
+ const records = entries.filter((entry) => /^\d{3}-/.test(entry.name));
323
+ for (const record of records) {
324
+ auditRecord(report, record, heads);
325
+ }
326
+
327
+ const claimed = new Map();
328
+ for (const record of records) {
329
+ const number = record.name.slice(0, 3);
330
+ const first = claimed.get(number);
331
+
332
+ if (first === undefined) {
333
+ claimed.set(number, record.name);
334
+ } else {
335
+ report(
336
+ 'docs-decision-number',
337
+ 'docs/decisions/',
338
+ `ADR-${number} is claimed by ${first} and ${record.name}`,
339
+ );
340
+ }
341
+ }
342
+
343
+ if (files.includes('docs/decisions/README.md')) {
344
+ report(
345
+ 'docs-decision-index',
346
+ 'docs/decisions/README.md',
347
+ 'docs/decisions/README.md is an index — an index is a copy; the queue is derived (terra <brand> decisions)',
348
+ );
349
+ }
350
+
351
+ if (!files.includes('docs/decisions/_template.md')) {
352
+ report(
353
+ 'docs-template-missing',
354
+ 'docs/decisions/_template.md',
355
+ 'docs/decisions/_template.md is missing — the mold sits where the record is written',
356
+ );
357
+ }
358
+ }
359
+
360
+ /** Every page under `reference/` says so on its first line: it is a projection. */
361
+ function auditReference(report, { files, heads }) {
362
+ for (const entry of under(files, 'reference/')) {
363
+ if (!entry.name.endsWith('.md')) {
364
+ continue;
365
+ }
366
+ if (!(heads[entry.path]?.[0] ?? '').includes(GENERATED)) {
367
+ report(
368
+ 'docs-reference-unstamped',
369
+ entry.path,
370
+ `${entry.path} carries no ${GENERATED} marker — reference/ is a projection, never authored`,
371
+ );
372
+ }
373
+ }
374
+ }
375
+
376
+ /** No page of the manual reaches into a tree the repository does not own. */
377
+ function auditLinks(report, { files, links }) {
378
+ for (const path of files) {
379
+ for (const link of links[path] ?? []) {
380
+ if (link.startsWith('#') || SCHEME.test(link)) {
381
+ continue;
382
+ }
383
+ if (link.startsWith('/') || resolveLink(path, link).startsWith('..')) {
384
+ report(
385
+ 'docs-cross-repo-link',
386
+ path,
387
+ `${path} links ${link}: another repository's tree is not a place this manual may reach`,
388
+ );
389
+ }
390
+ }
391
+ }
392
+ }
393
+
394
+ /**
395
+ * What breaks the shape of `docs/`, in rule order, each violation naming the
396
+ * path it is about. An empty array is a compliant manual.
397
+ *
398
+ * A repository with no `docs/` gets exactly one violation: every other rule
399
+ * would restate the same absence, and one sentence is the whole answer.
400
+ */
401
+ export function auditDocs(tree) {
402
+ const files = tree.files ?? [];
403
+
404
+ if (!files.includes('docs/')) {
405
+ return [
406
+ {
407
+ message:
408
+ 'docs/ is missing — every repository carries its own manual, starting at docs/README.md',
409
+ path: 'docs/',
410
+ rule: 'docs-absent',
411
+ },
412
+ ];
413
+ }
414
+
415
+ const violations = [];
416
+ const report = (rule, path, message) => violations.push({ message, path, rule });
417
+
418
+ const children = directChildren(files);
419
+ const chapters = children
420
+ .filter((path) => !path.endsWith('/') && /^\d/.test(path.slice('docs/'.length)))
421
+ .map((path) => ({
422
+ name: path.slice('docs/'.length),
423
+ number: Number.parseInt(path.slice('docs/'.length), 10),
424
+ path,
425
+ }));
426
+ const heads = tree.heads ?? {};
427
+ const links = tree.links ?? {};
428
+
429
+ auditMap(report, { chapters, files, links: links['docs/README.md'] ?? [] });
430
+ auditChapters(report, { chapters, ships: tree.ships });
431
+ auditFolder(report, { children });
432
+ auditDecisions(report, { files, heads });
433
+ auditReference(report, { files, heads });
434
+
435
+ if (tree.agents === null || !(tree.agents ?? '').includes('docs/README.md')) {
436
+ report(
437
+ 'docs-agents-route',
438
+ 'AGENTS.md',
439
+ 'AGENTS.md does not route to docs/README.md — the brief is a map, not a manual',
440
+ );
441
+ }
442
+
443
+ auditLinks(report, { files, links });
444
+
445
+ return violations;
446
+ }
@@ -0,0 +1,524 @@
1
+ import { expect, test } from 'vitest';
2
+
3
+ import { auditDocs } from './docs.js';
4
+
5
+ /**
6
+ * The manual every repository carries, in its smallest compliant form: a map,
7
+ * the three required spine chapters, and a brief that routes into it. Each test
8
+ * breaks exactly one thing and reads the rule that names it.
9
+ */
10
+ function manual(overrides: Record<string, unknown> = {}) {
11
+ return {
12
+ agents: '# Agent brief\n\nThe corpus is docs/README.md.\n',
13
+ files: [
14
+ 'docs/',
15
+ 'docs/01-architecture.md',
16
+ 'docs/02-developing.md',
17
+ 'docs/03-testing.md',
18
+ 'docs/README.md',
19
+ ],
20
+ heads: {},
21
+ links: {
22
+ 'docs/README.md': ['01-architecture.md', '02-developing.md', '03-testing.md'],
23
+ },
24
+ ships: { dockerfile: false, infrastructure: false, publishable: false },
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ /** The rule ids a tree breaks, in the order the engine reports them. */
30
+ function rules(tree: ReturnType<typeof manual>) {
31
+ return auditDocs(tree as never).map((violation) => violation.rule);
32
+ }
33
+
34
+ /** The sentence one rule printed about a tree. */
35
+ function sentence(tree: ReturnType<typeof manual>, rule: string) {
36
+ return auditDocs(tree as never).find((violation) => violation.rule === rule)?.message;
37
+ }
38
+
39
+ test('passes the smallest compliant manual', () => {
40
+ // Given - a map, the three spine chapters, a brief that routes into them
41
+ // Then - nothing to say
42
+ expect(auditDocs(manual() as never)).toEqual([]);
43
+ });
44
+
45
+ test('names a repository that carries no manual at all, and says nothing else', () => {
46
+ // Given - a repository with no docs/ directory
47
+ const tree = manual({ agents: null, files: [] });
48
+
49
+ // Then - one sentence, not a cascade of every rule the absence breaks
50
+ expect(rules(tree)).toEqual(['docs-absent']);
51
+ expect(sentence(tree, 'docs-absent')).toBe(
52
+ 'docs/ is missing — every repository carries its own manual, starting at docs/README.md',
53
+ );
54
+ });
55
+
56
+ test('names a docs/ with no map', () => {
57
+ // Given - chapters with no README.md to route into them
58
+ const tree = manual({
59
+ files: ['docs/', 'docs/01-architecture.md', 'docs/02-developing.md', 'docs/03-testing.md'],
60
+ links: {},
61
+ });
62
+
63
+ // Then - the map is the one file an outside corpus points at
64
+ expect(rules(tree)).toEqual(['docs-map-missing']);
65
+ expect(sentence(tree, 'docs-map-missing')).toBe(
66
+ 'docs/README.md is missing — the map is the one file an outside corpus points at',
67
+ );
68
+ });
69
+
70
+ test('names a map that routes to a chapter nobody wrote', () => {
71
+ // Given - a row for a fourth chapter that does not exist
72
+ const tree = manual({
73
+ links: {
74
+ 'docs/README.md': [
75
+ '01-architecture.md',
76
+ '02-developing.md',
77
+ '03-testing.md',
78
+ '05-presets.md',
79
+ ],
80
+ },
81
+ });
82
+
83
+ // Then - the drift is named from the map's side
84
+ expect(sentence(tree, 'docs-map-drift')).toBe(
85
+ 'docs/README.md and the chapters disagree: 05-presets.md has no file',
86
+ );
87
+ });
88
+
89
+ test('names a chapter the map forgot', () => {
90
+ // Given - a fourth chapter with no row
91
+ const tree = manual({
92
+ files: [...manual().files, 'docs/04-operating.md'],
93
+ });
94
+
95
+ // Then - the drift is named from the chapter's side
96
+ expect(sentence(tree, 'docs-map-drift')).toBe(
97
+ 'docs/README.md and the chapters disagree: 04-operating.md has no row',
98
+ );
99
+ });
100
+
101
+ test('names a map link that leaves the manual', () => {
102
+ // Given - a map routing to the repository's own README
103
+ const tree = manual({
104
+ links: { 'docs/README.md': [...manual().links['docs/README.md'], '../README.md'] },
105
+ });
106
+
107
+ // Then - the map routes to chapters, decisions/ and reference/, and nothing else
108
+ expect(sentence(tree, 'docs-map-foreign-link')).toBe(
109
+ 'docs/README.md links ../README.md: the map routes to its own chapters and nothing else',
110
+ );
111
+ });
112
+
113
+ test('lets the map route to decisions/ and reference/', () => {
114
+ // Given - the two folders a map may point at
115
+ const tree = manual({
116
+ links: {
117
+ 'docs/README.md': [
118
+ ...manual().links['docs/README.md'],
119
+ 'decisions/',
120
+ 'reference/index.md',
121
+ ],
122
+ },
123
+ });
124
+
125
+ // Then - neither is foreign
126
+ expect(rules(tree)).toEqual([]);
127
+ });
128
+
129
+ test('names a chapter that is not NN-kebab.md', () => {
130
+ // Given - a fourth chapter with one digit and an upper-case word
131
+ const tree = manual({
132
+ files: [...manual().files, 'docs/4-Lint_presets.md'],
133
+ links: { 'docs/README.md': [...manual().links['docs/README.md'], '4-Lint_presets.md'] },
134
+ });
135
+
136
+ // Then - the mold is named, and the number it took is still judged
137
+ expect(sentence(tree, 'docs-chapter-name')).toBe(
138
+ 'docs/4-Lint_presets.md is not NN-kebab.md — two digits, lowercase words, single hyphens',
139
+ );
140
+ });
141
+
142
+ test('names chapter numbers that skip, and numbers claimed twice', () => {
143
+ // Given - two chapters numbered 05 and none numbered 04
144
+ const tree = manual({
145
+ files: [...manual().files, 'docs/05-building.md', 'docs/05-presets.md'],
146
+ links: {
147
+ 'docs/README.md': [
148
+ ...manual().links['docs/README.md'],
149
+ '05-building.md',
150
+ '05-presets.md',
151
+ ],
152
+ },
153
+ });
154
+
155
+ // Then - one sentence about the whole run
156
+ expect(sentence(tree, 'docs-chapter-numbering')).toBe(
157
+ 'chapter numbers run 01, 02, 03, 05, 05: they are contiguous from 01, one file per number, except that 04 may be absent',
158
+ );
159
+ });
160
+
161
+ test('names a chapter sitting on a reserved number', () => {
162
+ // Given - a getting-started chapter taking position 01
163
+ const tree = manual({
164
+ files: ['docs/', 'docs/01-getting-started.md', 'docs/README.md'],
165
+ links: { 'docs/README.md': ['01-getting-started.md'] },
166
+ });
167
+
168
+ // Then - the position names its owner
169
+ expect(sentence(tree, 'docs-spine-name')).toBe(
170
+ 'docs/01-getting-started.md takes number 01, which is reserved for 01-architecture.md',
171
+ );
172
+ });
173
+
174
+ test('names each missing spine chapter', () => {
175
+ // Given - a manual holding architecture alone
176
+ const tree = manual({
177
+ files: ['docs/', 'docs/01-architecture.md', 'docs/README.md'],
178
+ links: { 'docs/README.md': ['01-architecture.md'] },
179
+ });
180
+
181
+ // Then - developing and testing are each asked for
182
+ expect(rules(tree)).toEqual(['docs-spine-missing', 'docs-spine-missing']);
183
+ expect(sentence(tree, 'docs-spine-missing')).toBe(
184
+ '02-developing.md is missing — the spine is architecture, developing, testing',
185
+ );
186
+ });
187
+
188
+ test('asks for 04-operating.md from a repository that ships an image', () => {
189
+ // Given - a Dockerfile at the root or at a workspace member's root
190
+ const tree = manual({ ships: { dockerfile: true, infrastructure: false, publishable: false } });
191
+
192
+ // Then - the reason it is asked for is named
193
+ expect(sentence(tree, 'docs-operating-missing')).toBe(
194
+ '04-operating.md is missing — this repository ships an image (a Dockerfile)',
195
+ );
196
+ });
197
+
198
+ test('asks for 04-operating.md from a published package', () => {
199
+ // Given - a manifest that never says it is private
200
+ const tree = manual({ ships: { dockerfile: false, infrastructure: false, publishable: true } });
201
+
202
+ // Then - the third clause of the presence test speaks
203
+ expect(sentence(tree, 'docs-operating-missing')).toBe(
204
+ '04-operating.md is missing — this repository ships a published package (package.json is not private)',
205
+ );
206
+ });
207
+
208
+ test('never asks for 04-operating.md from a repository that ships nothing', () => {
209
+ // Given - no image, no infrastructure, a private manifest
210
+ // Then - the lint only ever requires; it never forbids a 04 either
211
+ expect(rules(manual())).toEqual([]);
212
+ });
213
+
214
+ test('lets 05 follow 03 directly when the repository ships nothing', () => {
215
+ // Given - 01-03 then 05, no 04 chapter, nothing that would ask for one
216
+ const tree = manual({
217
+ files: [...manual().files, 'docs/05-building.md'],
218
+ links: { 'docs/README.md': [...manual().links['docs/README.md'], '05-building.md'] },
219
+ });
220
+
221
+ // Then - 04 is the one gap the numbering excuses
222
+ expect(rules(tree)).toEqual([]);
223
+ });
224
+
225
+ test('still asks for 04-operating.md from that same shape once it ships', () => {
226
+ // Given - the same 01-03 + 05 shape, but an image this time
227
+ const tree = manual({
228
+ files: [...manual().files, 'docs/05-building.md'],
229
+ links: { 'docs/README.md': [...manual().links['docs/README.md'], '05-building.md'] },
230
+ ships: { dockerfile: true, infrastructure: false, publishable: false },
231
+ });
232
+
233
+ // Then - the numbering stays clean; only the presence test speaks
234
+ expect(rules(tree)).toEqual(['docs-operating-missing']);
235
+ });
236
+
237
+ test('refuses a gap at 05 even though 04 may be absent', () => {
238
+ // Given - 01-03 then 06, skipping past the one number the spine excuses
239
+ const tree = manual({
240
+ files: [...manual().files, 'docs/06-quality-checks.md'],
241
+ links: { 'docs/README.md': [...manual().links['docs/README.md'], '06-quality-checks.md'] },
242
+ });
243
+
244
+ // Then - only 04 is excused; 05 still has to be there
245
+ expect(rules(tree)).toEqual(['docs-chapter-numbering']);
246
+ });
247
+
248
+ test('names a chapter that is a journal, not a subject', () => {
249
+ // Given - a fourth chapter recording a design exploration
250
+ const tree = manual({
251
+ files: [...manual().files, 'docs/04-design-exploration.md'],
252
+ links: {
253
+ 'docs/README.md': [...manual().links['docs/README.md'], '04-design-exploration.md'],
254
+ },
255
+ });
256
+
257
+ // Then - the word is named, and so is where that content belongs
258
+ expect(sentence(tree, 'docs-journal-chapter')).toBe(
259
+ "docs/04-design-exploration.md names a journal, not a subject — exploration; the record is an ADR, the history is git's",
260
+ );
261
+ });
262
+
263
+ test('names a subfolder that is none of the three', () => {
264
+ // Given - a folder of dated QA passes under docs/
265
+ const tree = manual({ files: [...manual().files, 'docs/qa/', 'docs/qa/2026-01.md'] });
266
+
267
+ // Then - the three are named
268
+ expect(sentence(tree, 'docs-foreign-folder')).toBe(
269
+ 'docs/qa/ is not one of decisions/, reference/, _assets/',
270
+ );
271
+ });
272
+
273
+ test('names a file under docs/ that is neither the map nor a chapter', () => {
274
+ // Given - an unnumbered page dropped beside the chapters
275
+ const tree = manual({ files: [...manual().files, 'docs/automations.md'] });
276
+
277
+ // Then - number it, or move it out of the chapter space
278
+ expect(sentence(tree, 'docs-loose-file')).toBe(
279
+ 'docs/automations.md is neither the map nor a chapter — number it, or move it under _assets/',
280
+ );
281
+ });
282
+
283
+ /** A decisions/ folder holding one well-formed record — the ground the decision rules judge. */
284
+ function withDecisions(overrides: Record<string, unknown> = {}) {
285
+ return manual({
286
+ files: [
287
+ ...manual().files,
288
+ 'docs/decisions/',
289
+ 'docs/decisions/001-the-first-call.md',
290
+ 'docs/decisions/_template.md',
291
+ ],
292
+ heads: {
293
+ 'docs/decisions/001-the-first-call.md': [
294
+ '# ADR-001: The first call',
295
+ '',
296
+ '**Status:** Accepted',
297
+ ],
298
+ },
299
+ ...overrides,
300
+ });
301
+ }
302
+
303
+ test('passes a well-formed decisions folder', () => {
304
+ // Given - one record on the mold, beside the mold itself
305
+ // Then - nothing to say
306
+ expect(rules(withDecisions())).toEqual([]);
307
+ });
308
+
309
+ test('names a decision file that is not NNN-kebab.md', () => {
310
+ // Given - a record named without its number
311
+ const tree = withDecisions({
312
+ files: [...withDecisions().files, 'docs/decisions/the-second-call.md'],
313
+ });
314
+
315
+ // Then - the mold of the name is stated
316
+ expect(sentence(tree, 'docs-decision-name')).toBe(
317
+ 'docs/decisions/the-second-call.md is not NNN-kebab.md',
318
+ );
319
+ });
320
+
321
+ test('names a decision opening on the wrong heading', () => {
322
+ // Given - a record whose title forgets the ADR number
323
+ const tree = withDecisions({
324
+ heads: {
325
+ 'docs/decisions/001-the-first-call.md': [
326
+ '# The first call',
327
+ '',
328
+ '**Status:** Accepted',
329
+ ],
330
+ },
331
+ });
332
+
333
+ // Then - the line it opens on, and the mold it owes
334
+ expect(sentence(tree, 'docs-decision-heading')).toBe(
335
+ 'docs/decisions/001-the-first-call.md opens on # The first call: the mold is "# ADR-001: Title"',
336
+ );
337
+ });
338
+
339
+ test('names a decision whose heading claims another number', () => {
340
+ // Given - a record numbered 001 titled ADR-002
341
+ const tree = withDecisions({
342
+ heads: {
343
+ 'docs/decisions/001-the-first-call.md': [
344
+ '# ADR-002: The first call',
345
+ '',
346
+ '**Status:** Accepted',
347
+ ],
348
+ },
349
+ });
350
+
351
+ // Then - the same rule catches the disagreement
352
+ expect(rules(tree)).toEqual(['docs-decision-heading']);
353
+ });
354
+
355
+ test('names a decision outside the status vocabulary', () => {
356
+ // Given - a record declaring a status nobody defined
357
+ const tree = withDecisions({
358
+ heads: {
359
+ 'docs/decisions/001-the-first-call.md': [
360
+ '# ADR-001: The first call',
361
+ '',
362
+ '**Status:** Draft',
363
+ ],
364
+ },
365
+ });
366
+
367
+ // Then - the four the vocabulary holds
368
+ expect(sentence(tree, 'docs-decision-status')).toBe(
369
+ 'docs/decisions/001-the-first-call.md: **Status:** is none of Proposed, Accepted, Superseded by [ADR-NNN](file.md), Deprecated',
370
+ );
371
+ });
372
+
373
+ test('accepts a superseded status that links the record replacing it', () => {
374
+ // Given - the one status carrying an argument, written as a citation
375
+ const tree = withDecisions({
376
+ heads: {
377
+ 'docs/decisions/001-the-first-call.md': [
378
+ '# ADR-001: The first call',
379
+ '',
380
+ '**Status:** Superseded by [ADR-014](014-the-later-call.md)',
381
+ ],
382
+ },
383
+ });
384
+
385
+ // Then - nothing to say
386
+ expect(rules(tree)).toEqual([]);
387
+ });
388
+
389
+ test('refuses a superseded status with no link naming the successor', () => {
390
+ // Given - the bare form: a successor is named, but not as a place to look
391
+ const tree = withDecisions({
392
+ heads: {
393
+ 'docs/decisions/001-the-first-call.md': [
394
+ '# ADR-001: The first call',
395
+ '',
396
+ '**Status:** Superseded by ADR-014',
397
+ ],
398
+ },
399
+ });
400
+
401
+ // Then - the missing link is the whole complaint
402
+ expect(sentence(tree, 'docs-decision-status')).toBe(
403
+ 'docs/decisions/001-the-first-call.md: **Status:** names a successor but no link — write Superseded by [ADR-NNN](file.md)',
404
+ );
405
+ });
406
+
407
+ test('names a number two records claim', () => {
408
+ // Given - two records numbered 001
409
+ const tree = withDecisions({
410
+ files: [...withDecisions().files, 'docs/decisions/001-the-same-call.md'],
411
+ heads: {
412
+ ...withDecisions().heads,
413
+ 'docs/decisions/001-the-same-call.md': [
414
+ '# ADR-001: The same call',
415
+ '',
416
+ '**Status:** Proposed',
417
+ ],
418
+ },
419
+ });
420
+
421
+ // Then - both claimants are named
422
+ expect(sentence(tree, 'docs-decision-number')).toBe(
423
+ 'ADR-001 is claimed by 001-the-first-call.md and 001-the-same-call.md',
424
+ );
425
+ });
426
+
427
+ test('names a hand-written decisions index', () => {
428
+ // Given - a README.md listing the records
429
+ const tree = withDecisions({ files: [...withDecisions().files, 'docs/decisions/README.md'] });
430
+
431
+ // Then - an index is a copy, and the queue is derived
432
+ expect(sentence(tree, 'docs-decision-index')).toBe(
433
+ 'docs/decisions/README.md is an index — an index is a copy; the queue is derived (terra <brand> decisions)',
434
+ );
435
+ });
436
+
437
+ test('names a decisions folder with no mold', () => {
438
+ // Given - records with no _template.md beside them
439
+ const tree = withDecisions({
440
+ files: withDecisions().files.filter((path) => path !== 'docs/decisions/_template.md'),
441
+ });
442
+
443
+ // Then - the mold sits where the record is written
444
+ expect(sentence(tree, 'docs-template-missing')).toBe(
445
+ 'docs/decisions/_template.md is missing — the mold sits where the record is written',
446
+ );
447
+ });
448
+
449
+ test('names a reference page carrying no generation marker', () => {
450
+ // Given - a hand-authored page inside the projection
451
+ const tree = manual({
452
+ files: [...manual().files, 'docs/reference/', 'docs/reference/index.md'],
453
+ heads: { 'docs/reference/index.md': ['# API'] },
454
+ });
455
+
456
+ // Then - reference/ is a projection, never authored
457
+ expect(sentence(tree, 'docs-reference-unstamped')).toBe(
458
+ 'docs/reference/index.md carries no GENERATED marker — reference/ is a projection, never authored',
459
+ );
460
+ });
461
+
462
+ test('passes a stamped reference page', () => {
463
+ // Given - the marker the docs compiler writes on its first line
464
+ const tree = manual({
465
+ files: [...manual().files, 'docs/reference/', 'docs/reference/index.md'],
466
+ heads: {
467
+ 'docs/reference/index.md': ['<!-- GENERATED by `typescript docs` — DO NOT EDIT -->'],
468
+ },
469
+ });
470
+
471
+ // Then - nothing to say
472
+ expect(rules(tree)).toEqual([]);
473
+ });
474
+
475
+ test('names a repository whose brief does not route to the manual', () => {
476
+ // Given - no AGENTS.md at the root
477
+ const tree = manual({ agents: null });
478
+
479
+ // Then - the brief is a map, not a manual
480
+ expect(sentence(tree, 'docs-agents-route')).toBe(
481
+ 'AGENTS.md does not route to docs/README.md — the brief is a map, not a manual',
482
+ );
483
+ });
484
+
485
+ test('names a brief that exists but routes elsewhere', () => {
486
+ // Given - a brief restating the corpus instead of pointing at it
487
+ const tree = manual({ agents: '# Agent brief\n\nEverything you need is right here.\n' });
488
+
489
+ // Then - the same rule
490
+ expect(rules(tree)).toEqual(['docs-agents-route']);
491
+ });
492
+
493
+ test('names a chapter reaching into another repository', () => {
494
+ // Given - a chapter linking a sibling clone's page
495
+ const tree = manual({
496
+ links: {
497
+ ...manual().links,
498
+ 'docs/01-architecture.md': ['../../package-test/docs/README.md'],
499
+ },
500
+ });
501
+
502
+ // Then - another repository's tree is not a place this manual may reach
503
+ expect(sentence(tree, 'docs-cross-repo-link')).toBe(
504
+ "docs/01-architecture.md links ../../package-test/docs/README.md: another repository's tree is not a place this manual may reach",
505
+ );
506
+ });
507
+
508
+ test('lets a chapter cite a sibling repository by url, and reach its own root', () => {
509
+ // Given - a forge link, an anchor, and a link back up to the repository's README
510
+ const tree = manual({
511
+ links: {
512
+ ...manual().links,
513
+ 'docs/01-architecture.md': [
514
+ 'https://github.com/jterrazz/jterrazz-studio/blob/main/docs/08-repo-structure.md',
515
+ '#the-spine',
516
+ '../README.md',
517
+ '02-developing.md',
518
+ ],
519
+ },
520
+ });
521
+
522
+ // Then - a citation is not a reach, and the repository's own tree is fair game
523
+ expect(rules(tree)).toEqual([]);
524
+ });