The three checks that read another repository -- checkFacts, checkQuickstart,
checkReference -- fetched source files from the API's `raw` route, which answers
`Cache-Control: public, max-age=21600`. The CDN in front of Gitea caches that,
so the checks can read a blob most of a working day old.
It bit on cutover day. checkFacts reported
FAIL moduleApi
platform.json says : 1.9.0
website main:server/src/modules/version.js says : 1.6.0
against a `main` that says 1.9.0 -- the served copy was two weeks old
(`cf-cache-status: HIT`, `Age: 15713`, `last-modified: 18 Aug`). No edit in this
repository could have made it pass, and the same run reported a bundle triple
that had already been republished as still current: a stale read fails BOTH
ways, and the false pass is the dangerous one.
The `contents` endpoint answers `private, must-revalidate`, which the CDN
bypasses, so it is always the ref's current blob. The cost is a JSON parse and a
base64 decode. checkReference's canonical-document existence loop already used
it, which is why that half was never affected.
PLAN.md 12 records the finding next to the check it constrains.
Co-Authored-By: Claude <noreply@anthropic.com>
198 lines
9.0 KiB
JavaScript
198 lines
9.0 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 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);
|
|
});
|