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