#!/usr/bin/env node /** * checkReference.mjs — PLAN.md §12, added in phase 8. * * The Reference section names things: every environment variable, every config key, every * installer command, every canonical document. §1 forbids re-specifying a contract, and * this is the machinery that makes writing the NAMES down safe anyway — the same bargain * checkQuickstart.mjs struck for the quickstart, applied to six more sources. * * Each enumeration in `src/data/reference.mjs` is compared against its authority, read from * the repository that owns it over the Gitea API — never from a working tree, per §1's * process rule. Every comparison is a SET comparison in both directions: * * - a name this site lists that the source no longer has fails (the reference is stale); * - a name the source has that this site does not list fails (the reference is * incomplete, which is the failure mode a hand-maintained list actually has). * * The second direction is the one that earns its keep. A reference page does not usually * rot by describing something that vanished — it rots by quietly not mentioning the three * things added since it was written. * * Descriptions are deliberately NOT checked. Nothing here can know whether a one-line * summary is still true, so it does not pretend to; keeping them terse is the mitigation. * * GITEA_TOKEN= node scripts/checkReference.mjs * * Anonymous raw fetches fail on this instance, so the token is required. A check that * silently skips itself is worse than no check. */ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { envVars, sidecarConfig, installerCommands, bridgeCfg, visibilityLadder, canonicalDocs, } from '../src/data/reference.mjs'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8')); const BASE = platform.gitea.base; const ORG = platform.gitea.org; const TOKEN = process.env.GITEA_TOKEN?.trim(); const failures = []; const checked = []; const ok = (what) => checked.push(what); const fail = (what, detail) => failures.push({ what, detail }); /** Same file accessor checkFacts.mjs and checkQuickstart.mjs use, CDN caveat included. */ async function raw(repo, filePath, ref = 'main') { const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`; const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } }); if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`); const meta = await res.json(); if (meta.encoding !== 'base64' || typeof meta.content !== 'string') { throw new Error( `${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).` ); } return Buffer.from(meta.content, 'base64').toString('utf8'); } /** * The one comparison this whole script performs, so the failure messages are identical * everywhere and say which direction broke. */ function compareSets(label, mine, theirs, hint) { const mineSet = new Set(mine); const theirsSet = new Set(theirs); const stale = [...mineSet].filter((k) => !theirsSet.has(k)); const missing = [...theirsSet].filter((k) => !mineSet.has(k)); for (const k of stale) { fail(`${label}: ${k}`, `listed here, but ${hint} no longer has it — remove it, and re-read the prose around it`); } for (const k of missing) { fail(`${label}: ${k}`, `is in ${hint} and NOT listed here — add it, or the reference is lying by omission`); } if (!stale.length && !missing.length) ok(`${label} (${mineSet.size})`); } /** `KEY=value` lines. Commented-out suggestions are prose about a variable, not a key. */ const envKeysOf = (text) => text .split(/\r?\n/) .map((l) => l.match(/^([A-Z][A-Z0-9_]*)=/)) .filter(Boolean) .map((m) => m[1]); /** `Key=value` lines from the plugin's config, same rule about comments. */ const cfgKeysOf = (text) => text .split(/\r?\n/) .map((l) => l.match(/^([A-Za-z][A-Za-z0-9]*)=/)) .filter(Boolean) .map((m) => m[1]); async function run() { if (!TOKEN) { console.error('checkReference: GITEA_TOKEN is not set. This check cannot run anonymously.'); process.exit(2); } // ── 1. Environment variables ────────────────────────────────────────────── compareSets( 'env', Object.keys(envVars), envKeysOf(await raw('website', '.env.example')), 'website main:.env.example', ); // ── 2. sidecar.toml ─────────────────────────────────────────────────────── // // Parsed from the serde structs rather than from a sample file, because the sample is // GENERATED by the binary on first run and no committed copy is authoritative. Each // `pub name: T` inside a `struct XCfg` is one key, and the struct name gives the section. const configRs = await raw('link', 'sidecar/src/config.rs'); const sidecarKeys = []; for (const m of configRs.matchAll(/struct\s+(\w+)Cfg\s*\{([\s\S]*?)\n\}/g)) { const section = m[1].toLowerCase(); for (const f of m[2].matchAll(/pub\s+(\w+)\s*:/g)) sidecarKeys.push(`${section}.${f[1]}`); } compareSets('sidecar.toml', Object.keys(sidecarConfig), sidecarKeys, 'link main:sidecar/src/config.rs'); // ── 3. Installer commands ───────────────────────────────────────────────── const cliRs = await raw('installer', 'src/cli.rs'); const cmdBlock = cliRs.match(/enum\s+Command\s*\{([\s\S]*?)\n\}/); const cmds = cmdBlock ? [...cmdBlock[1].matchAll(/^\s*([A-Z]\w*)\s*[,{]/gm)].map((m) => m[1]) : []; compareSets('installer command', Object.keys(installerCommands), cmds, 'installer main:src/cli.rs'); // ── 4. Bridge.cfg ───────────────────────────────────────────────────────── const bridgeKeys = Object.values(bridgeCfg).flatMap((group) => Object.keys(group)); compareSets( 'Bridge.cfg', bridgeKeys, cfgKeysOf(await raw('servuo-plugins', 'overlay/Config/Bridge.cfg')), 'servuo-plugins main:overlay/Config/Bridge.cfg', ); // ── 5. The visibility ladder ────────────────────────────────────────────── // // A security boundary, so it is checked against the module that enforces it rather than // against prose. The order matters as much as the membership: it is a ladder, and a // reader reasoning about "staff and above" needs the rungs in the right sequence. const vis = await raw('Module-uo', 'server/utils/shardVisibility.js'); const ladderMatch = vis.match(/const\s+LADDER\s*=\s*\[([\s\S]*?)\]/); const ladder = ladderMatch ? [...ladderMatch[1].matchAll(/'([a-z_]+)'/g)].map((m) => m[1]) : []; if (ladder.length === 0) { fail('visibility ladder', 'could not find LADDER in Module-uo main:server/utils/shardVisibility.js'); } else if (ladder.join(' ') !== visibilityLadder.join(' ')) { fail( 'visibility ladder', `order or membership differs — here "${visibilityLadder.join(' → ')}", upstream "${ladder.join(' → ')}"`, ); } else ok(`visibility ladder (${ladder.length} rungs, in order)`); // ── 6. Canonical documents ──────────────────────────────────────────────── // // Existence only. A link to a document that moved is the single most likely way this // section breaks, and it is exactly what a build can answer. for (const docPath of Object.keys(canonicalDocs)) { const url = `${BASE}/api/v1/repos/${ORG}/docs/contents/${docPath}?ref=main`; const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } }); if (res.ok) ok(`canonical doc ${docPath}`); else fail(`canonical doc ${docPath}`, `not found in docs main (HTTP ${res.status})`); } // ── Report ──────────────────────────────────────────────────────────────── if (failures.length === 0) { console.log(`checkReference: ${checked.length} enumeration check(s) passed against their sources.`); return; } console.error(`\ncheckReference: ${failures.length} disagreement(s) with the platform:\n`); for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`); console.error(` The Reference section names things, which is only safe while the names are checked (§1, and the same bargain checkQuickstart.mjs struck). Update src/data/reference.mjs to match the source. Do not "fix" the check. `); process.exit(1); } run().catch((err) => { console.error(`checkReference: ${err.message}`); process.exit(2); });