All checks were successful
PR checks / checks (pull_request) Successful in 8m25s
platform.json drops the single protocol/bundle/capability list for a `games` object (uo, rust) plus a `runicnpc` block. checkFacts.mjs loops the protocol, plugin-protocol, capability and bundle checks over both games, reads the schema-2 bundles at v2/<game>/current.json, checks each module's coreApi, and reads RunicNPC's plugin.toml. 41 facts, up from 19; clears the two that were red on main (Module API 1.17.0, installer v0.8.0). D66: the hero, footer and structured data show the Module API only; each game's protocol and bundle go on that game's pages, and the docs landing table splits into shared versions plus one column per game. The re-read section 1 requires found, and this fixes: - three hard-coded "1.6.0" Module API versions in the module docs - module-lifecycle saying modules load alphabetically (the loader now refuses a second module, W19), and the install pages never mentioning the one-module rule - "there is one module" in five places, now two modules both built inside this project; the Integration Kit keeps its draft banner - the homepage diagram's ServUO/uo-link sub-labels (D63 wording now) notBuilt drops `second-module` (resolved by Module-Rust's release) and rewords `multi-module`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
495 lines
21 KiB
JavaScript
495 lines
21 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;
|
|
}
|
|
|
|
/**
|
|
* A file's bytes, read through the `contents` endpoint rather than `raw`.
|
|
*
|
|
* `raw` answers with `Cache-Control: public, max-age=21600`, so the CDN in front of Gitea
|
|
* serves a copy for six hours and this check can read a blob most of a working day old.
|
|
* That is not theoretical: on the day of the engagement cutover it reported website's
|
|
* MODULE_API_VERSION as 1.6.0 -- the value from two weeks earlier -- and failed a site
|
|
* whose number was right. A check that goes red on stale data is a check people learn to
|
|
* ignore, which is the one failure mode this file exists to avoid.
|
|
*
|
|
* `contents` answers `private, must-revalidate`, which the CDN does not cache, so it is
|
|
* always the ref's current blob. The cost is a JSON parse and a base64 decode.
|
|
*/
|
|
async function raw(repo, filePath, ref) {
|
|
const meta = await json(`${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`);
|
|
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');
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
// Every game the site quotes facts for (PLAN.md §16). Checks 1, 2, 4 and 5 run once per
|
|
// game, and a row's label carries the game's key so a failure names which one moved.
|
|
const GAMES = Object.entries(platform.games);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Each game's wire protocol, from its sidecar
|
|
// ---------------------------------------------------------------------------
|
|
async function checkProtocol() {
|
|
for (const [key, game] of GAMES) {
|
|
const authority = `${game.sidecarRepo} main:sidecar/src/main.rs`;
|
|
const src = await raw(game.sidecarRepo, 'sidecar/src/main.rs', 'main');
|
|
const value = extract(
|
|
src,
|
|
/pub const PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)\s*;/,
|
|
'PROTOCOL_VERSION',
|
|
authority
|
|
);
|
|
record(`${key} protocol (sidecar)`, game.protocol, Number(value), authority);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. Each game plugin'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 plugin. If these two ever
|
|
// disagree, the site must not print either number. Both games keep the file under the
|
|
// same name at the root of their plugin repository.
|
|
// ---------------------------------------------------------------------------
|
|
async function checkOverlayProtocol() {
|
|
for (const [key, game] of GAMES) {
|
|
const authority = `${game.pluginRepo} main:overlay.toml`;
|
|
const toml = await raw(game.pluginRepo, 'overlay.toml', 'main');
|
|
const value = extract(toml, /^\s*protocol\s*=\s*(\d+)\s*$/m, 'protocol', authority);
|
|
record(`${key} protocol (plugin)`, game.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 each game's 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() {
|
|
for (const [key, game] of GAMES) {
|
|
const authority = `${game.module.repo} main:module.json`;
|
|
const manifest = JSON.parse(await raw(game.module.repo, 'module.json', 'main'));
|
|
const declared = [...(manifest.capabilities || [])].sort();
|
|
const expected = [...game.module.capabilities].sort();
|
|
|
|
record(`${key} module capabilities`, expected.join(' '), declared.join(' '), authority);
|
|
// The range the module declares against core. The manifest docs use it as the worked
|
|
// example of a caret range resolving across minor bumps, so it is a quoted fact.
|
|
record(`${key} module coreApi`, game.module.coreApi, manifest.coreApi, authority);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. Each game's current bundle
|
|
//
|
|
// One manifest per game on the installer's `bundles` branch, at
|
|
// `v2/<game>/current.json` (schema 2). The ROOT `current.json` is the schema-1 manifest
|
|
// from before the installer learned a second game; it is frozen and no longer read here,
|
|
// because a check against a file that stopped moving passes forever.
|
|
//
|
|
// Each bundle is protocol-checked by CI when it is published, so its protocol is a third
|
|
// independent read of the same number rather than a duplicate of checks 1 and 2.
|
|
// ---------------------------------------------------------------------------
|
|
async function readBundle(game) {
|
|
const file = `v2/${game.bundleGame}/current.json`;
|
|
const authority = `installer bundles:${file}`;
|
|
const current = JSON.parse(await raw('installer', file, 'bundles'));
|
|
if (current.schema !== 2) {
|
|
throw new Error(`${authority} is schema ${current.schema}, and this check reads schema 2.`);
|
|
}
|
|
return { current, authority };
|
|
}
|
|
|
|
async function checkBundle() {
|
|
const { uo, rust } = platform.games;
|
|
|
|
{
|
|
const { current, authority } = await readBundle(uo);
|
|
record('uo bundle.tag', uo.bundle.tag, current.bundle, authority);
|
|
record('uo bundle.sidecar', uo.bundle.sidecar, current.sidecar?.tag, authority);
|
|
record('uo bundle.overlay', uo.bundle.overlay, current.payload?.tag, authority);
|
|
record('uo bundle.servuoMin', uo.bundle.servuoMin, current.payload?.compat?.min_version, authority);
|
|
record('uo protocol (bundle)', uo.protocol, current.protocol, authority);
|
|
}
|
|
|
|
{
|
|
const { current, authority } = await readBundle(rust);
|
|
const compat = current.payload?.compat ?? {};
|
|
record('rust bundle.tag', rust.bundle.tag, current.bundle, authority);
|
|
record('rust bundle.sidecar', rust.bundle.sidecar, current.sidecar?.tag, authority);
|
|
record('rust bundle.plugin', rust.bundle.plugin, current.payload?.tag, authority);
|
|
record('rust bundle.oxideMin', rust.bundle.oxideMin, compat.frameworks?.oxide?.min_version, authority);
|
|
record('rust bundle.carbonMin', rust.bundle.carbonMin, compat.frameworks?.carbon?.min_version, authority);
|
|
record(
|
|
'rust bundle.requiresPlugins',
|
|
[...rust.bundle.requiresPlugins].sort().join(' '),
|
|
[...(compat.requires_plugins ?? [])].sort().join(' '),
|
|
authority
|
|
);
|
|
record('rust bundle.runicnpc', rust.bundle.runicnpc, current.npc?.tag, authority);
|
|
record('rust protocol (bundle)', rust.protocol, current.protocol, authority);
|
|
// The bundle carries RunicNPC's API too; check 5b reads it from the plugin's own repo.
|
|
record('runicnpc api (bundle)', platform.runicnpc.api, current.npc?.api, authority);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5b. RunicNPC's own declarations
|
|
//
|
|
// RunicNPC stands alone as well as shipping in the Rust bundle (D61), so its facts are
|
|
// read from its own `plugin.toml` too — the file its release workflow folds into the
|
|
// manifest the installer and the egg read. The API version is the number a plugin author
|
|
// calling `RunicNpc_*` relies on, and the site quotes it beside the API reference (D62).
|
|
// ---------------------------------------------------------------------------
|
|
async function checkRunicNpc() {
|
|
const authority = 'runicnpc-rust main:plugin.toml';
|
|
const toml = await raw('runicnpc-rust', 'plugin.toml', 'main');
|
|
const npc = platform.runicnpc;
|
|
|
|
record('runicnpc api', npc.api, Number(extract(toml, /^\s*api\s*=\s*(\d+)\s*$/m, 'api', authority)), authority);
|
|
record(
|
|
'runicnpc oxideMin',
|
|
npc.oxideMin,
|
|
extract(toml, /^\s*min_oxide_version\s*=\s*"([^"]+)"/m, 'min_oxide_version', authority),
|
|
authority
|
|
);
|
|
record(
|
|
'runicnpc carbonMin',
|
|
npc.carbonMin,
|
|
extract(toml, /^\s*min_carbon_version\s*=\s*"([^"]+)"/m, 'min_carbon_version', authority),
|
|
authority
|
|
);
|
|
const required = extract(toml, /^\s*requires_plugins\s*=\s*\[([^\]]*)\]/m, 'requires_plugins', authority)
|
|
.split(',')
|
|
.map((name) => name.trim().replace(/^"|"$/g, ''))
|
|
.filter(Boolean);
|
|
record(
|
|
'runicnpc requiresPlugins',
|
|
[...npc.requiresPlugins].sort().join(' '),
|
|
required.sort().join(' '),
|
|
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. The Android APK `/app/` offers, and the Android version it claims to need
|
|
//
|
|
// The app is on no store, so the download block on `/app/` links straight at a release
|
|
// asset — the one kind of link on this site that 404s the moment a filename changes,
|
|
// because the filename carries the version. Both asset names are asserted against
|
|
// releases/latest, so a release that renames or drops either turns this repo red before a
|
|
// visitor finds a dead link.
|
|
//
|
|
// `minSdk` is checked for a different reason. "Android 10 or newer" is prose derived from a
|
|
// number, and it is exactly the kind of derived claim §12 exists to stop rotting: raising
|
|
// the minimum in the app would otherwise leave this site telling people with Android 10
|
|
// that it works for them. The mapping from API level to the marketing version is a fixed
|
|
// table, so checking the number is enough to protect the sentence.
|
|
//
|
|
// What is NOT checked is `serviceable` — see the comment beside it in platform.json. No
|
|
// fetch can tell whether a build works, so that value is a person's word, and the site
|
|
// treats it as the gate on the link rather than the link as the gate on itself.
|
|
// ---------------------------------------------------------------------------
|
|
async function checkAndroidApk() {
|
|
const authority = 'Android-app releases/latest assets';
|
|
const release = await json('Android-app/releases/latest');
|
|
const names = new Set((release.assets || []).map((asset) => asset.name));
|
|
|
|
const apk = platform.androidApk;
|
|
record(`apk asset`, true, names.has(apk.asset), `${authority} → ${apk.asset}`);
|
|
record(`apk checksums`, true, names.has(apk.checksums), `${authority} → ${apk.checksums}`);
|
|
|
|
// The asset name carries the version, so it has to agree with the release this site
|
|
// already quotes — a mismatch here means one of the two was updated alone.
|
|
const tag = String(release.tag_name || '').replace(/^v/, '');
|
|
record('apk names the release', true, apk.asset.includes(tag), `${authority} → ${release.tag_name}`);
|
|
|
|
const gradleAuthority = 'Android-app main:app/build.gradle.kts';
|
|
const gradle = await raw('Android-app', 'app/build.gradle.kts', 'main');
|
|
const minSdk = Number(
|
|
extract(gradle, /minSdk\s*=\s*(\d+)/, 'minSdk', gradleAuthority)
|
|
);
|
|
record('android minSdk', apk.minSdk, minSdk, gradleAuthority);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 9. 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']);
|
|
|
|
/**
|
|
* Domains reserved by RFC 2606 and RFC 6761 for documentation and examples.
|
|
*
|
|
* Phase 5 is what needed this, and the exemption is principled rather than a concession.
|
|
* The rule being enforced is that no CONTACT address appears outside `brand.json` (D13), so
|
|
* that changing the published address stays a file copy. An `example.com` address cannot be
|
|
* a contact address — the domain is reserved precisely so that documentation can use it and
|
|
* it can never route to anybody — so exempting these weakens nothing.
|
|
*
|
|
* Without it the rule would have forbidden the signup form's `placeholder="you@example.com"`
|
|
* and the CLI's usage line, which is the check telling somebody to write a worse page in
|
|
* order to satisfy a rule about a different problem. A check people have to work around is
|
|
* one they eventually switch off.
|
|
*
|
|
* Matched on the domain, not on the exact address, because these appear with whatever local
|
|
* part reads best in context.
|
|
*/
|
|
const RESERVED_DOMAINS = /@(?:[a-z0-9-]+\.)*(?:example\.(?:com|net|org)|example|invalid|test|localhost)$/i;
|
|
|
|
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;
|
|
if (RESERVED_DOMAINS.test(match[0])) 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,
|
|
checkRunicNpc,
|
|
checkReleases,
|
|
checkWebsiteHasNoReleases,
|
|
checkAndroidApk,
|
|
];
|
|
|
|
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();
|