|
| 1 | +#!/usr/bin/env node |
| 2 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 3 | +// |
| 4 | +// check-runtime-services-index (#9604) -- hold the runtime-services chapter's |
| 5 | +// two INDEX lists to the pages that actually exist. |
| 6 | +// |
| 7 | +// node scripts/check-runtime-services-index.mjs |
| 8 | +// node scripts/check-runtime-services-index.mjs --self-test # verify the checker itself |
| 9 | +// |
| 10 | +// ## What it guards |
| 11 | +// |
| 12 | +// `content/docs/kernel/runtime-services/` publishes one `<name>-service.mdx` |
| 13 | +// page per documented `services.<name>` accessor, and THREE hand-written places |
| 14 | +// claim to enumerate them: |
| 15 | +// |
| 16 | +// 1. `runtime-services/meta.json` -> `pages` (chapter nav order) |
| 17 | +// 2. `runtime-services/index.mdx` -> "This chapter documents ..." bullets |
| 18 | +// 3. `kernel/index.mdx` -> the `services.*` table |
| 19 | +// |
| 20 | +// None of the three is generated, so each drifts from the tree one edit at a |
| 21 | +// time, and nothing reads them: `check:docs-audit-scope` derives WHICH pages the |
| 22 | +// docs-accuracy audit covers, never whether an index enumerates them. #9604 |
| 23 | +// measured the result -- `services.sms` had a page, a `meta.json` entry, a |
| 24 | +// registered slot (`sms-plugin.ts:181`) and a canonical-source row, and was |
| 25 | +// still missing from BOTH index lists. It shipped that way and every gate was |
| 26 | +// green. #9588 was the same page drifting on a different line. |
| 27 | +// |
| 28 | +// Nothing breaks at runtime; the cost is that an index page's whole job is to be |
| 29 | +// a trustworthy map. A reader who does not find SMS in the list concludes the |
| 30 | +// chapter has no SMS page. `content/docs/` is also the corpus humans and AIs |
| 31 | +// copy from, so a short list is read as a fact about the platform's surface. |
| 32 | +// Declared = enforced. |
| 33 | +// |
| 34 | +// ## What "derived" means here |
| 35 | +// |
| 36 | +// The pages on disk are the source of truth -- they are the thing a reader can |
| 37 | +// actually open. Each page also has to declare its own accessor |
| 38 | +// (`title: services.<name>` matching its filename), which is checked first: it |
| 39 | +// is the premise the other three comparisons rest on, so a page that lies about |
| 40 | +// its own name must go red here rather than silently redefine the expected set. |
| 41 | +// |
| 42 | +// Order is enforced too, not just membership. The chapter list currently |
| 43 | +// follows `meta.json`'s `pages` order exactly, and that convention is the only |
| 44 | +// thing that tells the next author WHERE a new bullet goes. Membership-only |
| 45 | +// checking would have accepted `services.sms` appended at the end, next to a |
| 46 | +// list whose order encodes the nav -- so the gate keeps the answer mechanical. |
| 47 | +// |
| 48 | +// ## Deliberately NOT checked: the "Source of Truth" list |
| 49 | +// |
| 50 | +// `index.mdx` carries a fourth list -- `- <Label>: \`<path>\`` canonical-source |
| 51 | +// rows. This gate does not compare it with the pages, for two reasons: |
| 52 | +// |
| 53 | +// - It is a superset by exactly one row on purpose-of-record: `Security: |
| 54 | +// packages/spec/src/contracts/security-service.ts` names a real, registered |
| 55 | +// slot (`security-plugin.ts:1157`) that this chapter has no page for, and |
| 56 | +// `services.security` is documented NOWHERE under `content/docs/`. Whether |
| 57 | +// that row should become a page, move, or be dropped is a product-surface |
| 58 | +// question for the maintainer (#9604 explicitly declines to guess). Encoding |
| 59 | +// any of the three answers here -- including as an allowlist entry -- would |
| 60 | +// pre-judge it. |
| 61 | +// - Its row labels are prose, not accessors (`Audit bridge`, `Data`), and that |
| 62 | +// line is under active edit. |
| 63 | +// |
| 64 | +// When the Security question is settled, extending this gate to that list is the |
| 65 | +// natural follow-up; until then a green here means "the three enumerations agree |
| 66 | +// with the tree", which is exactly what the summary line says. |
| 67 | + |
| 68 | +import { readFileSync, readdirSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; |
| 69 | +import { join, dirname } from 'node:path'; |
| 70 | +import { tmpdir } from 'node:os'; |
| 71 | +import { fileURLToPath } from 'node:url'; |
| 72 | + |
| 73 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 74 | +const repoRoot = () => join(HERE, '..'); |
| 75 | + |
| 76 | +const CHAPTER_DIR = 'content/docs/kernel/runtime-services'; |
| 77 | +const KERNEL_INDEX = 'content/docs/kernel/index.mdx'; |
| 78 | +const PAGE_SUFFIX = '-service.mdx'; |
| 79 | +const META_SUFFIX = '-service'; |
| 80 | + |
| 81 | +// --------------------------------------------------------------------------- |
| 82 | +// Derivation |
| 83 | + |
| 84 | +/** Accessor names from the pages that exist, plus each page's declared title. */ |
| 85 | +export function readPages(chapterDir) { |
| 86 | + return readdirSync(chapterDir) |
| 87 | + .filter((f) => f.endsWith(PAGE_SUFFIX)) |
| 88 | + .sort() |
| 89 | + .map((file) => { |
| 90 | + const name = file.slice(0, -PAGE_SUFFIX.length); |
| 91 | + const text = readFileSync(join(chapterDir, file), 'utf8'); |
| 92 | + const m = /^title:\s*(.+?)\s*$/m.exec(text); |
| 93 | + return { name, file, title: m ? m[1] : null }; |
| 94 | + }); |
| 95 | +} |
| 96 | + |
| 97 | +/** `pages` entries that name a service page, in nav order. */ |
| 98 | +export function readMetaOrder(chapterDir) { |
| 99 | + const raw = JSON.parse(readFileSync(join(chapterDir, 'meta.json'), 'utf8')); |
| 100 | + const pages = Array.isArray(raw.pages) ? raw.pages : []; |
| 101 | + return pages.filter((p) => typeof p === 'string' && p.endsWith(META_SUFFIX)).map((p) => p.slice(0, -META_SUFFIX.length)); |
| 102 | +} |
| 103 | + |
| 104 | +/** The "This chapter documents ..." bullets, in page order. */ |
| 105 | +export function readChapterList(indexText) { |
| 106 | + return [...indexText.matchAll(/^-\s+`services\.([A-Za-z0-9_]+)`\s*$/gm)].map((m) => m[1]); |
| 107 | +} |
| 108 | + |
| 109 | +/** Accessors linked from the `services.*` table in kernel/index.mdx, in order. */ |
| 110 | +export function readKernelTable(kernelText) { |
| 111 | + return [...kernelText.matchAll(/\[`services\.([A-Za-z0-9_]+)`\]\(\/docs\/kernel\/runtime-services\/([A-Za-z0-9_-]+)\)/g)] |
| 112 | + .map((m) => ({ accessor: m[1], href: m[2] })); |
| 113 | +} |
| 114 | + |
| 115 | +// --------------------------------------------------------------------------- |
| 116 | +// Comparison |
| 117 | + |
| 118 | +const missing = (expected, actual) => expected.filter((n) => !actual.includes(n)); |
| 119 | +const extra = (expected, actual) => actual.filter((n) => !expected.includes(n)); |
| 120 | + |
| 121 | +export function check({ pages, metaOrder, chapterList, kernelTable }) { |
| 122 | + const findings = []; |
| 123 | + const add = (where, msg) => findings.push({ where, msg }); |
| 124 | + |
| 125 | + // 0. The premise: every page declares the accessor its filename claims. |
| 126 | + for (const p of pages) { |
| 127 | + const want = `services.${p.name}`; |
| 128 | + if (p.title !== want) { |
| 129 | + add(`${CHAPTER_DIR}/${p.file}`, `frontmatter title is ${p.title === null ? '(absent)' : `"${p.title}"`}, expected "${want}" to match the filename`); |
| 130 | + } |
| 131 | + } |
| 132 | + // A page that lies about its own name makes every set below meaningless. |
| 133 | + if (findings.length) return findings; |
| 134 | + |
| 135 | + const onDisk = pages.map((p) => p.name); |
| 136 | + |
| 137 | + // 1. meta.json <-> disk |
| 138 | + for (const n of missing(onDisk, metaOrder)) add(`${CHAPTER_DIR}/meta.json`, `"pages" omits "${n}${META_SUFFIX}" (${n}${PAGE_SUFFIX} exists)`); |
| 139 | + for (const n of extra(onDisk, metaOrder)) add(`${CHAPTER_DIR}/meta.json`, `"pages" lists "${n}${META_SUFFIX}" but ${n}${PAGE_SUFFIX} does not exist`); |
| 140 | + |
| 141 | + // 2. chapter list <-> disk |
| 142 | + for (const n of missing(onDisk, chapterList)) add(`${CHAPTER_DIR}/index.mdx`, `chapter list omits \`services.${n}\` (${n}${PAGE_SUFFIX} exists)`); |
| 143 | + for (const n of extra(onDisk, chapterList)) add(`${CHAPTER_DIR}/index.mdx`, `chapter list names \`services.${n}\` but ${n}${PAGE_SUFFIX} does not exist`); |
| 144 | + |
| 145 | + // 3. chapter list order == meta.json nav order |
| 146 | + const navOrder = metaOrder.filter((n) => chapterList.includes(n)); |
| 147 | + const listed = chapterList.filter((n) => metaOrder.includes(n)); |
| 148 | + if (navOrder.join() !== listed.join()) { |
| 149 | + add(`${CHAPTER_DIR}/index.mdx`, `chapter list order ${JSON.stringify(listed)} does not follow meta.json "pages" order ${JSON.stringify(navOrder)}`); |
| 150 | + } |
| 151 | + |
| 152 | + // 4. kernel/index.mdx table <-> disk, and each row's href resolves |
| 153 | + const linked = kernelTable.map((r) => r.accessor); |
| 154 | + for (const n of missing(onDisk, linked)) add(KERNEL_INDEX, `\`services.*\` table has no row for \`services.${n}\` (${n}${PAGE_SUFFIX} exists)`); |
| 155 | + for (const n of extra(onDisk, linked)) add(KERNEL_INDEX, `\`services.*\` table has a row for \`services.${n}\` but ${n}${PAGE_SUFFIX} does not exist`); |
| 156 | + for (const r of kernelTable) { |
| 157 | + if (r.href !== `${r.accessor}${META_SUFFIX}`) { |
| 158 | + add(KERNEL_INDEX, `\`services.${r.accessor}\` links to "${r.href}", expected "${r.accessor}${META_SUFFIX}"`); |
| 159 | + } |
| 160 | + } |
| 161 | + |
| 162 | + return findings; |
| 163 | +} |
| 164 | + |
| 165 | +export function summarise({ pages, chapterList, kernelTable }) { |
| 166 | + return `${pages.length} chapter page(s) vs meta.json "pages", ${chapterList.length} chapter-list bullet(s) and ${kernelTable.length} kernel/index.mdx table row(s)`; |
| 167 | +} |
| 168 | + |
| 169 | +// --------------------------------------------------------------------------- |
| 170 | + |
| 171 | +function run(root) { |
| 172 | + const chapterDir = join(root, CHAPTER_DIR); |
| 173 | + const pages = readPages(chapterDir); |
| 174 | + const metaOrder = readMetaOrder(chapterDir); |
| 175 | + const chapterList = readChapterList(readFileSync(join(chapterDir, 'index.mdx'), 'utf8')); |
| 176 | + const kernelTable = readKernelTable(readFileSync(join(root, KERNEL_INDEX), 'utf8')); |
| 177 | + if (pages.length === 0) throw new Error(`no ${PAGE_SUFFIX} pages found under ${CHAPTER_DIR} -- refusing to report OK over an empty set`); |
| 178 | + const input = { pages, metaOrder, chapterList, kernelTable }; |
| 179 | + return { findings: check(input), summary: summarise(input) }; |
| 180 | +} |
| 181 | + |
| 182 | +function main() { |
| 183 | + const root = repoRoot(); |
| 184 | + if (!existsSync(join(root, CHAPTER_DIR))) { |
| 185 | + console.error(`✗ check-runtime-services-index -- ${CHAPTER_DIR} not found`); |
| 186 | + process.exit(1); |
| 187 | + } |
| 188 | + const { findings, summary } = run(root); |
| 189 | + if (findings.length) { |
| 190 | + console.error(`✗ check-runtime-services-index -- ${findings.length} drift(s) between the runtime-services indexes and the pages on disk\n`); |
| 191 | + for (const f of findings) console.error(` • ${f.where}: ${f.msg}`); |
| 192 | + console.error('\n The pages on disk are the source of truth. Add the missing entry (or delete the stale'); |
| 193 | + console.error(' one) so all three enumerations agree; the chapter list follows meta.json "pages" order.'); |
| 194 | + console.error(' The "Source of Truth" canonical-source list is deliberately not checked -- see the header.'); |
| 195 | + process.exit(1); |
| 196 | + } |
| 197 | + console.log(`✓ check-runtime-services-index: ${summary} -- all three enumerations agree (Source-of-Truth list not in scope).`); |
| 198 | +} |
| 199 | + |
| 200 | +// --------------------------------------------------------------------------- |
| 201 | +// Self-test: every limb observed FAILING on a synthetic tree, and observed silent. |
| 202 | + |
| 203 | +function selfTest() { |
| 204 | + const failures = []; |
| 205 | + let checked = 0; |
| 206 | + const assert = (cond, why) => { checked++; if (!cond) failures.push(why); }; |
| 207 | + |
| 208 | + const dir = mkdtempSync(join(tmpdir(), 'rt-services-index-')); |
| 209 | + try { |
| 210 | + const chapter = join(dir, CHAPTER_DIR); |
| 211 | + mkdirSync(chapter, { recursive: true }); |
| 212 | + mkdirSync(join(dir, 'content/docs/kernel'), { recursive: true }); |
| 213 | + |
| 214 | + const names = ['data', 'email', 'sms']; |
| 215 | + const writeTree = ({ pages = names, meta = names, list = names, table = names, titleFor = (n) => `services.${n}`, hrefFor = (n) => `${n}-service` } = {}) => { |
| 216 | + for (const f of readdirSync(chapter)) rmSync(join(chapter, f), { force: true }); |
| 217 | + for (const n of pages) writeFileSync(join(chapter, `${n}${PAGE_SUFFIX}`), `---\ntitle: ${titleFor(n)}\n---\n`); |
| 218 | + writeFileSync(join(chapter, 'meta.json'), JSON.stringify({ pages: ['index', ...meta.map((n) => `${n}${META_SUFFIX}`), 'examples'] })); |
| 219 | + writeFileSync(join(chapter, 'index.mdx'), `# x\n\n${list.map((n) => `- \`services.${n}\``).join('\n')}\n\n## Source of Truth\n\n- Security: \`packages/spec/src/contracts/security-service.ts\`\n`); |
| 220 | + writeFileSync(join(dir, KERNEL_INDEX), `# k\n\n${table.map((n) => `| [\`services.${n}\`](/docs/kernel/runtime-services/${hrefFor(n)}) | stable | d |`).join('\n')}\n`); |
| 221 | + }; |
| 222 | + const findingsFor = (opts) => { writeTree(opts); return run(dir).findings; }; |
| 223 | + |
| 224 | + // ── The clean tree is silent ──────────────────────────────────────────── |
| 225 | + const clean = findingsFor(); |
| 226 | + assert(clean.length === 0, `a consistent tree reports nothing -- got ${JSON.stringify(clean)}`); |
| 227 | + assert(summarise({ pages: readPages(chapter), chapterList: names, kernelTable: names.map((n) => ({ accessor: n })) }).includes('3 chapter page(s)'), 'the summary names the counts, so a green can be read for its scope'); |
| 228 | + |
| 229 | + // ── Each limb observed FAILING ────────────────────────────────────────── |
| 230 | + // This is the #9604 defect itself: page + meta entry, absent from both lists. |
| 231 | + const sms = findingsFor({ list: ['data', 'email'], table: ['data', 'email'] }); |
| 232 | + assert(sms.some((f) => f.where.endsWith('runtime-services/index.mdx') && f.msg.includes('omits `services.sms`')), `the chapter list omitting a real page is caught -- got ${JSON.stringify(sms)}`); |
| 233 | + assert(sms.some((f) => f.where === KERNEL_INDEX && f.msg.includes('no row for `services.sms`')), `the kernel table omitting a real page is caught -- got ${JSON.stringify(sms)}`); |
| 234 | + |
| 235 | + assert(findingsFor({ meta: ['data', 'email'] }).some((f) => f.msg.includes('omits "sms-service"')), 'meta.json omitting a real page is caught'); |
| 236 | + assert(findingsFor({ pages: ['data', 'email'] }).some((f) => f.msg.includes('does not exist')), 'an enumeration naming a page that does not exist is caught'); |
| 237 | + assert(findingsFor({ list: ['data', 'sms', 'email'] }).some((f) => f.msg.includes('does not follow meta.json')), 'a chapter list in the wrong order is caught (membership alone would pass)'); |
| 238 | + assert(findingsFor({ hrefFor: (n) => (n === 'sms' ? 'sms-svc' : `${n}-service`) }).some((f) => f.msg.includes('links to "sms-svc"')), 'a kernel table row whose href does not match its accessor is caught'); |
| 239 | + |
| 240 | + const lying = findingsFor({ titleFor: (n) => (n === 'sms' ? 'services.text' : `services.${n}`) }); |
| 241 | + assert(lying.some((f) => f.msg.includes('expected "services.sms"')), 'a page whose title contradicts its filename is caught'); |
| 242 | + assert(lying.every((f) => f.where.endsWith(`sms${PAGE_SUFFIX}`)), 'the title premise short-circuits: no set comparison runs over a page that lies about its name'); |
| 243 | + |
| 244 | + // ── Refuses to report OK over nothing ─────────────────────────────────── |
| 245 | + let threw = false; |
| 246 | + try { findingsFor({ pages: [], meta: [], list: [], table: [] }); } catch { threw = true; } |
| 247 | + assert(threw, 'an EMPTY chapter is rejected, never reported OK'); |
| 248 | + } finally { |
| 249 | + rmSync(dir, { recursive: true, force: true }); |
| 250 | + } |
| 251 | + |
| 252 | + if (failures.length) { |
| 253 | + console.error(`✗ check-runtime-services-index --self-test -- ${failures.length} failure(s)\n`); |
| 254 | + for (const f of failures) console.error(` • ${f}`); |
| 255 | + process.exit(1); |
| 256 | + } |
| 257 | + console.log(`✓ check-runtime-services-index --self-test: ${checked} assertions over a temp fixture (real run() path); every limb -- chapter list, kernel table, meta.json, order, href, title premise, empty tree -- observed FAILING and observed silent.`); |
| 258 | +} |
| 259 | + |
| 260 | +if (process.argv.includes('--self-test')) selfTest(); |
| 261 | +else main(); |
0 commit comments