#!/usr/bin/env node // ── §5.3 — this module's frozen route manifest ───────────────────────────── // // Core freezes its URL surface in `server/routes.manifest.json` by walking the // live Express stack and committing the result; a PR that moves a URL has to // commit the new manifest, which puts the change in front of a reviewer. After // phase 3 the seventy URLs this module serves are no longer in that file. They // are here, frozen the same way and by the same generator. // // **The module's routes are DERIVED, never listed.** This script is handed two // manifests generated from the SAME core at the pinned ref — one without this // module on the volume, one with — and the difference is what this module serves. // Nothing here says "/api/v1/public/shard/*"; a mount prefix appears in exactly // one place, `server/index.js`'s `registerRoutes` call, which is where an operator's // core reads it from too. // // Taking the difference rather than filtering by prefix buys the other half of // §5.3 for free, and it is the half that matters most: **no core URL may move.** // A module that shadowed a core route, or whose mount displaced one, shows up // here as a removal or a change, not merely as an addition somewhere else. That // is the promise §1.2 makes to the shipped Android app and the Discord bot. // // The third thing it checks is the OpenAPI fragment (§2.8). `swagger-fragment.json` // is generated from the module's own registrations against §2.4's stated tier // bases — the one place a constant could be wrong. Here there is ground truth: a // real core with this module loaded, reporting the URLs it actually serves. Every // route must have a documented operation and every documented operation must be a // route. That is the per-module form of core's standing rule, never ship a route // that isn't in the spec — and it is what stops a wrong constant in the generator // from producing a fragment that is internally consistent and describes nothing // core will ever serve. // // Usage (the workflow does the cloning; see .gitea/workflows/frozen-manifest.yml): // node scripts/frozenManifest.js --before core-only.json --after core-plus-uo.json // node scripts/frozenManifest.js --before … --after … --check const fs = require('fs') const path = require('path') const MODULE_ROOT = path.resolve(__dirname, '..', '..') const MANIFEST = path.join(MODULE_ROOT, 'routes.manifest.json') const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json') const COMMENT = 'Generated inventory of the URLs module-uo serves - the module half of the freeze ' + 'core keeps in server/routes.manifest.json. DERIVED as the difference between a core ' + 'without this module and the same core with it, both at the pinned ref in ci/core-ref.json. ' + 'Regenerate with the frozen-manifest workflow; see server/scripts/frozenManifest.js.' const key = (r) => `${r.method} ${r.path}` /** * The module's routes, plus proof that core's own surface did not move. * * @param {object} before routes.manifest.json from core alone * @param {object} after routes.manifest.json from the same core with this module * @returns {{ added: object[], removed: string[] }} */ function diffManifests(before, after) { const added = [] const removed = [] for (const tier of ['public', 'internal']) { const was = new Set((before[tier] || []).map(key)) for (const route of after[tier] || []) { if (!was.has(key(route))) added.push({ ...route, tier }) was.delete(key(route)) } for (const gone of was) removed.push(`${tier} ${gone}`) } added.sort((a, b) => (key(a) < key(b) ? -1 : 1)) return { added, removed } } /** * Which of the module's routes the fragment fails to document, and vice versa. * * Express `:id` is OpenAPI `{id}`; the fragment is already in OpenAPI's spelling * because that is what core merges, so the manifest's paths are converted here * rather than the other way round. */ function coverage(added, fragment) { const documented = new Set() for (const [p, item] of Object.entries(fragment.paths || {})) { for (const method of Object.keys(item)) documented.add(`${method.toUpperCase()} ${p}`) } const undocumented = [] for (const route of added) { const oas = `${route.method} ${route.path.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}` if (documented.has(oas)) documented.delete(oas) else undocumented.push(oas) } // Whatever is left is documented and not served: a route that moved or was // deleted while its annotation stayed behind. Core's spec has no equivalent // check and grew four orphan tags and thirty-three orphan schemas because of it. return { undocumented, unserved: [...documented].sort() } } function serialize(routes) { return `${JSON.stringify( { $comment: COMMENT, routes: routes.map(({ method, path: p, tier }) => ({ method, path: p, tier })), }, null, 2, )}\n` } function main() { const arg = (name) => { const i = process.argv.indexOf(name) return i === -1 ? null : process.argv[i + 1] } const beforePath = arg('--before') const afterPath = arg('--after') if (!beforePath || !afterPath) { process.stderr.write('usage: frozenManifest.js --before --after [--check]\n') process.exit(2) } const before = JSON.parse(fs.readFileSync(beforePath, 'utf8')) const after = JSON.parse(fs.readFileSync(afterPath, 'utf8')) const { added, removed } = diffManifests(before, after) let failed = false if (removed.length > 0) { process.stderr.write( `\nLoading this module REMOVED or CHANGED ${removed.length} of core's own route(s):\n` + `${removed.map((r) => ` - ${r}`).join('\n')}\n` + 'A module may only add. This is the frozen-URL promise (MODULE_SYSTEM.md §1.2) breaking.\n', ) failed = true } if (added.length === 0) { process.stderr.write( '\nLoading this module added NO routes. Either it failed to load in the core checkout\n' + '(check the boot log for a startup_failed line) or the two manifests are the same file.\n', ) process.exit(1) } const fragment = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8')) const { undocumented, unserved } = coverage(added, fragment) if (undocumented.length > 0) { process.stderr.write( `\n${undocumented.length} route(s) this module serves have no operation in swagger-fragment.json:\n` + `${undocumented.map((r) => ` - ${r}`).join('\n')}\n` + 'Run `npm run swagger --prefix server` and commit the result (MODULE_API.md §2.8).\n', ) failed = true } if (unserved.length > 0) { process.stderr.write( `\n${unserved.length} operation(s) in swagger-fragment.json are not routes this module serves:\n` + `${unserved.map((r) => ` - ${r}`).join('\n')}\n` + 'A documented URL nobody serves is a client following the docs into a 404.\n', ) failed = true } if (failed) process.exit(1) const contents = serialize(added) if (process.argv.includes('--check')) { const current = fs.existsSync(MANIFEST) ? fs.readFileSync(MANIFEST, 'utf8').replace(/\r\n/g, '\n') : null if (current !== contents) { process.stderr.write( '\nroutes.manifest.json is stale. The URLs this module serves changed — regenerate it and\n' + 'commit the result so the move is reviewed rather than merged as mechanical.\n', ) process.exit(1) } process.stdout.write(`routes.manifest.json is current — ${added.length} routes, all documented\n`) return } fs.writeFileSync(MANIFEST, contents) process.stdout.write(`wrote routes.manifest.json — ${added.length} routes, all documented\n`) } if (require.main === module) main() module.exports = { diffManifests, coverage, serialize, MANIFEST, FRAGMENT }