A cold agent was given this repo and the documents it links to, and nothing else — no core source, no module-uo — and asked to build a module for a second game. It did, in one pass. The record is docs/modules/kit-acceptance.md; this is the repair list, plus the two things it recommended that were not defects. The one it could not find, because it had no core to render against: a module page built exactly as this kit teaches renders OUTSIDE the site. PublicLayout is the chrome, not the body. Core grew an opt-in `shell` prop for it (MODULE_API_VERSION 1.5.0, website#148); the template passes shell="narrow" and chapter 2 explains why you name a width and never a class. Fixed: - **F1, and the worst of them, because it lands in the first twenty minutes.** `npm run check:swagger` failed on a PRISTINE template on Windows: the check compared the committed fragment byte-for-byte and a default Windows clone is CRLF while the generator writes LF. The message blamed "the routes or their annotations". Now `template/.gitattributes` pins `eol=lf` and the comparison normalises line endings anyway — a check may only fail for the reason it names, and this one names a diagnosis. - **F3** — `.gitea/workflows/release.yml` carries `gitea.example.com` and `your-org/your-module` under a literal `# CHANGE THESE`, was not in the rename checklist, and `checkRenameSites.js` could not match it, so CI was silent by construction. Row added, pattern widened. (The agent reported both workflow flavours; only the Gitea one is affected — GitHub supplies its own variables. Corrected in the record.) The near-miss is kept in the check's comments and its suite: the obvious widening is `example\.com`, which fires on a fixture URL in checkImports.test.js. Every alternative has to be a string that cannot occur by accident, which is the same rule that made the id `examplegame`. - **F4** — the release bundle's include list was hardcoded, so adding `server/utils/` would have silently dropped it from every release while the bundle check stayed green. Inverted to an exclusion list, in both flavours, and run by hand because a release workflow never executes in CI. - **F5** — the annotation-quoting warning was wrong in both directions, and the correction is measured rather than reasoned. A backtick is harmless (the template's own description has two spans and they survive). A `"` is not, and it does not throw: `'A "quoted" status'` is silently TRUNCATED to `A "` while swagger-autogen prints Success and the error capture sees nothing. The only signal is check:swagger blaming your routes. - **F6** — `template/.gitignore`, so a copied template that is `git init`ed inherits ignore rules instead of nothing. - **F7** — the UI kit is eight exports across five rows, not seven. The contract said seven and this kit had faithfully carried the miscount out of it. Adopted, not defects: - Chapter 1 now says to run every check on the untouched copy first. That is what found F1; without a baseline the first failure is ambiguous forever. - The template ships the §2.7 self-check the agent wrote for itself. The rule has no CI in general — an outbound socket is not statically detectable — but a module can make a decidable claim about its own tree. Ported from its code with a header explaining how to NARROW it when a sidecar client arrives, since talking to your sidecar is the expected shape and is not what §2.7 forbids. The pin moves to website edge 4ad8b2b, the 1.5.0 bump, and template/module.json declares ^1.5.0 — so checkCoreApi's equality assertion still holds and the template uses a member that exists only at that ref and later. 32 server + 18 client template tests, 21 kit-script tests, all four checks green. Co-Authored-By: Claude <noreply@anthropic.com>
266 lines
11 KiB
JavaScript
266 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
// ── §2.8 — the OpenAPI fragment ───────────────────────────────────────────
|
|
//
|
|
// Generates (or checks) `swagger-fragment.json` in the bundle root: the paths,
|
|
// tags and schemas describing every route this module registers. Core merges the
|
|
// fragments of *started* modules over its own committed spec at request time and
|
|
// serves the result at `/api/docs.json` (MODULE_API.md §6.1a).
|
|
//
|
|
// ── Why a module has to ship this at all ──────────────────────────────────
|
|
//
|
|
// Core's own spec generation is STATIC analysis — swagger-autogen parses core's
|
|
// `app.js` as text and follows the literal `app.use(...)` chain. Your module
|
|
// arrives on a volume after core was built, is required by a filesystem loop, and
|
|
// mounts through `api.registerRoutes()`. There is no literal mount for a parser to
|
|
// follow, and core does not have your sources anyway. So nothing core can run
|
|
// will ever describe your routes.
|
|
//
|
|
// The failure mode is the dangerous one: swagger-autogen reports success and
|
|
// emits a spec with the routes simply absent. It happened twice inside core
|
|
// before anyone noticed, and once to the first module — 417 annotations that
|
|
// generated nothing at all, for two phases, because nobody had built the
|
|
// fragment. If you take one thing from this file, take that a green build is not
|
|
// evidence that anything was described.
|
|
//
|
|
// ── Where the prefixes come from ──────────────────────────────────────────
|
|
//
|
|
// swagger-autogen is pointed at one router file at a time, so its paths come out
|
|
// relative to that router (`/status`, not `/api/v1/public/world/status`) —
|
|
// nothing in the file says where it hangs. §6.1a requires fully-qualified paths,
|
|
// because core merges the fragment verbatim and never re-derives a prefix.
|
|
//
|
|
// So this script **runs your own `register()`** against a recording `api` and
|
|
// reads the mounts back out. Every prefix is therefore the prefix that router is
|
|
// actually registered under — the same call an operator's core will make, rather
|
|
// than a table beside it that drifts the first time a mount moves. Which file a
|
|
// recorded router object came from is answered by `require.cache`: the module
|
|
// whose `exports` IS that router.
|
|
//
|
|
// The tier base paths are the one thing that cannot be derived here, because they
|
|
// are core's and not yours. They are §2.4's normative table, quoted below.
|
|
|
|
const fs = require('fs')
|
|
const os = require('os')
|
|
const path = require('path')
|
|
|
|
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
|
|
|
|
const { fakeCtx, fakeApi } = require('../test/_fakes')
|
|
const doc = require('../swagger/doc')
|
|
|
|
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
|
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
|
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
|
|
|
|
// MODULE_API.md §2.4. A router registered under a tier sits inside that tier's
|
|
// router in core, behind its gate; the base path is core's and fixed.
|
|
const TIER_BASE = {
|
|
public: '/api/v1/public',
|
|
admin: '/api/v1/admin',
|
|
player: '/api/v1/player',
|
|
}
|
|
|
|
/**
|
|
* Run `register()` with a recording api and return `[{ file, prefix, what }]`.
|
|
*
|
|
* The ctx is the test fakes' — the same one the suite proves the module runs
|
|
* against — because registration must not touch a database (§2.2), and this
|
|
* script is exactly the kind of no-database caller that rule exists for.
|
|
*/
|
|
function mountedRouters() {
|
|
const register = require('../index')
|
|
const api = fakeApi()
|
|
register(fakeCtx(), api)
|
|
|
|
const fileOf = (router) => {
|
|
for (const mod of Object.values(require.cache)) {
|
|
if (mod && mod.exports === router) return mod.filename
|
|
}
|
|
return null
|
|
}
|
|
|
|
const mounts = []
|
|
for (const [tier, byPrefix] of Object.entries(api.record.routes || {})) {
|
|
const base = TIER_BASE[tier]
|
|
if (!base) throw new Error(`swagger: registered under unknown tier "${tier}" — §2.4 has three`)
|
|
for (const [prefix, router] of Object.entries(byPrefix)) {
|
|
mounts.push({ router, prefix: base + prefix, what: `${tier}${prefix}` })
|
|
}
|
|
}
|
|
|
|
return mounts.map(({ router, prefix, what }) => {
|
|
const file = fileOf(router)
|
|
if (!file) {
|
|
// A router built inline in index.js rather than required from its own
|
|
// file. swagger-autogen needs a file to read, so there is nothing to
|
|
// generate from — put the router in its own module.
|
|
throw new Error(`swagger: cannot find the source file of the router for ${what}`)
|
|
}
|
|
return { file, prefix, what }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Run swagger-autogen over one router file. Paths come out router-relative.
|
|
*
|
|
* **swagger-autogen reports a broken annotation and then succeeds anyway** — it
|
|
* `console.error`s "Syntax error" or "out of structure", drops that one
|
|
* annotation, and prints `Success` in green. So its diagnostics are captured here
|
|
* and made fatal. Nothing else will tell you.
|
|
*/
|
|
async function fragmentFor(file) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'module-swagger-'))
|
|
const out = path.join(dir, 'fragment.json')
|
|
|
|
const complaints = []
|
|
const realError = console.error
|
|
console.error = (...args) => {
|
|
const line = args.map(String).join(' ')
|
|
if (/syntax error|out of structure/i.test(line)) complaints.push(line.trim())
|
|
else realError(...args)
|
|
}
|
|
try {
|
|
// A DEEP COPY per call, and that is not defensive style. swagger-autogen
|
|
// writes its result back into the object it was handed, so reusing one `doc`
|
|
// across several routers re-wraps the previous pass's output every time. The
|
|
// first module to hit this produced a 484 MB fragment from six routers.
|
|
await swaggerAutogen(out, [path.relative(SERVER_ROOT, file).split(path.sep).join('/')], {
|
|
...JSON.parse(JSON.stringify(doc)),
|
|
info: { title: 'examplegame fragment', version: '0' },
|
|
})
|
|
} finally {
|
|
console.error = realError
|
|
}
|
|
if (complaints.length > 0) {
|
|
throw new Error(
|
|
`swagger: ${path.relative(MODULE_ROOT, file)} has ${complaints.length} annotation(s) ` +
|
|
`swagger-autogen could not parse — it drops them and reports success:\n ${complaints.join('\n ')}`,
|
|
)
|
|
}
|
|
|
|
const fragment = JSON.parse(fs.readFileSync(out, 'utf8'))
|
|
fs.rmSync(dir, { recursive: true, force: true })
|
|
return fragment
|
|
}
|
|
|
|
/**
|
|
* Re-root a router-relative fragment under the prefix it is mounted at.
|
|
*
|
|
* Express path params (`:id`) become OpenAPI's (`{id}`), and any param belonging
|
|
* to the PREFIX is moved to the front of each operation's parameter list —
|
|
* swagger-autogen orders parameters by where they appeared in the path it saw,
|
|
* which was only the tail.
|
|
*/
|
|
function prefixPaths(fragment, prefix) {
|
|
const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '')
|
|
const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1])
|
|
const paths = {}
|
|
for (const [p, item] of Object.entries(fragment.paths || {})) {
|
|
for (const operation of Object.values(item)) {
|
|
const params = operation && operation.parameters
|
|
if (!Array.isArray(params)) continue
|
|
const rank = (q) => {
|
|
const i = outer.indexOf(q && q.name)
|
|
return i === -1 ? outer.length : i
|
|
}
|
|
operation.parameters = params
|
|
.map((q, i) => ({ q, i }))
|
|
.sort((a, b) => rank(a.q) - rank(b.q) || a.i - b.i)
|
|
.map(({ q }) => q)
|
|
}
|
|
// `router.get('/')` under a prefix concatenates to a trailing slash, a URL no
|
|
// client calls. Core's generator normalises the same way.
|
|
paths[`${oas}${p}`.replace(/\/$/, '')] = item
|
|
}
|
|
return paths
|
|
}
|
|
|
|
/**
|
|
* Build the whole fragment: every mounted router, re-rooted and merged.
|
|
*
|
|
* Only `paths`, `tags` and `components.schemas` — the three sections §6.1a lets a
|
|
* fragment carry. `info`, `servers` and the security schemes belong to the merged
|
|
* document, which is to say to core.
|
|
*/
|
|
async function build() {
|
|
const spec = { paths: {}, tags: [], components: { schemas: {} } }
|
|
let shared = false
|
|
|
|
for (const { file, prefix, what } of mountedRouters()) {
|
|
const generated = await fragmentFor(file)
|
|
// Tags and schemas are the same on every pass — each was handed the same
|
|
// `doc` — so take them from whichever ran first. What lands in the fragment
|
|
// has to be what swagger-autogen PRODUCED and not what it was given: those
|
|
// two differ (see fragmentFor), and core merges this file verbatim into a
|
|
// spec whose own schemas went through the same mill.
|
|
if (!shared) {
|
|
spec.tags = generated.tags || []
|
|
spec.components.schemas = (generated.components || {}).schemas || {}
|
|
shared = true
|
|
}
|
|
const paths = prefixPaths(generated, prefix)
|
|
const count = Object.keys(paths).length
|
|
if (count === 0) {
|
|
// An empty result is precisely what the silent drop looks like, so it is a
|
|
// hard failure rather than a router that happens to declare no routes.
|
|
throw new Error(`swagger: ${what} (${path.relative(MODULE_ROOT, file)}) generated NO paths`)
|
|
}
|
|
for (const [p, item] of Object.entries(paths)) {
|
|
if (spec.paths[p]) throw new Error(`swagger: two of this module's routers both document ${p}`)
|
|
spec.paths[p] = item
|
|
}
|
|
process.stdout.write(` ${String(count).padStart(3)} path(s) ${prefix} ← ${what}\n`)
|
|
}
|
|
|
|
// Sorted, because swagger-autogen emits router-traversal order: without this,
|
|
// moving a route between files rewrites most of a committed artifact even when
|
|
// the API is provably unchanged.
|
|
spec.paths = Object.fromEntries(Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
return spec
|
|
}
|
|
|
|
async function main() {
|
|
const check = process.argv.includes('--check')
|
|
const spec = await build()
|
|
const json = `${JSON.stringify(spec, null, 2)}\n`
|
|
|
|
if (!check) {
|
|
fs.writeFileSync(FRAGMENT, json)
|
|
process.stdout.write(`\nwrote ${path.relative(MODULE_ROOT, FRAGMENT)} — ${Object.keys(spec.paths).length} paths\n`)
|
|
return
|
|
}
|
|
|
|
if (!fs.existsSync(FRAGMENT)) {
|
|
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
|
|
process.exit(1)
|
|
}
|
|
// Compared with line endings normalised, and that is not fussiness. A default
|
|
// Windows clone checks this file out as CRLF while the generator above writes
|
|
// LF, so a byte comparison failed on a PRISTINE template and told the reader
|
|
// their routes had changed — the kit's acceptance run lost ten minutes to it
|
|
// before reaching for `od -c` (docs/modules/kit-acceptance.md, F1). A check may
|
|
// only fail for the reason it names; this one names a diagnosis, so it has to
|
|
// be right about it. `.gitattributes` stops the CRLF from arriving in the first
|
|
// place, and this stops it mattering if it does.
|
|
const lf = (s) => s.replace(/\r\n/g, '\n')
|
|
|
|
if (lf(fs.readFileSync(FRAGMENT, 'utf8')) !== lf(json)) {
|
|
process.stderr.write(
|
|
'\nswagger-fragment.json is STALE — the routes or their annotations changed and it was not\n' +
|
|
'regenerated. Run `npm run swagger` and commit the result. Core merges this file verbatim,\n' +
|
|
'so a stale one documents a URL surface this module does not serve.\n',
|
|
)
|
|
process.exit(1)
|
|
}
|
|
process.stdout.write(`\nswagger-fragment.json is current — ${Object.keys(spec.paths).length} paths\n`)
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((err) => {
|
|
process.stderr.write(`${err.stack}\n`)
|
|
process.exit(1)
|
|
})
|
|
}
|
|
|
|
module.exports = { mountedRouters, prefixPaths, build, TIER_BASE, FRAGMENT }
|