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