Files
runicgateway.com/scripts/checkReference.mjs
wtclaude d89ce06bb8
All checks were successful
PR checks / checks (pull_request) Successful in 1m13s
docs(builder): phase 8 — modules, architecture and reference
Twenty pages completing the tree section 10 planned: Modules (8), Architecture
(5) and Reference (7). Four decisions, D38-D41, recorded in PLAN.md section 10.

D39 is the one that shaped the phase. Section 1 forbids re-specifying a
contract, and a Reference section is exactly where that rule is most tempting to
break, so the line is drawn at names: every environment variable, config key,
installer command, visibility rung and canonical document is listed with one
terse line saying what it is FOR, while shapes, semantics and every "why" stay
in the canonical document.

That is only safe because the names are checked. checkReference.mjs compares six
enumerations against the repositories that own them, over the Gitea API, as set
comparisons in BOTH directions -- and the second direction is the one that earns
its keep, because a reference page does not usually rot by describing something
that vanished, it rots by quietly not mentioning what was added since.

The check went green on its first run, which is the least trustworthy possible
outcome, so it was verified by breaking it: seven mutations, all caught. The one
worth keeping is the visibility ladder REORDERED with its membership unchanged
-- it is a security boundary, and a set comparison alone would have passed it.

D41 turns plannedSidebar from a checklist into a checked invariant, and finding
out why was the phase's first defect: it had already drifted, because phase 7
added the Content page under D37 and never updated the list. Nothing failed,
because nothing read it. checkSidebar.mjs now asserts the two trees agree on
groups, labels and order -- order because the order of Getting started IS the
installation path.

Two more things the writing found. PLAN.md's page count was wrong and had been
since section 10 was written ("roughly 38, 37 planned" for a tree of forty).
And module.json's `mounts` and the SPA's paths are different mechanisms that no
single document stated plainly -- module-uo declares admin: ["/shard",
"/uo-link"] while its screen lives at /admin/uo/link, because API routes are
deliberately NOT namespaced while SPA routes are. That is precisely the
distinction the installer got wrong in v0.1.0, and it now has a named home.

D40: the docs link to /architecture/'s drawn diagrams rather than importing
them. Those components carry marketing chrome and depend on diagram.css, which
Starlight does not load; the docs use text diagrams, which paste into an issue.

npm run verify green: 40 pages across 5 groups agree with plannedSidebar, 2390
internal links resolve, 123 repository links point at a branch, 19 facts, 59
quickstart checks, 22 reference enumerations, astro check 0 errors, 36 tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 12:17:31 -05:00

192 lines
8.7 KiB
JavaScript

#!/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=<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 raw-file accessor checkFacts.mjs and checkQuickstart.mjs use. */
async function raw(repo, filePath, ref = 'main') {
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${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}`);
return res.text();
}
/**
* 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);
});