docs(book): the four chapters — Phase 5 slice 2
All checks were successful
PR Checks / prose (pull_request) Successful in 8s
PR Checks / template (pull_request) Successful in 27s

The book, written out of the tree slice 1 proved. Four chapters in the order the
work happens: the first module in twenty minutes, the website module, the sidecar,
and the game-side plugin.

Shape, settled with the org lead:

  * template/README.md stays the REFERENCE — it travels with a copied template and
    CI holds it against the tree — and chapter 1 is the narration: what you should
    see after each step, the state your module lands in, and the four ways it fails.
    The chapter links to the checklist rather than restating it.
  * chapters 3 and 4 cite link/ and servuo-plugins/ by FILE AND IDENTIFIER, never by
    line. Those repositories move for their own reasons and checkLinks already
    forbids commit permalinks, so a line number in this book is wrong the moment
    they do. The template stays the only code quoted verbatim.
  * one PR: the outline's status table and the link check are only coherent when the
    whole set lands.

scripts/checkChapterPaths.js is the anti-rot half a machine can answer: every path
a chapter names in backticks must exist. None of those mentions is a markdown link,
so checkLinks never looked at them, and none is code, so nothing else did either —
renaming one template file would have left four chapters quietly pointing at
nothing. Its anchor list is STATED rather than derived from the tree, for the reason
the template's own build guard states it: a list derived from what exists cannot
fail when what exists changes, and an anchor that stops matching is a check that has
silently stopped checking. So each anchor must exist or the check fails. Eleven
tests, every "must not catch" case a span that really appears in the book.

stripFences moved to scripts/lib/markdown.js and both checks use it — shared code,
not a shared description.

CHAPTER 1 WAS RUN, NOT REASONED ABOUT. The template was copied into a real core on
edge, booted against the dev database, and every claim in "what you should see"
checked: the five log lines, /examplegame/status with its injected
<script type="module" src="/modules/examplegame/entry.js">, the chunk served
no-cache while module.json 404s, /api/v1/public/world/status, the capabilities in
/api/v1/public/modules, and the route in the merged /api/docs.json. Then the three
failures the chapter tells a reader to cause on purpose, because a chapter that
predicts the wrong debugging heuristic is worse than one that predicts none:

  * an undeclared prefix  -> stage `register`, "declared public/extra but never
    registered it", routes 404 and absent from /public/modules;
  * a table without the id prefix -> stage `schema`, at LOAD time, before mounting;
  * a throwing onBoot -> after mounting, so the same route answers 503 "Module
    unavailable" rather than vanishing.

All three came out exactly as written, and the messages in the chapter are that
core's own. Two small corrections fell out of the run: the log sample now shows the
real interleaving of core's three lines with the module's two, and the section on
failure adds that a module disappears from /api/v1/public/modules in every failure
case — a check that needs no login.

MODULE_SYSTEM.md 2.11.1 slice 2. Docs half: docs#146.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-12 13:20:29 -05:00
parent 24b9d30a16
commit f41ff92c67
11 changed files with 1438 additions and 121 deletions

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env node
// Every path in this repo that a chapter names in backticks must exist.
//
// The book teaches out of `template/`: it says "open
// `template/server/index.js`", "the aliases are in `template/client/vite.config.js`",
// "your tables go in `template/server/db/schema.sql`". None of that is a markdown
// link, so `checkLinks.js` never looks at it — and none of it is code, so nothing
// else does either. Rename one template file and four chapters quietly point at
// nothing, which is the exact rot this repo exists to be immune to.
//
// This is the cheap half of "is the book still true", and it is honest about
// being only the half a machine can answer. Whether a paragraph has become wrong
// about a file that still exists is a reviewer's job (MODULE_SYSTEM.md §2.10).
//
// ── What counts as a claim about this repo ────────────────────────────────────
//
// An inline code span whose text begins with one of this repo's own top-level
// directories, `ANCHORS` below. That is what makes the check answerable: a
// chapter also quotes `server/index.js` loosely, and `sidecar/src/store.rs`,
// which lives in another repo entirely and cannot be resolved here. Anchoring on
// our own directory names means every token this check reads is a claim it can
// actually settle.
//
// **The anchors are stated, not derived from the tree**, and that is deliberate
// for the reason core's own build guard states it (MODULE_API.md §3.6): a list
// derived from what exists cannot fail when what exists changes. Rename
// `template/` and a derived anchor set would simply stop checking every
// `template/…` mention in the book, silently, at the moment they all became
// wrong. So the anchors are written down — and each one must exist, or this check
// fails. An anchor that has stopped matching is a check that has stopped
// checking, the same rule the identifier exemptions in core's CI follow.
//
// Fenced blocks are excluded (`lib/markdown.js`). A fence in this book is often a
// listing of the reader's own future tree, and their files are not ours.
//
// Usage: node scripts/checkChapterPaths.js (from the repo root)
const fs = require('fs')
const path = require('path')
const { codeSpans } = require('./lib/markdown')
const ROOT = path.resolve(__dirname, '..')
// This repo's own top-level directories. See the note above on why this is a list
// and not a directory scan.
const ANCHORS = ['template/', 'book/', 'scripts/', 'ci/']
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist'])
/** Every markdown file in the repo, repo-relative, sorted. */
function markdownFiles(dir = ROOT, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue
markdownFiles(path.join(dir, entry.name), out)
} else if (entry.name.toLowerCase().endsWith('.md')) {
out.push(path.relative(ROOT, path.join(dir, entry.name)).split(path.sep).join('/'))
}
}
return out.sort()
}
/**
* The repo paths a document claims, from its inline code spans.
*
* A span is a claim when it starts with an anchor and names something a
* filesystem could answer for. Three kinds are skipped, each because the answer
* would be "no" for a reason that is not a mistake:
*
* • a placeholder — `template/<id>/…`, `scripts/*.js` — which is a shape rather
* than a path;
* • a span with whitespace in it, which is a phrase or a command line
* (`npm ci --prefix template/server` is not a path and its first word is not
* an anchor either, but a span like `cd template/server && npm test` would
* slip through on its first token without this);
* • trailing prose punctuation, stripped rather than skipped, so `template/`
* ending a sentence still resolves.
*/
function claimedPaths(markdown) {
const found = []
for (const { text, line } of codeSpans(markdown)) {
const token = text.trim()
if (/\s/.test(token)) continue
if (!ANCHORS.some((a) => token.startsWith(a))) continue
if (/[<>*?]|\.\.\./.test(token)) continue
// A path may legitimately end in `/` (a directory); anything else in this set
// is the sentence around it, not part of the name.
const cleaned = token.replace(/[.,;:)\]]+$/, '')
if (cleaned) found.push({ path: cleaned, line })
}
return found
}
/** Everything wrong, as sentences. Empty means every claim resolves. */
function problems({ claims, exists }) {
const out = []
for (const anchor of ANCHORS) {
const dir = anchor.replace(/\/$/, '')
if (!exists(dir)) {
out.push(
`${anchor} is listed as an anchor and does not exist. ` +
'Either restore it or update ANCHORS — an anchor that matches nothing is a ' +
'check that has silently stopped checking.',
)
}
}
for (const { file, path: claimed, line } of claims) {
if (!exists(claimed)) {
out.push(`${file}:${line}: no such path — ${claimed}`)
}
}
return out
}
module.exports = { ANCHORS, claimedPaths, problems, markdownFiles }
if (require.main !== module) return
const files = markdownFiles()
const claims = []
for (const file of files) {
const text = fs.readFileSync(path.join(ROOT, file), 'utf8')
for (const claim of claimedPaths(text)) claims.push({ file, ...claim })
}
const exists = (p) => fs.existsSync(path.join(ROOT, p))
const found = problems({ claims, exists })
if (found.length) {
console.error(`\ncheckChapterPaths: ${found.length} problem(s):\n`)
for (const p of found) console.error(` - ${p}`)
console.error('')
process.exit(1)
}
console.log(
`checkChapterPaths: ${claims.length} path(s) claimed across ${files.length} markdown file(s) — all present.`,
)

View File

@@ -0,0 +1,81 @@
// The chapter-path check, checked.
//
// Same rule as the rename check's own suite: a check written when the thing it
// guards is already clean never fires again, and nothing distinguishes "still
// checking" from "quietly broken" without cases it is required to reject. Every
// "must not catch" case below is a real span that appears in the book.
//
// No filesystem — `problems()` takes `exists` as an argument precisely so it can
// be tested this way, and `claimedPaths()` is pure.
const test = require('node:test')
const assert = require('node:assert')
const { ANCHORS, claimedPaths, problems } = require('./checkChapterPaths')
/** `problems()` over a fixture set of paths that exist. */
const check = (claims, present) =>
problems({ claims, exists: (p) => new Set([...present, ...ANCHORS.map((a) => a.replace(/\/$/, ''))]).has(p) })
test('a claim that resolves is not a problem', () => {
assert.deepStrictEqual(check([{ file: 'book/01.md', line: 3, path: 'template/module.json' }],
['template/module.json']), [])
})
test('a claim that does not resolve fails, naming the file and line', () => {
const found = check([{ file: 'book/02-website-module.md', line: 41, path: 'template/server/gone.js' }], [])
assert.strictEqual(found.length, 1)
assert.match(found[0], /book\/02-website-module\.md:41.*template\/server\/gone\.js/)
})
test('a missing anchor fails on its own', () => {
// The half that keeps this check honest: if `template/` is renamed, every
// template path in the book is wrong AND the check would stop looking at them.
const found = problems({ claims: [], exists: (p) => p !== 'template' })
assert.strictEqual(found.length, 1)
assert.match(found[0], /template\/ is listed as an anchor and does not exist/)
})
test('paths are read only from inline code spans', () => {
const md = 'Open the entry point and read it: template/server/index.js, then stop.'
assert.deepStrictEqual(claimedPaths(md), [])
})
test('a code span inside a fenced block is not a claim', () => {
// A fence is usually the reader's own future tree, and their files are not ours.
const md = ['```', '`template/nope.js`', 'template/also-nope.js', '```'].join('\n')
assert.deepStrictEqual(claimedPaths(md), [])
})
test('a path in another repo is not this check\'s business', () => {
const md = 'The store is `sidecar/src/store.rs`, and the plugin is `overlay/Scripts/Custom/Bridge/BridgeLink.cs`.'
assert.deepStrictEqual(claimedPaths(md), [])
})
test('a placeholder shape is not a path', () => {
const md = 'Your copy lands at `template/<id>/module.json`, and the checks are `scripts/*.js`.'
assert.deepStrictEqual(claimedPaths(md), [])
})
test('a command line is not a path', () => {
// The first token is an anchor in neither case, but a span that BEGINS with one
// and carries arguments would otherwise be read as a filename with spaces in it.
const md = 'Run `npm ci --prefix template/server`, or `template/server && npm test` if you must.'
assert.deepStrictEqual(claimedPaths(md), [])
})
test('trailing sentence punctuation is stripped, not skipped', () => {
const md = 'It all lives under `template/`.'
assert.deepStrictEqual(claimedPaths(md), [{ path: 'template/', line: 1 }])
})
test('a claim on a later line reports that line', () => {
const md = ['# Title', '', 'See `template/server/boot.js`.'].join('\n')
assert.deepStrictEqual(claimedPaths(md), [{ path: 'template/server/boot.js', line: 3 }])
})
test('every anchor is a directory of this repo', () => {
// Stated, not derived (see the header) — so this asserts the stated list is
// still the real one at the moment it is written down.
assert.ok(ANCHORS.every((a) => a.endsWith('/')), 'anchors are directory prefixes')
})

View File

@@ -19,6 +19,8 @@
const fs = require('fs')
const path = require('path')
const { stripFences } = require('./lib/markdown')
const ROOT = path.resolve(__dirname, '..')
const QUIET = process.argv.includes('--quiet')
@@ -38,31 +40,10 @@ function markdownFiles(dir = ROOT, out = []) {
return out.sort()
}
// Fenced code blocks are stripped before links are read: a fence can legitimately
// contain a path that does not exist (a directory listing of a project the reader
// has not created yet), and flagging those would make the check useless in exactly
// the document type this repo is made of. Stripped by walking lines and toggling
// on a fence marker, rather than by regexp — a fence's own content can contain
// anything, including a line that looks like the end of one.
function stripFences(text) {
const out = []
let fence = null
for (const line of text.split(/\r?\n/)) {
const m = /^\s*(```+|~~~+)/.exec(line)
if (fence) {
if (m && m[1][0] === fence[0] && m[1].length >= fence.length) fence = null
out.push('')
continue
}
if (m) {
fence = m[1]
out.push('')
continue
}
out.push(line)
}
return out.join('\n')
}
// Fenced code blocks are stripped before links are read (`lib/markdown.js`): a
// fence can legitimately contain a path that does not exist a directory listing
// of a project the reader has not created yet and flagging those would make the
// check useless in exactly the document type this repo is made of.
/** Inline `[text](target)` links and `[ref]: target` definitions, with line numbers. */
function linksIn(text) {

60
scripts/lib/markdown.js Normal file
View File

@@ -0,0 +1,60 @@
// The two pieces of markdown handling both checks in this directory need, in one
// place rather than two copies that drift.
//
// Shared code, not a shared description. Core's own loader and its schema replay
// use one splitter for the same reason (MODULE_API.md §2.6): two implementations
// of "what counts as a code fence" would disagree eventually, and the check that
// disagreed quietly would be the one still reporting green.
/**
* The text with every fenced code block blanked out, line count preserved.
*
* Fenced blocks are stripped before either check reads anything, because a fence
* can legitimately contain a path or a link that does not exist: a directory
* listing of the project the reader has not created yet, a URL in an example. In
* a repo made entirely of that document type, flagging them makes the check
* useless.
*
* Done by walking lines and toggling on a fence marker rather than by regexp — a
* fence's own content can contain anything, including a line that looks like the
* end of one. Lines are replaced by empty strings rather than removed so that
* line numbers in a report still point at the right place.
*/
function stripFences(text) {
const out = []
let fence = null
for (const line of text.split(/\r?\n/)) {
const m = /^\s*(```+|~~~+)/.exec(line)
if (fence) {
if (m && m[1][0] === fence[0] && m[1].length >= fence.length) fence = null
out.push('')
continue
}
if (m) {
fence = m[1]
out.push('')
continue
}
out.push(line)
}
return out.join('\n')
}
/**
* Every inline code span outside a fenced block, with the 1-based line it is on.
*
* `[a](b)` inside backticks is an example rather than a link, and `template/x.js`
* inside backticks is a claim about this repo's tree — which is why one check
* throws these away and the other reads only these.
*/
function codeSpans(text) {
const found = []
stripFences(text).split(/\r?\n/).forEach((line, i) => {
for (const m of line.matchAll(/`([^`]+)`/g)) {
found.push({ text: m[1], line: i + 1 })
}
})
return found
}
module.exports = { stripFences, codeSpans }