Phase 3's acceptance criterion 1, made real. Three things, one review: **The dead bindings.** `client/src/api/client.js` still carried ~190 lines of UO namespaces — `shard`, `atlas`, the two SSE URLs, `admin.shard/shardOps/atlas/ userShard`, the uo-link and town-crier calls, `player.shard` — with zero core consumers since slice 3 deleted the views. module-uo vendors its own bindings. The five assertions core's `apiClient.test.js` made about those URLs moved with them (Module-uo#5); the encoding test that used `governorHistory` now uses a core route. **The copy.** Core is the platform, not one game's site, so its words are game-neutral now: `About`, `Screenshots`, `Website`'s cards, `Status` (which was never about a game server at all — it reports site mode), `Wiki`, `SiteFooter`, the default hero, `brand.js`'s tagline and description, the seeded wiki categories, and two user-visible NavEditor strings that named a module's admin screen by its proper name. Which game an instance is for is the operator's to say — BRAND_* vars, the hero editor, CMS pages — and every real instance already does: `.env.uomysticmoon.example` sets both brand strings explicitly, so nothing live changes wording. Wiki page SLUGS are untouched: `seedDefault*` only inserts what is absent, so renaming one adds a duplicate page to every install. Also gone: an orphan comment block in `schema.sql` describing the spawn-atlas tables slice 1 took away, and the two settings rows core seeded for a module (`game_account_signup`, `uo_link_protocol_3_migrated`). The second was a live defect — see Module-uo#5, which takes ownership of both and repairs the one-shot migration core's ordering had disabled. **The check.** `scripts/checkModuleIdentifiers.js` + `npm run check:modules`, first step of the server-tests job because it needs no dependencies. It reads CODE, not prose — file names, import specifiers, route path literals, declared identifiers and property names — per §5.2, so core's English may still say "shard" where saying it is worth more than the word costs. Two things it gets right only because getting them wrong was tried first: it matches WHOLE WORDS (a substring pass flags `defaultImage`, which contains "ultIma", four times in this repo), and it strips comments and string bodies in one character walk (a comment contains quotes, a string contains `//`) — the `checkImports.js` lesson. It has its own 17-test suite, because a boundary check that silently stops checking is worse than none. The three §6.5 grandfathering allowlists are exempt by name, and an exemption that stops matching fails the build rather than lingering. BREAKING CHANGE: core no longer seeds `game_account_signup` or `uo_link_protocol_3_migrated`; module-uo's schema fragment does. An install running core without module-uo keeps whatever rows it already has and gains no new ones — nothing in core reads either key. Deferred to slice 5, deliberately: README.md's 48 UO mentions, including a `## Shard integration (uo-link)` section and the architecture diagram. That is documentation, which §5.2 does not cover, and it belongs with the phase-closing docs pass rather than half-done here. Co-Authored-By: Claude <noreply@anthropic.com>
329 lines
14 KiB
JavaScript
329 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
// ── §5.2 — zero module identifiers in core ─────────────────────────────────
|
|
//
|
|
// Phase 3's acceptance criterion 1, as a check rather than a review promise: no
|
|
// `shard`, `uoLink`, `cliloc`, `atlas` or `towncrier` anywhere in core's source
|
|
// (MODULE_API.md §5.2, MODULE_SYSTEM.md §2.7.1 slice 4). The extraction is only
|
|
// worth what this is worth — a boundary nothing enforces grows a hole the first
|
|
// time someone is in a hurry, and the hole looks exactly like the code that was
|
|
// there before.
|
|
//
|
|
// **It reads code, not prose, and that is the whole design.** Four things are
|
|
// checked, and each is a thing a module owns:
|
|
//
|
|
// 1. file and directory names
|
|
// 2. import and require SPECIFIERS — the path, not the file's contents
|
|
// 3. route path literals — the string handed to .get/.post/.put/.patch/
|
|
// .delete/.use
|
|
// 4. declared identifiers — function, const, class, and object property names
|
|
//
|
|
// Comments and string content in general are NOT read. Core's own English may
|
|
// legitimately say "shard": `About.jsx` did until slice 4 rewrote it, and a
|
|
// comment explaining *what moved and why* — `AcceptInvite.jsx` has one — is
|
|
// worth more than the word costs. A literal word grep would fail on both, prove
|
|
// nothing about the boundary, and teach people to phrase around it. The
|
|
// boundary this defends is structural: core must not NAME a module's files,
|
|
// import them, route to them, or declare their symbols. It may talk about them.
|
|
//
|
|
// Two things learned the hard way, both of which this file would have got wrong:
|
|
//
|
|
// • **Match on word boundaries, not substrings.** `defaultImage` contains
|
|
// "ultIma"; `atlas` is inside "atlasSomething" legitimately only when it is
|
|
// the same word. The tokeniser below splits identifiers on camelCase and
|
|
// separators and compares WHOLE words, so `shardStatus` is a hit and
|
|
// `defaultImage` is not. A substring pass flagged four innocent lines in
|
|
// this repo on its first run.
|
|
// • **Strip comments and strings with a character walk, not a regexp.** The
|
|
// module's own `checkImports.js` flagged the comments that explain what it
|
|
// catches. A comment contains quotes (`-- '' when randomised`), a string
|
|
// contains `//` (any URL), and a regexp literal contains both. Doing it in
|
|
// one pass, in order, is the only way that comes out right — and this file
|
|
// has its own test suite (`server/test/checkModuleIdentifiers.test.js`)
|
|
// because a check that silently stops checking is worse than no check.
|
|
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
const { execFileSync } = require('child_process')
|
|
|
|
const ROOT = path.resolve(__dirname, '..')
|
|
|
|
// The trees core owns. `modules/` is deliberately absent — that is where a
|
|
// module's own code lives, and it is the one place these words belong.
|
|
const TREES = [
|
|
path.join(ROOT, 'server', 'src'),
|
|
path.join(ROOT, 'server', 'scripts'),
|
|
path.join(ROOT, 'server', 'db'),
|
|
path.join(ROOT, 'client', 'src'),
|
|
]
|
|
|
|
const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', '.git'])
|
|
const CODE = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'])
|
|
|
|
// The words a module owns. Lower-cased whole words, compared against the
|
|
// tokeniser's output — so `uoLink`, `uo_link` and `uo-link` all reduce to the
|
|
// two tokens `uo` and `link`, and the pair is what is matched.
|
|
const RESERVED = new Set(['shard', 'shards', 'cliloc', 'clilocs', 'atlas', 'towncrier'])
|
|
// Sequences of tokens that are reserved together but innocent apart: "uo" and
|
|
// "link" each appear in ordinary core code ("link" especially), and only the
|
|
// pair names the sidecar.
|
|
const RESERVED_PAIRS = [['uo', 'link'], ['town', 'crier'], ['spawn', 'atlas'], ['serv', 'uo']]
|
|
// Standalone `uo` is reserved too: it is the module id, and a core file called
|
|
// `uo.js` or a route `/uo` is the boundary being crossed in the plainest way.
|
|
const RESERVED_ALONE = new Set(['uo', 'uolink', 'servuo', 'ultima'])
|
|
|
|
// ── The grandfathering exemptions ───────────────────────────────────────────
|
|
//
|
|
// Exactly three, and all three are the SAME mechanism: core's per-module legacy
|
|
// allowlists (MODULE_API.md §6.5). A table prefix, a set of stream ids and an
|
|
// announce leg all predate the module system, are stored in live rows, and are
|
|
// read by a shipped Android client — so `uo` keeps them, and keeping them means
|
|
// core holds a map whose KEY is the module id. There is no way to write that
|
|
// down without naming the module; that is what grandfathering is.
|
|
//
|
|
// Nothing else may be added here without the same kind of reason. In particular
|
|
// this is not an escape hatch for "core still needs this for now" — that is the
|
|
// state slice 4 exists to end.
|
|
//
|
|
// Each entry must MATCH something. An exemption that no longer fires is deleted
|
|
// by the check itself (`unused exemption` below), because a stale one is how an
|
|
// allowlist quietly becomes permission for whatever drifts into it later.
|
|
const EXEMPT = [
|
|
{
|
|
file: 'server/src/modules/loader.js',
|
|
name: 'uo',
|
|
kind: 'property name',
|
|
why: 'LEGACY_TABLE_PREFIXES — the grandfathered shard_/uo_link_ table prefixes (API §6.5)',
|
|
},
|
|
{
|
|
file: 'server/src/modules/registries.js',
|
|
name: 'uo',
|
|
kind: 'property name',
|
|
why: 'LEGACY_STREAM_IDS and LEGACY_LEGS — grandfathered stream ids and the towncrier leg (API §6.5)',
|
|
},
|
|
]
|
|
|
|
const isExempt = (hit) =>
|
|
EXEMPT.some((e) => e.file === hit.file && e.name === hit.name && e.kind === hit.kind)
|
|
|
|
/**
|
|
* Split a name into lower-case words: camelCase humps, and runs separated by
|
|
* `-`, `_`, `.`, `/` or digits.
|
|
*
|
|
* `shardStatus` → [shard, status]; `uo_link_config` → [uo, link, config];
|
|
* `defaultImage` → [default, image] — which is the point: the substring
|
|
* "ultIma" inside it is not a word and never appears here.
|
|
*/
|
|
function tokenize(name) {
|
|
return String(name)
|
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
.split(/[^A-Za-z]+/)
|
|
.filter(Boolean)
|
|
.map((w) => w.toLowerCase())
|
|
}
|
|
|
|
/** Does this name contain a reserved word, as a word? */
|
|
function reservedWordIn(name) {
|
|
const words = tokenize(name)
|
|
for (const w of words) {
|
|
if (RESERVED.has(w) || RESERVED_ALONE.has(w)) return w
|
|
}
|
|
for (const [a, b] of RESERVED_PAIRS) {
|
|
for (let i = 0; i < words.length - 1; i++) {
|
|
if (words[i] === a && words[i + 1] === b) return `${a}-${b}`
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Blank out comments, and MASK string/template/regexp contents, in one
|
|
* left-to-right pass.
|
|
*
|
|
* Masking rather than deleting: the checks that run afterwards need to know
|
|
* WHERE a string was (a route path literal is a string) while not reading what
|
|
* is in an arbitrary one. So a string's delimiters and length survive and its
|
|
* body becomes spaces, except that the string-literal check below re-reads the
|
|
* original text at the same offsets. Comments are replaced by spaces so every
|
|
* offset in the returned text still lines up with the input — line numbers stay
|
|
* honest without a second pass.
|
|
*/
|
|
function maskCode(src) {
|
|
const out = Array.from(src)
|
|
const blank = (from, to) => {
|
|
for (let i = from; i < to && i < out.length; i++) if (out[i] !== '\n') out[i] = ' '
|
|
}
|
|
let i = 0
|
|
while (i < src.length) {
|
|
const c = src[i]
|
|
const next = src[i + 1]
|
|
if (c === '/' && next === '/') {
|
|
let j = i
|
|
while (j < src.length && src[j] !== '\n') j++
|
|
blank(i, j)
|
|
i = j
|
|
continue
|
|
}
|
|
if (c === '/' && next === '*') {
|
|
const end = src.indexOf('*/', i + 2)
|
|
const j = end === -1 ? src.length : end + 2
|
|
blank(i, j)
|
|
i = j
|
|
continue
|
|
}
|
|
if (c === '-' && next === '-' && src[i + 2] === ' ') {
|
|
// SQL line comment; harmless in JS, where `-- ` cannot start an expression.
|
|
let j = i
|
|
while (j < src.length && src[j] !== '\n') j++
|
|
blank(i, j)
|
|
i = j
|
|
continue
|
|
}
|
|
if (c === '"' || c === "'" || c === '`') {
|
|
let j = i + 1
|
|
while (j < src.length) {
|
|
if (src[j] === '\\') { j += 2; continue }
|
|
if (src[j] === c) break
|
|
j++
|
|
}
|
|
blank(i + 1, j) // keep the quotes, blank the body
|
|
i = j + 1
|
|
continue
|
|
}
|
|
i++
|
|
}
|
|
return out.join('')
|
|
}
|
|
|
|
// ── the four checks ─────────────────────────────────────────────────────────
|
|
|
|
const SPECIFIER = /(?:require\(\s*|from\s+|import\(\s*)(['"])([^'"]+)\1/g
|
|
const ROUTE = /\.(?:get|post|put|patch|delete|use|all)\(\s*(['"`])([^'"`]*)\1/g
|
|
const DECLARED = /\b(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g
|
|
const PROPERTY = /(?:^|[{,]\s*)([A-Za-z_$][\w$]*)\s*:/gm
|
|
|
|
function lineOf(src, index) {
|
|
return src.slice(0, index).split('\n').length
|
|
}
|
|
|
|
/**
|
|
* Check one file. `src` is the raw text; `masked` has comments blanked and
|
|
* string bodies blanked at the same offsets, so a regexp run over `masked`
|
|
* finds only real code — and the captured offsets index back into `src` when a
|
|
* check legitimately needs the string's content (specifiers and route paths).
|
|
*/
|
|
function checkFile(rel, src) {
|
|
const hits = []
|
|
const masked = maskCode(src)
|
|
const add = (kind, name, index) => {
|
|
const word = reservedWordIn(name)
|
|
if (word) hits.push({ file: rel, line: lineOf(src, index), kind, name, word })
|
|
}
|
|
|
|
for (const m of masked.matchAll(SPECIFIER)) {
|
|
// Read the specifier out of the ORIGINAL text: its body was masked, and a
|
|
// path is the one string whose content is structural.
|
|
const start = m.index + m[0].indexOf(m[1]) + 1
|
|
add('import specifier', src.slice(start, start + m[2].length), m.index)
|
|
}
|
|
for (const m of masked.matchAll(ROUTE)) {
|
|
const start = m.index + m[0].indexOf(m[1]) + 1
|
|
add('route path', src.slice(start, start + m[2].length), m.index)
|
|
}
|
|
for (const m of masked.matchAll(DECLARED)) add('declared identifier', m[1], m.index)
|
|
for (const m of masked.matchAll(PROPERTY)) add('property name', m[1], m.index)
|
|
|
|
return hits
|
|
}
|
|
|
|
function walk(dir, out = []) {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (SKIP_DIRS.has(entry.name)) continue
|
|
const full = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) walk(full, out)
|
|
else out.push(full)
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* The files core SHIPS, which is what the boundary is about — not whatever
|
|
* happens to be in a working tree.
|
|
*
|
|
* `git ls-files` rather than a walk, because an untracked local artifact is not
|
|
* core's source and must not fail anyone's build. This is not hypothetical: an
|
|
* operator-supplied `server/db/data/spawnAtlas.art.json` is gitignored, sits in
|
|
* the tree of anyone who has run the atlas import, and would otherwise report a
|
|
* file-name violation that no commit could fix. The walk stays as the fallback
|
|
* for an export with no git in it, where over-reporting is the safer failure.
|
|
*/
|
|
function sourceFiles() {
|
|
try {
|
|
const out = execFileSync('git', ['ls-files', '-z', '--cached', '--', ...TREES.map((t) => path.relative(ROOT, t))], {
|
|
cwd: ROOT,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
})
|
|
const files = out.split('\0').filter(Boolean).map((f) => path.join(ROOT, f))
|
|
if (files.length) return files
|
|
} catch {
|
|
// no git, or not a checkout — fall through
|
|
}
|
|
return TREES.filter((t) => fs.existsSync(t)).flatMap((t) => walk(t))
|
|
}
|
|
|
|
function run() {
|
|
const hits = []
|
|
for (const file of sourceFiles()) {
|
|
if (!fs.existsSync(file)) continue
|
|
const rel = path.relative(ROOT, file).split(path.sep).join('/')
|
|
|
|
// 1. the name itself
|
|
const word = reservedWordIn(path.basename(file))
|
|
if (word) hits.push({ file: rel, line: 0, kind: 'file name', name: path.basename(file), word })
|
|
|
|
// 2-4. the contents, for code files only
|
|
if (!CODE.has(path.extname(file))) continue
|
|
hits.push(...checkFile(rel, fs.readFileSync(file, 'utf8')))
|
|
}
|
|
|
|
const live = hits.filter((h) => !isExempt(h))
|
|
// A grandfathering entry that matches nothing is deleted, loudly. Reported as
|
|
// a failure rather than a warning: the exemption list is the one part of this
|
|
// check that can only get weaker, so it is the part that needs the noise.
|
|
const unused = EXEMPT.filter((e) => !hits.some((h) => h.file === e.file && h.name === e.name && h.kind === e.kind))
|
|
return { hits: live, unused }
|
|
}
|
|
|
|
module.exports = { run, checkFile, maskCode, tokenize, reservedWordIn, EXEMPT }
|
|
|
|
if (require.main === module) {
|
|
const { hits, unused } = run()
|
|
if (hits.length === 0 && unused.length === 0) {
|
|
console.log('OK — core names no module identifier (MODULE_API.md §5.2).')
|
|
process.exit(0)
|
|
}
|
|
for (const e of unused) {
|
|
console.error(
|
|
`\nUnused exemption: ${e.file} "${e.name}" (${e.kind}) matches nothing any more.\n` +
|
|
` ${e.why}\n` +
|
|
' Delete it from EXEMPT in this file. A grandfathering entry that has outlived what it ' +
|
|
'grandfathered is permission with nothing attached to it.',
|
|
)
|
|
}
|
|
if (hits.length === 0) process.exit(1)
|
|
console.error(
|
|
`\nCore names ${hits.length} module identifier${hits.length === 1 ? '' : 's'} ` +
|
|
'(MODULE_API.md §5.2). Each of these belongs to an installed module:\n',
|
|
)
|
|
for (const h of hits) {
|
|
console.error(` ${h.file}:${h.line} ${h.kind} "${h.name}" — reserved word "${h.word}"`)
|
|
}
|
|
console.error(
|
|
'\nCore may TALK about a module in English; it may not name its files, import them, ' +
|
|
'route to them, or declare its symbols. If one of these is core\'s own and the word is ' +
|
|
'a coincidence, the fix is to rename it — the reserved list is short and deliberate.\n',
|
|
)
|
|
process.exit(1)
|
|
}
|