Files
runicgateway.com/scripts/checkTerms.mjs
wtclaude ecc556d7ac
All checks were successful
PR checks / checks (pull_request) Successful in 2m11s
feat(docs): phase 16 - the RunicNPC subgroup, its API carried in full and drift-checked
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
2026-10-22 15:49:07 -05:00

272 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* checkTerms.mjs — PLAN.md §16, D63 and D64. The twelfth check, added in phase 14.
*
* D63: outside a game's own section, the site may NAME the games it supports, but it may not
* use their VOCABULARY. Core concepts keep core's names — a Team, a game server, the sidecar,
* the bridge plugin, the game module — because that is what core itself calls them, and a
* site that says "shard" on a core page is describing one game's deployment as though it were
* the platform.
*
* D64: a convention that holds only while everyone remembers it does not hold. This is the
* same argument `checkTokens.mjs` makes for colours, applied to words. The vocabulary problem
* was measured before this check existed — "shard" 227 times in 45 files — and none of that
* was anybody's decision; it was the residue of a site written while there was one game.
*
* What it reads, as of phase 14:
*
* - every page under `src/content/docs`, frontmatter included (a title and a description
* are what a reader sees first, in the sidebar and in search results);
* - `src/config/sidebar.mjs`, as DATA rather than as text: every group label and every
* page label, in both trees. Its comments are not scanned, because a comment is not
* something a reader of the site ever sees.
*
* Phase 17 widens it to all of `src/`, once the marketing pages have a game page to move
* their game content to. That is also when it learns D67's screenshot tag.
*
* Where a game's words are allowed:
*
* - in that game's own docs directory (`GAME_DIRS`), and its own sidebar group — a group
* whose label is the game's `name` in `platform.json`;
* - between `<GameExample game="…">` tags on a core page (D68). The component prints the
* game's name above the block, so the words arrive already labelled.
*
* What is NOT read, deliberately:
*
* - link targets and bare URLs. A repository is called what it is called, and an address is
* not prose. The LINK TEXT is read, so `[Module-uo](…)` fails and `[releases](…)` passes;
* - `import` lines and `{…}` expressions. They are code, and what an expression renders is
* a value from `platform.json` or `src/data/`, which phase 17 brings into scope.
*
* node scripts/checkTerms.mjs
*
* No token and no network: everything it reads is in this repository.
*/
import { readFileSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { docsSidebar, plannedSidebar, flatLabels } from '../src/config/sidebar.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
/**
* The words, each tagged with the game it belongs to. Matching is case-insensitive and on
* word boundaries, so "shard" does not match `SHARD_VISIBILITY` and "wipe" does not match
* "wiped" unless it is listed.
*
* A word is on this list because it is a game's name for something core has its own name for
* (shard → game server, guild and clan → Team), or because it names one game's component
* (uo-link → the sidecar, ServUO → the game server). Game NAMES — Ultima Online, UO, Rust —
* are not on it: D63 allows them everywhere. Steam is not on it either: Steam sign-in is a
* core identity provider, even though Rust was its first user.
*/
const TERMS = [
{ game: 'uo', word: 'shard', re: /\bshards?\b/gi },
{ game: 'uo', word: 'ServUO', re: /\bservuo\b/gi },
{ game: 'uo', word: 'uo-link', re: /\buo-?link\b/gi },
{ game: 'uo', word: 'Module-uo', re: /\bmodule-uo\b/gi },
{ game: 'uo', word: 'Bridge.cfg', re: /\bbridge\.cfg\b/gi },
{ game: 'uo', word: 'cliloc', re: /\bclilocs?\b/gi },
{ game: 'uo', word: 'guild', re: /\bguilds?\b/gi },
{ game: 'uo', word: 'IDOC', re: /\bidocs?\b/gi },
{ game: 'rust', word: 'rust-link', re: /\brust-?link\b/gi },
{ game: 'rust', word: 'Rust-Plugins', re: /\brust-plugins\b/gi },
{ game: 'rust', word: 'Module-Rust', re: /\bmodule-rust\b/gi },
{ game: 'rust', word: 'Oxide', re: /\boxide\b/gi },
{ game: 'rust', word: 'Carbon', re: /\bcarbon\b/gi },
{ game: 'rust', word: 'uMod', re: /\bumod\b/gi },
{ game: 'rust', word: 'clan', re: /\bclans?\b/gi },
{ game: 'rust', word: 'wipe', re: /\bwipes?\b/gi },
];
/** Each game's own docs directory. A page under one may use that game's words. */
const GAME_DIRS = {
uo: 'src/content/docs/docs/ultima-online/',
rust: 'src/content/docs/docs/rust/',
};
/**
* The allowlist: an exact phrase in one file that may use a word the rule would otherwise
* refuse. Short on purpose, and every entry says why. An entry that no longer matches its
* file fails the check too, so the list cannot rot into a set of permanent exemptions.
*/
const ALLOW = [
{
file: 'src/content/docs/docs/administration/notifications-and-email.mdx',
text: 'Guild (server) ID',
why: "Discord's own word, not a game's: it is the label on core's Discord Bot screen.",
},
];
for (const game of Object.keys(GAME_DIRS)) {
if (!platform.games[game]) throw new Error(`checkTerms: GAME_DIRS names "${game}", which platform.json does not.`);
}
for (const t of TERMS) {
if (!platform.games[t.game]) throw new Error(`checkTerms: "${t.word}" is tagged "${t.game}", which platform.json does not know.`);
}
const failures = [];
const fail = (where, detail) => failures.push({ where, detail });
/** Replace a span with spaces, keeping newlines, so later line numbers stay true. */
const blank = (text) => text.replace(/[^\n]/g, ' ');
/** The game whose directory holds this file, or null for a core page. */
function gameOfPath(relative) {
const posix = relative.split(path.sep).join('/');
for (const [game, dir] of Object.entries(GAME_DIRS)) if (posix.startsWith(dir)) return game;
return null;
}
/**
* Turn an MDX source into the text a reader would actually see, with everything that is not
* prose blanked out. Returns that text plus the GameExample regions, as offsets into it.
*/
function proseOf(source, relative) {
let text = source;
// GameExample regions first, while the tags are still intact. Recorded, then the tags
// themselves are blanked so the attribute does not read as prose.
const regions = [];
for (const m of text.matchAll(/<GameExample\s+game="([^"]*)"\s*>([\s\S]*?)<\/GameExample>/g)) {
const game = m[1];
if (!platform.games[game]) {
fail(`${relative}:${lineOf(source, m.index)}`, `<GameExample game="${game}"> names a game platform.json does not have`);
}
regions.push({ game, start: m.index, end: m.index + m[0].length });
}
text = text.replace(/<\/?GameExample\b[^>]*>/g, blank);
text = text.replace(/<!--[\s\S]*?-->/g, blank); // HTML comments
text = text.replace(/^import\s.*$/gm, blank); // MDX imports
text = text.replace(/\]\([^)\s]*\)/g, (m) => '] ' + blank(m.slice(2))); // link targets, not their text
text = text.replace(/\b(?:href|src)=(?:"[^"]*"|\{`[^`]*`\})/g, blank); // JSX/HTML link targets
text = text.replace(/\bhttps?:\/\/[^\s)>"'`]+/g, blank); // bare URLs
// `{…}` expressions, outside code fences only — a fenced JSON example's braces are prose.
// Innermost first, repeatedly, so nested and multi-line expressions both go.
const parts = text.split(/(^```[\s\S]*?^```)/m);
for (let i = 0; i < parts.length; i += 2) {
let prev;
do {
prev = parts[i];
parts[i] = parts[i].replace(/\{[^{}]*\}/g, blank);
} while (parts[i] !== prev);
}
text = parts.join('');
return { text, regions };
}
function lineOf(source, index) {
return source.slice(0, index).split('\n').length;
}
/** Hits for every term the given context may not use. */
function scan(text, allowedGame) {
const hits = [];
for (const t of TERMS) {
if (t.game === allowedGame) continue;
t.re.lastIndex = 0;
for (const m of text.matchAll(t.re)) hits.push({ term: t, value: m[0], index: m.index });
}
return hits;
}
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()) yield* walk(full);
else if (/\.mdx?$/.test(entry.name)) yield full;
}
}
// ── 1. The documentation pages ──────────────────────────────────────────────
let files = 0;
const allowUsed = new Set();
for await (const file of walk(path.join(ROOT, 'src', 'content', 'docs'))) {
const relative = path.relative(ROOT, file).split(path.sep).join('/');
const posix = relative;
const source = readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
files++;
let { text, regions } = proseOf(source, relative);
for (const [i, entry] of ALLOW.entries()) {
if (entry.file !== posix) continue;
if (text.includes(entry.text)) {
allowUsed.add(i);
text = text.split(entry.text).join(blank(entry.text));
}
}
const pageGame = gameOfPath(relative);
for (const hit of scan(text, pageGame)) {
const region = regions.find((r) => hit.index >= r.start && hit.index < r.end);
if (region && region.game === hit.term.game) continue;
const where = region ? `inside <GameExample game="${region.game}">` : pageGame ? `in the ${platform.games[pageGame].name} group` : 'on a core page';
fail(
`${relative}:${lineOf(source, hit.index)}`,
`"${hit.value}" is ${platform.games[hit.term.game].name} vocabulary, ${where}`,
);
}
}
for (const [i, entry] of ALLOW.entries()) {
if (!allowUsed.has(i)) {
fail(`allowlist: ${entry.file}`, `"${entry.text}" no longer appears there — remove the entry`);
}
}
// ── 2. The sidebar, as data ─────────────────────────────────────────────────
const gameByName = new Map(Object.entries(platform.games).map(([key, g]) => [g.name, key]));
const trees = [
['docsSidebar', docsSidebar.map((g) => [g.label, flatLabels(g.items)])],
['plannedSidebar', Object.entries(plannedSidebar).map(([label, items]) => [label, flatLabels(items)])],
];
let labels = 0;
for (const [treeName, groups] of trees) {
for (const [groupLabel, items] of groups) {
const game = gameByName.get(groupLabel) ?? null;
for (const label of [groupLabel, ...items]) {
labels++;
for (const hit of scan(label, game)) {
fail(
`src/config/sidebar.mjs ${treeName} → ${groupLabel}`,
`"${label}" uses "${hit.value}", ${platform.games[hit.term.game].name} vocabulary, ${game ? `in the ${platform.games[game].name} group` : 'in a core group'}`,
);
}
}
}
}
// ── Report ──────────────────────────────────────────────────────────────────
if (failures.length === 0) {
console.log(
`checkTerms: ${files} docs pages and ${labels} sidebar labels use only their own game's words ` +
`(${TERMS.length} terms, ${ALLOW.length} allowlisted phrase${ALLOW.length === 1 ? '' : 's'}).`,
);
} else {
console.error(`\ncheckTerms: ${failures.length} game word(s) outside their game's section:\n`);
for (const f of failures) console.error(` ✗ ${f.where}\n ${f.detail}`);
console.error(`
PLAN.md §16, D63: name the games anywhere, but keep their vocabulary in their own section.
Use core's word instead — a Team, a game server, the sidecar, the bridge plugin, the game
module. If a core page genuinely needs a game's real example, wrap it in
<GameExample game="…"> (D68), which labels it with the game's name. Add to the allowlist
in this script only for a word that is not a game's at all, and say why.
`);
process.exit(1);
}