#!/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) }