All checks were successful
PR checks / checks (pull_request) Successful in 49s
Replaces phase 1's scaffold with the real homepage: hero, the data path as
inline SVG, the self-hosted argument, all five capability groups, and the
get-started CTA. Three decisions the org lead took first are recorded in
PLAN.md as D17-D19.
The data path is drawn generically and captioned specifically (D17): the nodes
say "your game server" and "sidecar", the sub-labels and caption name ServUO and
uo-link. The SVG is aria-hidden because the four numbered steps beside it carry
the same path in prose — one telling, not two.
The capability list is data with a check behind it (D18). Every Game-intelligence
item names the module-uo capability slug it comes from, and the build fails if
the page and platform.json disagree either way. That needed a fifteenth fact in
checkFacts.mjs: §12 named the capability list as an externally-sourced fact and
nothing re-read it, so the chain rested on someone remembering. It also found
that the site was omitting two of the module's eight capabilities — guilds and
city governors are now listed, in the page and in §10.
The hero leads with the emblem (D19), derived from whichever logo.png is in
force so one file still changes the hero, header, tab icon and app icon
together.
Also here, both found by standing the build up rather than by review:
- checkBrand.mjs now enforces the demo slot's markup contract. applyBrand.mjs
reveals the demo link by replacing an exact pair of empty attributes; an
attribute inserted between them produces a build where the mount sets a demo
URL, the boot log says nothing and the link never appears. Both halves are
checked and the literal is derived from the expression applyBrand.mjs uses,
so they cannot drift.
- The header nav overflowed at 390px — four links plus the lockup measured
433px against a 390px viewport, so every phone got a horizontally scrolling
page. Phase 1 left this to phase 3 expecting a disclosure control; it got a
wrap instead, because with four links there is nothing to disclose and a
hamburger costs state, script and duplicate markup.
Verified on a clean checkout of this commit: all four checks, astro check, a
production build, a live /brand/* smoke, and a demo URL mounted and reverted.
Co-Authored-By: Claude <noreply@anthropic.com>
330 lines
13 KiB
JavaScript
330 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* checkFacts.mjs — PLAN.md §12
|
|
*
|
|
* The site quotes versions, protocol numbers and capability lists. §1 records why that
|
|
* needs a mechanism rather than diligence: an earlier draft of the plan confidently stated
|
|
* the platform was on protocol 3, because every checkout in the workspace sat on a feature
|
|
* branch whose local `main` ref had never been fetched, and `git show main:<path>` answered
|
|
* from a months-old blob.
|
|
*
|
|
* So: every fact in `src/data/platform.json` is re-read here from its authority over the
|
|
* Gitea API — never from a working tree — and any disagreement fails the build. When the
|
|
* platform moves, this repo goes red so someone updates the site. That failure is the
|
|
* feature, the same as the Integration Kit's checkCoreApi.js.
|
|
*
|
|
* It also enforces one rule that is not a version: no email address may appear in the
|
|
* source outside `brand-default/brand.json` (D13). The published contact is a personal
|
|
* address that is meant to stay replaceable by a file copy, and that promise survives
|
|
* exactly as long as nobody types the address into a paragraph.
|
|
*
|
|
* GITEA_TOKEN=<token> node scripts/checkFacts.mjs
|
|
*
|
|
* Anonymous raw fetches fail on this instance, so the token is required rather than
|
|
* optional. A check that silently skips itself is worse than no check.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { readdir } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
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 = [];
|
|
|
|
function record(label, expected, actual, authority) {
|
|
const ok = String(expected) === String(actual);
|
|
(ok ? checked : failures).push({
|
|
label,
|
|
expected,
|
|
actual,
|
|
expectedLabel: 'platform.json says',
|
|
authority: `${authority} says`,
|
|
ok,
|
|
});
|
|
}
|
|
|
|
async function api(pathname) {
|
|
const url = `${BASE}/api/v1/repos/${ORG}/${pathname}`;
|
|
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
|
if (!res.ok) {
|
|
throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
|
}
|
|
return res;
|
|
}
|
|
|
|
const raw = async (repo, filePath, ref) =>
|
|
(await api(`${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`)).text();
|
|
|
|
const json = async (pathname) => (await api(pathname)).json();
|
|
|
|
/** The first capture group of `re` in `text`, or a thrown error naming what was looked for. */
|
|
function extract(text, re, what, authority) {
|
|
const m = text.match(re);
|
|
if (!m) throw new Error(`Could not find ${what} in ${authority} — the file's shape changed.`);
|
|
return m[1];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. The wire protocol, from the sidecar itself
|
|
// ---------------------------------------------------------------------------
|
|
async function checkProtocol() {
|
|
const authority = 'link main:sidecar/src/main.rs';
|
|
const src = await raw('link', 'sidecar/src/main.rs', 'main');
|
|
const value = extract(
|
|
src,
|
|
/pub const PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)\s*;/,
|
|
'PROTOCOL_VERSION',
|
|
authority
|
|
);
|
|
record('protocol (sidecar)', platform.protocol, Number(value), authority);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. The overlay's declared protocol — the third declaration site
|
|
//
|
|
// CLAUDE.md: a protocol bump has to land in overlay.toml in the same PR as the emitters,
|
|
// or the installer refuses to pair the sidecar with the overlay. If these two ever
|
|
// disagree, the site must not print either number.
|
|
// ---------------------------------------------------------------------------
|
|
async function checkOverlayProtocol() {
|
|
const authority = 'servuo-plugins main:overlay.toml';
|
|
const toml = await raw('servuo-plugins', 'overlay.toml', 'main');
|
|
const value = extract(toml, /^\s*protocol\s*=\s*(\d+)\s*$/m, 'protocol', authority);
|
|
record('protocol (overlay)', platform.protocol, Number(value), authority);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. The Module API version
|
|
// ---------------------------------------------------------------------------
|
|
async function checkModuleApi() {
|
|
const authority = 'website main:server/src/modules/version.js';
|
|
const src = await raw('website', 'server/src/modules/version.js', 'main');
|
|
const value = extract(
|
|
src,
|
|
/MODULE_API_VERSION\s*=\s*['"]([^'"]+)['"]/,
|
|
'MODULE_API_VERSION',
|
|
authority
|
|
);
|
|
record('moduleApi', platform.moduleApi, value, authority);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. The capabilities the installed module actually declares
|
|
//
|
|
// §12 names "module-uo's capability list" as one of the facts platform.json holds, and it
|
|
// was the one fact nothing re-read. That mattered from phase 3 onwards, because the
|
|
// homepage renders the list rather than merely storing it: `src/data/capabilities.mjs`
|
|
// asserts at build time that every declared slug is claimed by a named capability on the
|
|
// page and vice versa. Without this check that assertion was anchored to a local copy
|
|
// nobody was verifying, so the whole chain rested on someone remembering.
|
|
//
|
|
// Sorted before comparing: the manifest's order is the module's business, and a reordered
|
|
// array is not a changed capability set. A slug appearing or disappearing is.
|
|
// ---------------------------------------------------------------------------
|
|
async function checkModuleCapabilities() {
|
|
const authority = 'Module-uo main:module.json';
|
|
const manifest = JSON.parse(await raw('Module-uo', 'module.json', 'main'));
|
|
const declared = [...(manifest.capabilities || [])].sort();
|
|
const expected = [...platform.moduleUoCapabilities].sort();
|
|
|
|
record('moduleUoCapabilities', expected.join(' '), declared.join(' '), authority);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. The current bundle
|
|
//
|
|
// The manifests live at the ROOT of the `bundles` branch — `current.json`,
|
|
// `bundle-<tag>.json` — not under `bundles/`. Fetching the directory 404s.
|
|
// ---------------------------------------------------------------------------
|
|
async function checkBundle() {
|
|
const authority = 'installer bundles:current.json';
|
|
const current = JSON.parse(await raw('installer', 'current.json', 'bundles'));
|
|
|
|
record('bundle.tag', platform.bundle.tag, current.bundle, authority);
|
|
record('bundle.sidecar', platform.bundle.sidecar, current.link?.tag, authority);
|
|
record('bundle.overlay', platform.bundle.overlay, current.overlay?.tag, authority);
|
|
record(
|
|
'bundle.servuoMin',
|
|
platform.bundle.servuoMin,
|
|
current.overlay?.servuo?.min_version,
|
|
authority
|
|
);
|
|
// The bundle is protocol-checked by CI when it is published, so this is a third
|
|
// independent read of the same number rather than a duplicate of check 1.
|
|
record('protocol (bundle)', platform.protocol, current.protocol, authority);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 6. Release versions, per repo
|
|
// ---------------------------------------------------------------------------
|
|
async function checkReleases() {
|
|
for (const [repo, expected] of Object.entries(platform.releases)) {
|
|
const authority = `${repo} releases/latest`;
|
|
const release = await json(`${repo}/releases/latest`);
|
|
record(`release ${repo}`, expected, release.tag_name, authority);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 7. `website` still publishes nothing
|
|
//
|
|
// It ships as container images and is never tagged, so the site refers to the platform by
|
|
// bundle tag and Module API version instead. The day that changes, this repo should notice
|
|
// rather than keep quietly omitting a version that now exists.
|
|
// ---------------------------------------------------------------------------
|
|
async function checkWebsiteHasNoReleases() {
|
|
const authority = 'website releases (expected empty)';
|
|
const releases = await json('website/releases');
|
|
record('websiteHasReleases', platform.websiteHasReleases, releases.length > 0, authority);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 8. D13 — the contact address lives in exactly one file
|
|
// ---------------------------------------------------------------------------
|
|
const CONTACT_CHECK = 'contact address (D13)';
|
|
|
|
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
|
|
|
const SCAN_DIRS = ['src', 'scripts'];
|
|
const SCAN_EXT = new Set([
|
|
'.astro', '.css', '.js', '.mjs', '.ts', '.tsx', '.json', '.md', '.mdx', '.svg', '.html',
|
|
]);
|
|
// Addresses that are not a contact route: the AI-disclosure trailer the org requires on
|
|
// every commit, and the noreply Gitea uses for the bot identity.
|
|
const ALLOWED_ADDRESSES = new Set(['noreply@anthropic.com', 'claude@whitlocktech.net']);
|
|
|
|
async function* walk(dir) {
|
|
let entries;
|
|
try {
|
|
entries = await readdir(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
yield* walk(full);
|
|
} else if (SCAN_EXT.has(path.extname(entry.name))) {
|
|
yield full;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function checkContactAddressIsIsolated() {
|
|
const offenders = [];
|
|
for (const dir of SCAN_DIRS) {
|
|
for await (const file of walk(path.join(ROOT, dir))) {
|
|
const text = readFileSync(file, 'utf8');
|
|
for (const match of text.matchAll(EMAIL_RE)) {
|
|
if (ALLOWED_ADDRESSES.has(match[0].toLowerCase())) continue;
|
|
const line = text.slice(0, match.index).split('\n').length;
|
|
offenders.push(`${path.relative(ROOT, file)}:${line} — ${match[0]}`);
|
|
}
|
|
}
|
|
}
|
|
if (offenders.length) {
|
|
failures.push({
|
|
label: CONTACT_CHECK,
|
|
expectedLabel: 'PLAN.md D13 requires',
|
|
expected: 'no email address in src/ or scripts/',
|
|
authority: ' found',
|
|
actual: `${offenders.length}:\n ` + offenders.join('\n '),
|
|
ok: false,
|
|
});
|
|
} else {
|
|
checked.push({ label: CONTACT_CHECK, expected: 'none', actual: 'none', ok: true });
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function main() {
|
|
if (!TOKEN) {
|
|
console.error(
|
|
'checkFacts: GITEA_TOKEN is not set.\n\n' +
|
|
' Anonymous raw fetches fail on this Gitea instance, and a fact check that skips\n' +
|
|
' itself is worse than no fact check — a stale version would ship silently.\n\n' +
|
|
' Locally: GITEA_TOKEN=<a token that can read the org> npm run check:facts\n' +
|
|
' In CI: already wired — .gitea/workflows/pr-checks.yml maps the org-level\n' +
|
|
' REGISTRY_TOKEN secret into GITEA_TOKEN for this step.\n'
|
|
);
|
|
process.exit(2);
|
|
}
|
|
|
|
await checkContactAddressIsIsolated();
|
|
|
|
const network = [
|
|
checkProtocol,
|
|
checkOverlayProtocol,
|
|
checkModuleApi,
|
|
checkModuleCapabilities,
|
|
checkBundle,
|
|
checkReleases,
|
|
checkWebsiteHasNoReleases,
|
|
];
|
|
|
|
for (const check of network) {
|
|
try {
|
|
await check();
|
|
} catch (error) {
|
|
failures.push({
|
|
label: check.name,
|
|
expected: 'a readable authority',
|
|
actual: error.message,
|
|
authority: 'Gitea API',
|
|
ok: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const row of checked) {
|
|
console.log(` ok ${row.label.padEnd(28)} ${row.actual}`);
|
|
}
|
|
|
|
if (!failures.length) {
|
|
console.log(
|
|
`\ncheckFacts: ${checked.length} facts agree with their authorities ` +
|
|
`(platform.json verified ${platform.verifiedOn}).`
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.error('\ncheckFacts: the platform has moved, or the site is wrong.\n');
|
|
for (const row of failures) {
|
|
console.error(` FAIL ${row.label}`);
|
|
console.error(` ${row.expectedLabel} : ${row.expected}`);
|
|
console.error(` ${row.authority} : ${row.actual}`);
|
|
console.error('');
|
|
}
|
|
|
|
if (failures.some((row) => row.label === CONTACT_CHECK)) {
|
|
console.error(
|
|
'The contact address belongs in brand-default/brand.json and nowhere else. D13\n' +
|
|
'publishes a personal address on the promise that replacing it later costs one\n' +
|
|
'file copy, and an address typed into a page is a copy no mount can reach.\n' +
|
|
'Read it through src/lib/brand.mjs instead.\n'
|
|
);
|
|
}
|
|
|
|
if (failures.some((row) => row.label !== CONTACT_CHECK)) {
|
|
console.error(
|
|
'Update src/data/platform.json AND re-read every page that quotes the changed value.\n' +
|
|
'Do not edit a value here to make this pass — §1 exists because that is how the\n' +
|
|
'wrong protocol number got written down in the first place.\n'
|
|
);
|
|
}
|
|
|
|
process.exit(1);
|
|
}
|
|
|
|
await main();
|