@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 +18 -14
- package/bin/commands/check.sh +27 -1
- package/bin/typescript.sh +18 -8
- package/lib/check-docs.js +150 -0
- package/package.json +5 -1
- package/src/docs.d.ts +42 -0
- package/src/docs.js +446 -0
- package/src/docs.test.ts +524 -0
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
|
|
15
|
-
npx typescript bundle
|
|
16
|
-
npx typescript start
|
|
17
|
-
npx typescript dev
|
|
18
|
-
npx typescript docs
|
|
19
|
-
npx typescript
|
|
20
|
-
npx typescript
|
|
21
|
-
npx typescript
|
|
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
|
-
- [
|
|
44
|
-
- [
|
|
45
|
-
- [
|
|
46
|
-
- [
|
|
47
|
-
- [
|
|
48
|
-
- [
|
|
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
|
|
package/bin/commands/check.sh
CHANGED
|
@@ -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
|
|
172
|
-
printf " bundle
|
|
173
|
-
printf " start
|
|
174
|
-
printf " dev
|
|
175
|
-
printf " docs
|
|
176
|
-
printf "
|
|
177
|
-
printf "
|
|
178
|
-
printf "
|
|
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.
|
|
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
|
+
}
|
package/src/docs.test.ts
ADDED
|
@@ -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
|
+
});
|