Phase 2 of docs/modules/rust/PLAN.md. Phase 1 built five guards and ran them by hand; this repo had no workflows at all, so nothing gated the branch that gets released and there was no way to release it. Three pieces: - **release.yml** — the derived-version engine link, installer and Module-uo already run (conventional-commit subjects since the newest tag; module.json's version survives as a floor; workflow_dispatch as the backdoor), assembling the bundle from an include list and publishing the tarball, the install manifest carrying its sha256, and SHA256SUMS. The tag is the number that ships and CI stamps it into the bundle's own module.json. - **pr-checks.yml** — server tests, check:imports, check:bundle, check:swagger, the client build, client tests and check:externals, plus frozen-manifest. - **frozen-manifest** — clones core at the sha pinned in ci/core-ref.json, generates its route table without this module and with it, and takes the difference. It ran locally against that exact ref: six routes, all documented, no core route moved. That is the first proof by a running core that /rust collides with nothing — phase 1 could only check it by reading, because core mounts /status and /version at a tier root where the loader's own collision probe cannot see them. The bundle carries no node_modules, because the shipped half declares no runtime dependencies (org lead, phase 2). checkBundle.js holds both halves of that: the include list still covers everything server/index.js reaches, and no dependency has appeared without the release learning to pack it. Verified by breaking it — dropping "model" from the list names the exact edit and exits 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
302 lines
13 KiB
JavaScript
302 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
// ── Does the release actually ship everything the module needs? ────────────
|
|
//
|
|
// `ci/bundle.json` says what a release copies. `server/index.js` says what the
|
|
// module requires. Nothing keeps two lists in agreement on its own, and the first
|
|
// module this project shipped proved it: `Module-uo` added `server/commands/` in a
|
|
// cutover, its include list did not learn about it, and v1.0.0 installed cleanly
|
|
// and then died on the operator's box with
|
|
//
|
|
// module "uo" failed to load — {"stage":"register","reason":"Cannot find
|
|
// module './commands/guild.command'"}
|
|
//
|
|
// Nothing caught it, because the PR checks install the module by copying the
|
|
// WHOLE repo into core — they only ever exercised a tree that had the file. **The
|
|
// subset only exists in the release**, and the release had no check that the
|
|
// subset was complete. This module has that check from its first release rather
|
|
// than after its first outage.
|
|
//
|
|
// It asks the question in the two places it can be asked:
|
|
//
|
|
// --check (PR checks) Every file reachable from the entry point by a
|
|
// relative require lives under something ci/bundle.json
|
|
// lists. Source-tree only, so it is fast and needs no
|
|
// assembled bundle — it fails on the PR that adds the
|
|
// directory, which is where the fix is cheapest.
|
|
//
|
|
// --bundle <dir> (release) Every relative specifier inside an ASSEMBLED bundle
|
|
// resolves to a file that is in it. Asked of the
|
|
// artifact rather than of the source, so it also
|
|
// catches a copy that half-failed, a list that names a
|
|
// path that has moved, and anything else between the
|
|
// declaration and the tarball.
|
|
//
|
|
// The two are deliberately not the same question. The first is about the list
|
|
// being right; the second is about the tarball being right. A release runs both.
|
|
//
|
|
// ── The third question, which is this module's own ─────────────────────────
|
|
//
|
|
// `server/package.json` declares **no runtime dependencies**, and the release
|
|
// therefore runs no `npm ci` and packs no `node_modules`. That is a decision, not
|
|
// an accident (org lead, phase 2), and the whole value of it is that the day it
|
|
// stops being true is a loud day. So both modes also assert the declaration is
|
|
// still empty: add a `dependencies` entry without teaching release.yml to install
|
|
// and pack it, and the bundle ships an import of something that is not there —
|
|
// the missing-directory failure again, wearing a different hat.
|
|
//
|
|
// ── Why reachability, and not "require the entry point" ────────────────────
|
|
//
|
|
// The obvious check — require the bundle's entry and see if it throws — does not
|
|
// work here, and the reason is in index.js's own header: its requires are inside
|
|
// `register()` because require order is load-bearing (`core.init(ctx)` has to run
|
|
// before anything under `router/` is required). So requiring the entry evaluates
|
|
// exactly one line, `require('./core')`, and reports success on a bundle missing
|
|
// every router it has. Calling `register()` for real would need a fake `ctx`
|
|
// complete enough to satisfy the whole module — which is what `test/` is for, and
|
|
// `test/` does not ship. Walking the requires statically asks the same question
|
|
// without needing either.
|
|
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
const { stripCommentsAndTemplates } = require('./checkImports')
|
|
|
|
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
|
|
|
// Only relative specifiers. A bare one is checkImports.js's question, not this
|
|
// one, and the two failures want different advice.
|
|
const RELATIVE = /(?:require\(|from\s+|import\()\s*['"](\.[^'"]+)['"]/g
|
|
|
|
/**
|
|
* Resolve a relative specifier the way Node would, for the file cases that can
|
|
* appear here: an exact path, `+.js`/`+.json`, or a directory's `index.js`.
|
|
*
|
|
* Returns null when nothing exists — which is the finding, not an error.
|
|
*/
|
|
function resolveFile(fromDir, specifier) {
|
|
const base = path.resolve(fromDir, specifier)
|
|
const candidates = [base, `${base}.js`, `${base}.json`, path.join(base, 'index.js')]
|
|
for (const c of candidates) {
|
|
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Every file reachable from `entry` by following relative requires, plus every
|
|
* specifier that resolved to nothing.
|
|
*
|
|
* Exported so the test can point it at fixtures — the same reason checkImports.js
|
|
* exports `scan`. A check that has never been shown to fail is a check nobody
|
|
* knows the state of, and this one is load-bearing for every release.
|
|
*/
|
|
function reachable(entry) {
|
|
const seen = new Set()
|
|
const missing = []
|
|
const queue = [entry]
|
|
|
|
while (queue.length) {
|
|
const file = queue.shift()
|
|
if (seen.has(file)) continue
|
|
seen.add(file)
|
|
|
|
// A .json dependency is a leaf: it is reached, it ships, and it has no
|
|
// requires of its own to follow.
|
|
if (file.endsWith('.json')) continue
|
|
|
|
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
|
for (const [, specifier] of source.matchAll(RELATIVE)) {
|
|
const target = resolveFile(path.dirname(file), specifier)
|
|
if (target) queue.push(target)
|
|
else missing.push({ file, specifier })
|
|
}
|
|
}
|
|
|
|
return { files: [...seen], missing }
|
|
}
|
|
|
|
/**
|
|
* Everything ci/bundle.json says ends up in the bundle, as absolute paths:
|
|
* `server[]` relative to server/, `root[]` and `generated[]` relative to the
|
|
* module root. All three are equally "in the tarball" as far as a require is
|
|
* concerned — the only difference is how they get there.
|
|
*/
|
|
function declaredServerPaths(moduleRoot = MODULE_ROOT) {
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(moduleRoot, 'ci', 'bundle.json'), 'utf8'))
|
|
return [
|
|
...manifest.server.map((p) => path.join(moduleRoot, 'server', p)),
|
|
...(manifest.root || []).map((p) => path.join(moduleRoot, p)),
|
|
...(manifest.generated || []).map((p) => path.join(moduleRoot, p)),
|
|
]
|
|
}
|
|
|
|
/**
|
|
* The runtime dependencies the shipped half declares.
|
|
*
|
|
* Empty is the shape this module is built around, and the release packs no
|
|
* `node_modules` because of it. Returned rather than asserted so both modes can
|
|
* report it with their own advice.
|
|
*/
|
|
function runtimeDependencies(moduleRoot = MODULE_ROOT) {
|
|
const pkgPath = path.join(moduleRoot, 'server', 'package.json')
|
|
if (!fs.existsSync(pkgPath)) return []
|
|
return Object.keys(JSON.parse(fs.readFileSync(pkgPath, 'utf8')).dependencies || {})
|
|
}
|
|
|
|
const DEPENDENCY_ADVICE =
|
|
'The release packs no node_modules, because this module declared none. A dependency\n' +
|
|
'listed here but not installed and copied by .gitea/workflows/release.yml ships as an\n' +
|
|
'import of something that is not in the tarball — the module installs and then dies at\n' +
|
|
'the register stage on the operator\'s box.\n\n' +
|
|
'Either drop the dependency (everything the shipped half needs arrives on ctx — §2.3),\n' +
|
|
'or add the `npm ci --omit=dev` + copy steps to release.yml and list "node_modules" in\n' +
|
|
'ci/bundle.json\'s server[], then update this check.\n'
|
|
|
|
const covers = (declared, file) => declared.some((d) => file === d || file.startsWith(d + path.sep))
|
|
|
|
/**
|
|
* --check: is ci/bundle.json's list sufficient for what the entry point reaches?
|
|
*
|
|
* Reports the top-level entry to ADD rather than the individual files, because
|
|
* that is the edit: the list is stated in top-level paths, and a new directory
|
|
* arrives with a dozen files in it.
|
|
*/
|
|
function checkDeclaration(moduleRoot = MODULE_ROOT) {
|
|
const serverRoot = path.join(moduleRoot, 'server')
|
|
const entry = path.join(serverRoot, 'index.js')
|
|
const { files, missing } = reachable(entry)
|
|
const declared = declaredServerPaths(moduleRoot)
|
|
|
|
// Grouped by the entry that would have to be added, which is the top-level
|
|
// path under server/ — or, for the rare reachable file outside it, the path
|
|
// itself, since that one belongs in root[] instead.
|
|
const uncovered = new Map()
|
|
for (const file of files) {
|
|
if (covers(declared, file)) continue
|
|
const inServer = file.startsWith(serverRoot + path.sep)
|
|
const key = inServer
|
|
? `server/${path.relative(serverRoot, file).split(path.sep)[0]}`
|
|
: path.relative(moduleRoot, file).split(path.sep).join('/')
|
|
if (!uncovered.has(key)) uncovered.set(key, [])
|
|
uncovered.get(key).push(file)
|
|
}
|
|
|
|
return { uncovered, missing, reached: files.length, dependencies: runtimeDependencies(moduleRoot) }
|
|
}
|
|
|
|
/**
|
|
* --bundle: does every relative specifier inside an assembled bundle resolve?
|
|
*
|
|
* Walks the bundle's own server tree rather than starting from the entry point,
|
|
* so a file that ships but is broken is caught too.
|
|
*/
|
|
function checkBundle(bundleRoot) {
|
|
const serverRoot = path.join(bundleRoot, 'server')
|
|
const missing = []
|
|
const files = []
|
|
|
|
const walk = (dir) => {
|
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const p = path.join(dir, e.name)
|
|
if (e.isDirectory()) {
|
|
// An installed dependency tree would be npm's business, not this
|
|
// check's. This module ships none; the skip stays so that the day one
|
|
// arrives, this is not also the thing that breaks.
|
|
if (e.name !== 'node_modules') walk(p)
|
|
} else if (/\.(js|mjs|cjs)$/.test(e.name)) {
|
|
files.push(p)
|
|
}
|
|
}
|
|
}
|
|
walk(serverRoot)
|
|
|
|
for (const file of files) {
|
|
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
|
for (const [, specifier] of source.matchAll(RELATIVE)) {
|
|
if (!resolveFile(path.dirname(file), specifier)) missing.push({ file, specifier })
|
|
}
|
|
}
|
|
|
|
return { missing, scanned: files.length, dependencies: runtimeDependencies(bundleRoot) }
|
|
}
|
|
|
|
module.exports = {
|
|
reachable,
|
|
resolveFile,
|
|
checkDeclaration,
|
|
checkBundle,
|
|
declaredServerPaths,
|
|
runtimeDependencies,
|
|
}
|
|
|
|
// Required by a test, or run as the check? Only the second one exits.
|
|
if (require.main !== module) return
|
|
|
|
const bundleFlag = process.argv.indexOf('--bundle')
|
|
|
|
if (bundleFlag !== -1) {
|
|
const root = process.argv[bundleFlag + 1]
|
|
if (!root) {
|
|
console.error('--bundle needs the path to an assembled bundle')
|
|
process.exit(2)
|
|
}
|
|
const { missing, scanned, dependencies } = checkBundle(path.resolve(root))
|
|
if (missing.length) {
|
|
console.error(`\nThe assembled bundle is incomplete — ${missing.length} require(s) resolve to nothing:\n`)
|
|
for (const m of missing) {
|
|
console.error(` ${path.relative(root, m.file)}\n requires "${m.specifier}" — not in the bundle`)
|
|
}
|
|
console.error('\nAdd the missing path to ci/bundle.json.\n')
|
|
process.exit(1)
|
|
}
|
|
if (dependencies.length) {
|
|
console.error(`\nThe assembled bundle declares ${dependencies.length} runtime dependency(ies) it does not carry:\n`)
|
|
for (const d of dependencies) console.error(` ${d}`)
|
|
console.error(`\n${DEPENDENCY_ADVICE}`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`OK — every relative require in the bundle resolves (${scanned} files scanned), and it needs no node_modules.`)
|
|
} else {
|
|
const { uncovered, missing, reached, dependencies } = checkDeclaration()
|
|
|
|
if (missing.length) {
|
|
console.error(`\n${missing.length} require(s) resolve to nothing in the source tree:\n`)
|
|
for (const m of missing) {
|
|
console.error(` ${path.relative(MODULE_ROOT, m.file)}\n requires "${m.specifier}"`)
|
|
}
|
|
console.error('')
|
|
process.exit(1)
|
|
}
|
|
|
|
if (uncovered.size) {
|
|
console.error('\nci/bundle.json does not ship everything server/index.js reaches.\n')
|
|
console.error('A release built from this list would install and then fail at the')
|
|
console.error("register stage with \"Cannot find module\", on the operator's box.\n")
|
|
for (const [key, files] of uncovered) {
|
|
console.error(` ${key} (${files.length} file${files.length === 1 ? '' : 's'} reachable)`)
|
|
for (const f of files.slice(0, 5)) console.error(` ${path.relative(MODULE_ROOT, f)}`)
|
|
if (files.length > 5) console.error(` … and ${files.length - 5} more`)
|
|
}
|
|
// server[] is written relative to server/, so name the entry to add rather
|
|
// than the path just displayed — they differ by exactly that prefix.
|
|
const toServer = [...uncovered.keys()].filter((k) => k.startsWith('server/'))
|
|
const toRoot = [...uncovered.keys()].filter((k) => !k.startsWith('server/'))
|
|
if (toServer.length) {
|
|
console.error(`\nAdd ${toServer.map((k) => `"${k.slice('server/'.length)}"`).join(', ')} to ci/bundle.json's server[].`)
|
|
}
|
|
if (toRoot.length) {
|
|
console.error(`\nAdd ${toRoot.map((k) => `"${k}"`).join(', ')} to ci/bundle.json's root[].`)
|
|
}
|
|
console.error('')
|
|
process.exit(1)
|
|
}
|
|
|
|
if (dependencies.length) {
|
|
console.error(`\nserver/package.json declares ${dependencies.length} runtime dependency(ies):\n`)
|
|
for (const d of dependencies) console.error(` ${d}`)
|
|
console.error(`\n${DEPENDENCY_ADVICE}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log(`OK — ci/bundle.json ships every file server/index.js reaches (${reached} files), and no runtime dependency is declared.`)
|
|
}
|