Files
Integration-kit/scripts/checkChapterPaths.js
wtclaude f41ff92c67
All checks were successful
PR Checks / prose (pull_request) Successful in 8s
PR Checks / template (pull_request) Successful in 27s
docs(book): the four chapters — Phase 5 slice 2
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>
2026-08-12 13:20:29 -05:00

143 lines
5.7 KiB
JavaScript

#!/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.`,
)