All checks were successful
PR checks / checks (pull_request) Successful in 2m11s
PLAN.md §16 phase 16, with the org lead's D72-D74 (count of record 74). - Eight pages nested in the Rust group at /docs/rust/runicnpc/ (D72): the overview, Install RunicNPC, Commands, NPC profiles, NPC placements, Cost and caps, Profile fields (the one field reference the editor, rnpc.profile set, the data file and the API all share) and The API. - The API page carries every call, hook and data shape (D62), in the site's own words, with no decision numbers and no per-call version history (D74). Signatures print from src/data/runicnpc.mjs through Signature.astro. - checkReference.mjs gains two sections. Against docs main:runicnpc/API.md: the API version, 24 call and 10 hook signatures, and the field names of the profile, RunicNpc_List rows, placements, routes and owners (D73), plus exactly one <Signature> per call on api.mdx. Against runicnpc-rust main:plugin/RunicNPC.cs: console verbs, permissions, place options and config keys. Six planted drifts all failed before it was trusted. - checkSidebar and checkTerms read nested sidebar groups via flatLabels(). - The Rust overview, Events zones and rewards, and Canonical documents link the new pages; the canonical documents gain the four runicnpc/ docs. Both NPC admin screens were walked on the walk site first (D36); nothing was saved. npm run verify is green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
528 lines
24 KiB
JavaScript
528 lines
24 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.
|
|
*
|
|
* Phase 15 (PLAN.md §16) added the Rust group's three: rust-link's `sidecar.toml` and its
|
|
* environment overrides, the bridge plugin's `RunicGateway.json`, and the event kinds with the
|
|
* class each is served under (D71).
|
|
*
|
|
* 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,
|
|
rustSidecarConfig,
|
|
rustSidecarEnv,
|
|
rustPluginConfig,
|
|
rustEventKinds,
|
|
} from '../src/data/reference.mjs';
|
|
import {
|
|
runicnpcOwners,
|
|
runicnpcCalls,
|
|
runicnpcHooks,
|
|
runicnpcListKeys,
|
|
runicnpcPlacementKeys,
|
|
runicnpcRouteKeys,
|
|
runicnpcProfileFields,
|
|
runicnpcCommands,
|
|
runicnpcPermissions,
|
|
runicnpcPlaceOptions,
|
|
runicnpcConfig,
|
|
} from '../src/data/runicnpc.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]);
|
|
|
|
/**
|
|
* A sidecar's TOML keys, parsed from its 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. Both sidecars are written this way, so one parser serves both.
|
|
*/
|
|
function tomlKeysOf(configRs) {
|
|
const keys = [];
|
|
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)) keys.push(`${section}.${f[1]}`);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
/** The body of a C# class, by brace counting from its opening brace. */
|
|
function classBody(source, name) {
|
|
const start = source.search(new RegExp(`class\\s+${name}\\b`));
|
|
if (start < 0) return null;
|
|
const open = source.indexOf('{', start);
|
|
let depth = 0;
|
|
for (let i = open; i < source.length; i++) {
|
|
if (source[i] === '{') depth++;
|
|
else if (source[i] === '}' && --depth === 0) return source.slice(open + 1, i);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The quoted strings inside `const NAME = Object.freeze([ … ])`, comments removed first —
|
|
* the module's comments are prose, and prose has apostrophes.
|
|
*/
|
|
function frozenListOf(source, name) {
|
|
const m = source.match(new RegExp(`const\\s+${name}\\s*=\\s*Object\\.freeze\\(\\[([\\s\\S]*?)\\]\\)`));
|
|
if (!m) return null;
|
|
const code = m[1].replace(/\/\/.*$/gm, '');
|
|
return [...code.matchAll(/'([^'\n]+)'/g)].map((x) => x[1]);
|
|
}
|
|
|
|
/**
|
|
* One section of a Markdown document: from the line starting with `heading` to the next
|
|
* heading of the same or a higher level. Null when there is no such heading, which every
|
|
* caller reports — a section that silently vanished must not read as an empty one.
|
|
*/
|
|
function mdSection(md, heading) {
|
|
const lines = md.split(/\r?\n/);
|
|
const start = lines.findIndex((l) => l.startsWith(heading));
|
|
if (start < 0) return null;
|
|
const level = heading.match(/^#+/)[0].length;
|
|
const stop = new RegExp(`^#{1,${level}} `);
|
|
let end = lines.length;
|
|
for (let i = start + 1; i < lines.length; i++) {
|
|
if (stop.test(lines[i])) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
return lines.slice(start + 1, end).join('\n');
|
|
}
|
|
|
|
/** The backticked names in the first cell of every table row of a section. */
|
|
function firstCellNames(section) {
|
|
return section
|
|
.split('\n')
|
|
.filter((l) => l.startsWith('| `'))
|
|
.map((l) => [...l.split('|')[1].matchAll(/`([^`]+)`/g)].map((m) => m[1]));
|
|
}
|
|
|
|
const collapse = (s) => s.replace(/\s+/g, ' ').trim();
|
|
|
|
/** The top-level keys of the first ```json block in a section. */
|
|
function jsonKeysOf(section) {
|
|
const m = section?.match(/```json\s*\n([\s\S]*?)\n```/);
|
|
if (!m) return null;
|
|
try {
|
|
return Object.keys(JSON.parse(m[1]));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function checkRunicNpcApi(md) {
|
|
const hint = 'docs main:runicnpc/API.md';
|
|
|
|
// The version the specification describes, against the one platform.json quotes (and
|
|
// checkFacts holds to the plugin's own plugin.toml). Together they close the loop.
|
|
const version = Number(md.match(/\*\*API version (\d+)\*\*/)?.[1]);
|
|
if (version !== platform.runicnpc.api) {
|
|
fail('RunicNPC API version', `${hint} describes API ${version || '(none found)'}; platform.json says ${platform.runicnpc.api}`);
|
|
} else ok(`RunicNPC API version (${version})`);
|
|
|
|
// Calls: `### \`RunicNpc_X(params)\` → \`returns\`` headings, compared by whole signature.
|
|
const theirCalls = new Map(
|
|
[...md.matchAll(/^### `(RunicNpc_\w+)\(([^)]*)\)` → `([^`]+)`\s*$/gm)].map((m) => [m[1], collapse(`(${m[2]}) → ${m[3]}`)]),
|
|
);
|
|
const myCalls = new Map(Object.entries(runicnpcCalls).map(([n, c]) => [n, collapse(`(${c.params}) → ${c.returns}`)]));
|
|
compareSignatures('RunicNPC call', myCalls, theirCalls, hint);
|
|
|
|
// Hooks: the first cell of each row of "Hooks it raises".
|
|
const hooksSection = mdSection(md, '## Hooks it raises');
|
|
if (!hooksSection) fail('RunicNPC hooks', `no "## Hooks it raises" section in ${hint}`);
|
|
else {
|
|
const theirHooks = new Map(
|
|
[...hooksSection.matchAll(/^\| `(OnRunicNpc\w+)\(([^)]*)\)` \|/gm)].map((m) => [m[1], collapse(`(${m[2]})`)]),
|
|
);
|
|
const myHooks = new Map(Object.entries(runicnpcHooks).map(([n, h]) => [n, collapse(`(${h.params})`)]));
|
|
compareSignatures('RunicNPC hook', myHooks, theirHooks, hint);
|
|
}
|
|
|
|
// The data shapes, by field name (D73).
|
|
const shape = (label, heading, mine, read) => {
|
|
const section = mdSection(md, heading);
|
|
const theirs = section && read(section);
|
|
if (!theirs || theirs.length === 0) fail(label, `could not read the "${heading}" section of ${hint}`);
|
|
else compareSets(label, mine, theirs, `${hint} "${heading.replace(/^#+ /, '')}"`);
|
|
};
|
|
|
|
shape('RunicNPC owner', '## Owners', Object.keys(runicnpcOwners), (s) => firstCellNames(s).flat());
|
|
shape('RunicNpc_List key', '### `RunicNpc_List', Object.keys(runicnpcListKeys), (s) => firstCellNames(s).flat());
|
|
|
|
// A profile's fields. A cell may name several (`hurtByPlayers`, `hurtsPlayers`), and a bare
|
|
// name after a dotted one shares its prefix (`phases[].damageDealt`, `aimCone`).
|
|
shape(
|
|
'RunicNPC profile field',
|
|
'### A profile',
|
|
Object.values(runicnpcProfileFields).flatMap((group) => Object.keys(group)),
|
|
(s) =>
|
|
firstCellNames(s).flatMap(([first, ...rest]) => {
|
|
const prefix = first.includes('.') ? first.slice(0, first.lastIndexOf('.') + 1) : '';
|
|
return [first, ...rest.map((n) => (n.includes('.') ? n : prefix + n))];
|
|
}),
|
|
);
|
|
|
|
// A placement's keys: the example's, plus the optional ones its bullets introduce.
|
|
shape('RunicNPC placement key', '### A placement', Object.keys(runicnpcPlacementKeys), (s) => {
|
|
const keys = jsonKeysOf(s);
|
|
return keys && [...new Set([...keys, ...[...s.matchAll(/^- `(\w+)`/gm)].map((m) => m[1])])];
|
|
});
|
|
shape('RunicNPC route key', '### A route', Object.keys(runicnpcRouteKeys), jsonKeysOf);
|
|
|
|
// The page itself: every call has exactly one <Signature call="…" />, so "in full" is held
|
|
// by the build and not by whoever last edited api.mdx.
|
|
const page = readFileSync(path.join(ROOT, 'src/content/docs/docs/rust/runicnpc/api.mdx'), 'utf8');
|
|
const onPage = [...page.matchAll(/<Signature\s+call="(\w+)"/g)].map((m) => m[1]);
|
|
let covered = true;
|
|
for (const call of Object.keys(runicnpcCalls)) {
|
|
const n = onPage.filter((c) => c === call).length;
|
|
if (n !== 1) {
|
|
covered = false;
|
|
fail(`RunicNPC API page: ${call}`, `has ${n} <Signature call="${call}" /> on api.mdx — every call needs exactly one, with its prose`);
|
|
}
|
|
}
|
|
if (covered) ok(`RunicNPC API page covers every call (${onPage.length})`);
|
|
}
|
|
|
|
/** Compare name → signature maps: both directions by name, then each shared name by signature. */
|
|
function compareSignatures(label, mine, theirs, hint) {
|
|
compareSets(label, [...mine.keys()], [...theirs.keys()], hint);
|
|
let same = true;
|
|
for (const [name, sig] of mine) {
|
|
if (theirs.has(name) && theirs.get(name) !== sig) {
|
|
same = false;
|
|
fail(`${label} ${name}`, `signature differs — here "${sig}", ${hint} "${theirs.get(name)}"`);
|
|
}
|
|
}
|
|
if (same) ok(`${label} signatures (${mine.size})`);
|
|
}
|
|
|
|
/** The text of a C# method, by brace counting from the first brace after its name. */
|
|
function methodBody(source, name) {
|
|
const start = source.search(new RegExp(`\\b${name}\\s*\\(`));
|
|
if (start < 0) return null;
|
|
const open = source.indexOf('{', start);
|
|
let depth = 0;
|
|
for (let i = open; i < source.length; i++) {
|
|
if (source[i] === '{') depth++;
|
|
else if (source[i] === '}' && --depth === 0) return source.slice(open + 1, i);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function checkRunicNpcSource(cs) {
|
|
const hint = 'runicnpc-rust main:plugin/RunicNPC.cs';
|
|
|
|
// The verbs: every `rnpc.<verb>` console command. The chat command answers the same verbs.
|
|
compareSets(
|
|
'RunicNPC command',
|
|
[...new Set(Object.values(runicnpcCommands).flatMap((s) => Object.keys(s)).map((usage) => usage.split(' ')[0]))],
|
|
[...cs.matchAll(/\[ConsoleCommand\("rnpc\.(\w+)"\)\]/g)].map((m) => m[1]),
|
|
`${hint} [ConsoleCommand]`,
|
|
);
|
|
|
|
// A command's permission must be one the plugin registers.
|
|
const perms = [...cs.matchAll(/const\s+string\s+Perm\w+\s*=\s*"(runicnpc\.\w+)"/g)].map((m) => m[1]);
|
|
compareSets('RunicNPC permission', Object.keys(runicnpcPermissions), perms, `${hint} Perm constants`);
|
|
for (const [section, commands] of Object.entries(runicnpcCommands)) {
|
|
for (const [usage, c] of Object.entries(commands)) {
|
|
if (c.perm.startsWith('runicnpc.') && !perms.includes(c.perm)) {
|
|
fail(`RunicNPC command ${usage}`, `(${section}) needs "${c.perm}", which the plugin does not register`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const options = methodBody(cs, 'ParseOptions');
|
|
if (!options) fail('RunicNPC place option', `could not find ParseOptions in ${hint}`);
|
|
else {
|
|
compareSets(
|
|
'RunicNPC place option',
|
|
Object.keys(runicnpcPlaceOptions),
|
|
[...options.matchAll(/case\s+"(\w+)":/g)].map((m) => m[1]),
|
|
`${hint} ParseOptions`,
|
|
);
|
|
}
|
|
|
|
// The config: `[JsonProperty("name")] public Type Field`, and a field whose type is another
|
|
// config class is a section, its keys written `section.key`.
|
|
const jsonKeys = (className, prefix = '') => {
|
|
const body = classBody(cs, className);
|
|
if (!body) return null;
|
|
return [...body.matchAll(/\[JsonProperty\("(\w+)"\)\]\s*public\s+(\w+)\s+\w+/g)].flatMap(([, key, type]) => {
|
|
const nested = /Config$/.test(type) ? jsonKeys(type, `${prefix}${key}.`) : null;
|
|
return nested ?? [`${prefix}${key}`];
|
|
});
|
|
};
|
|
const configKeys = jsonKeys('PluginConfig');
|
|
if (!configKeys) fail('RunicNPC.json', `could not find class PluginConfig in ${hint}`);
|
|
else compareSets('RunicNPC.json', Object.keys(runicnpcConfig), configKeys, `${hint} PluginConfig`);
|
|
}
|
|
|
|
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.
|
|
compareSets(
|
|
'sidecar.toml',
|
|
Object.keys(sidecarConfig),
|
|
tomlKeysOf(await raw('link', 'sidecar/src/config.rs')),
|
|
'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.values(canonicalDocs).flatMap((group) => Object.keys(group))) {
|
|
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})`);
|
|
}
|
|
|
|
// ── 7. Rust: rust-link's sidecar.toml, and the variables that override it ──
|
|
//
|
|
// PLAN.md §16, phase 15. The same parser as uo-link's — both sidecars declare their file
|
|
// as serde structs — plus the environment overrides, which a Pterodactyl egg sets and an
|
|
// operator meets on the panel before they ever see the file.
|
|
const rustConfigRs = await raw('Rust-Link', 'sidecar/src/config.rs');
|
|
compareSets(
|
|
'rust-link sidecar.toml',
|
|
Object.keys(rustSidecarConfig),
|
|
tomlKeysOf(rustConfigRs),
|
|
'Rust-Link main:sidecar/src/config.rs',
|
|
);
|
|
compareSets(
|
|
'rust-link environment variable',
|
|
Object.keys(rustSidecarEnv),
|
|
[...rustConfigRs.matchAll(/get\("(RUSTLINK_[A-Z_]+)"\)/g)].map((m) => m[1]),
|
|
'Rust-Link main:sidecar/src/config.rs',
|
|
);
|
|
for (const [envName, key] of Object.entries(rustSidecarEnv)) {
|
|
if (!(key in rustSidecarConfig)) fail(`rust-link environment variable ${envName}`, `says it overrides "${key}", which is not a key listed here`);
|
|
}
|
|
|
|
// ── 8. Rust: the bridge plugin's RunicGateway.json ────────────────────────
|
|
//
|
|
// The public fields of `PluginConfig`, which the framework serialises as the file.
|
|
const pluginCs = await raw('Rust-Plugins', 'overlay/oxide/plugins/RunicGateway.cs');
|
|
const pluginBody = classBody(pluginCs, 'PluginConfig');
|
|
if (!pluginBody) {
|
|
fail('RunicGateway.json', 'could not find class PluginConfig in Rust-Plugins main:overlay/oxide/plugins/RunicGateway.cs');
|
|
} else {
|
|
compareSets(
|
|
'RunicGateway.json',
|
|
Object.values(rustPluginConfig).flatMap((group) => Object.keys(group)),
|
|
[...pluginBody.matchAll(/public\s+[\w<>[\],]+\s+(\w+)\s*=/g)].map((m) => m[1]),
|
|
'Rust-Plugins main:overlay/oxide/plugins/RunicGateway.cs PluginConfig',
|
|
);
|
|
}
|
|
|
|
// ── 9. Rust: the event kinds, and who may see each ────────────────────────
|
|
//
|
|
// D71: checked against the module's own catalogue, because the module is what enforces
|
|
// the boundary (the same reasoning as check 5). Three comparisons, each a security
|
|
// statement: a kind listed public here must be public there, a presence kind must be one
|
|
// of the module's presence kinds, and a staff kind must be staff there. A kind moved from
|
|
// staff to public upstream fails this check twice over, which is the point.
|
|
const catalogue = await raw('Module-Rust', 'server/catalogue.js');
|
|
const upPublic = frozenListOf(catalogue, 'PUBLIC_KINDS');
|
|
const upStaff = frozenListOf(catalogue, 'STAFF_KINDS');
|
|
const upPresence = frozenListOf(catalogue, 'PRESENCE_KINDS');
|
|
if (!upPublic || !upStaff || !upPresence) {
|
|
fail('Rust event kinds', 'could not find PUBLIC_KINDS, STAFF_KINDS and PRESENCE_KINDS in Module-Rust main:server/catalogue.js');
|
|
} else {
|
|
const hint = 'Module-Rust main:server/catalogue.js';
|
|
compareSets('Rust presence kind', Object.keys(rustEventKinds.presence), upPresence, `${hint} PRESENCE_KINDS`);
|
|
compareSets(
|
|
'Rust public kind',
|
|
Object.keys(rustEventKinds.public),
|
|
upPublic.filter((k) => !upPresence.includes(k)),
|
|
`${hint} PUBLIC_KINDS (less its presence kinds)`,
|
|
);
|
|
compareSets('Rust staff kind', Object.keys(rustEventKinds.staff), upStaff, `${hint} STAFF_KINDS`);
|
|
for (const k of upPresence) {
|
|
if (!upPublic.includes(k)) fail(`Rust presence kind ${k}`, 'is a presence kind upstream but not a public one — the page explains presence as a narrowing of public, so re-read it');
|
|
}
|
|
}
|
|
|
|
// ── 10. RunicNPC: the API, against its specification ──────────────────────
|
|
//
|
|
// PLAN.md §16, D62 and D73. The one place this site carries a contract in full rather than
|
|
// linking to it, so the one place it must be held to the contract most tightly: every call
|
|
// and hook by its whole signature, and every field name of every data shape, both ways.
|
|
// `docs` `main:runicnpc/API.md` is normative, so it is the authority, not the plugin source.
|
|
await checkRunicNpcApi(await raw('docs', 'runicnpc/API.md'));
|
|
|
|
// ── 11. RunicNPC: the commands, permissions, options and config ───────────
|
|
//
|
|
// Names, checked against the plugin's own source the way RunicGateway.json is (check 8):
|
|
// the console verbs it registers, the permissions it registers, the `key=` options
|
|
// `place` and `here` parse, and the JSON keys of its config classes.
|
|
checkRunicNpcSource(await raw('runicnpc-rust', 'plugin/RunicNPC.cs'));
|
|
|
|
// ── 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);
|
|
});
|