diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0ab4f1..ceab056 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Check generated sources + run: npm run generate:check + - name: Check formatting run: npm run format:check diff --git a/eslint.config.js b/eslint.config.js index c96c212..9c12fbb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,6 +15,7 @@ export default [ "*.config.js", ".github/", "src/schema.ts", + "src/docs/", "src/.schema-*/", ], }, diff --git a/package.json b/package.json index eabd933..4ea8ab6 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "clean": "rm -rf dist tsconfig.tsbuildinfo", "test": "vitest run", "generate": "node scripts/generate.js", + "generate:check": "node scripts/generate.js --skip-download --check", "build": "tsc", "format": "prettier --write .", "format:check": "prettier --check .", @@ -73,7 +74,7 @@ "lint:fix": "eslint --fix", "spellcheck": "./scripts/spellcheck.sh", "spellcheck:fix": "./scripts/spellcheck.sh --write-changes", - "check": "npm run lint && npm run format:check && npm run spellcheck && npm run build && npm run test && npm run docs:ts:verify", + "check": "npm run generate:check && npm run lint && npm run format:check && npm run spellcheck && npm run build && npm run test && npm run docs:ts:verify", "docs:ts:build": "cd src && typedoc --options typedoc.json && typedoc --options typedoc.v2.json && echo 'TypeScript documentation generated in ./src/docs'", "docs:ts:verify": "cd src && typedoc --options typedoc.json --emit none && typedoc --options typedoc.v2.json --emit none && echo 'TypeDoc verification passed'" }, diff --git a/scripts/generate.js b/scripts/generate.js index 20e529f..ae7c3de 100644 --- a/scripts/generate.js +++ b/scripts/generate.js @@ -12,6 +12,7 @@ import * as prettier from "prettier"; const CURRENT_V1_SCHEMA_RELEASE = "schema-v1.20.0"; const CURRENT_V2_SCHEMA_RELEASE = "schema-v2.0.0-alpha.2"; +const CHECK_GENERATED = process.argv.includes("--check"); // ── Extensible-union pipeline ──────────────────────────────────────────────── // Several schemas model forward compatibility as an "extensible union": known @@ -109,17 +110,23 @@ async function main() { } for (const config of SCHEMA_CONFIGS) { - await generateSchema(config); + await generateSchema(config, CHECK_GENERATED); } } -async function generateSchema(config) { +async function generateSchema(config, checkGenerated) { const metadata = JSON.parse(await fs.readFile(config.metadataPath, "utf8")); const schemaSrc = await fs.readFile(config.schemaPath, "utf8"); const jsonSchema = JSON.parse( schemaSrc.replaceAll("#/$defs/", "#/components/schemas/"), ); + assertMetadataMatchesSchema( + metadata, + jsonSchema.$defs, + config.name, + Number.parseInt(config.openApiVersion, 10), + ); addExperimentalTags(jsonSchema); stripAnyOfDiscriminators(jsonSchema); const defExclusions = annotateExtensibleUnions(jsonSchema.$defs); @@ -241,6 +248,19 @@ export const PROTOCOL_VERSION = ${metadata.version}; await formatStable(`${indexSrc.replace(/\s*ClientOptions,/, "")}\n${meta}`), ); + if (checkGenerated) { + const drift = await compareGeneratedDirectories(schemaDir, stagingDir); + await fs.rm(stagingDir, { recursive: true, force: true }); + if (drift.length > 0) { + throw new Error( + `[${config.name}] Generated sources are stale:\n` + + `${drift.map((change) => ` ${change}`).join("\n")}\n` + + "Run `npm run generate -- --skip-download` and commit the result.", + ); + } + return; + } + // Rename-aside swap: a valid src/schema exists at every instant, so an // interruption strands at worst an ignored dot-directory, never a missing // schema. (ENOENT: a fresh checkout may have no schema dir to set aside.) @@ -252,6 +272,136 @@ export const PROTOCOL_VERSION = ${metadata.version}; await fs.rm(previousDir, { recursive: true, force: true }); } +function assertMetadataMatchesSchema( + metadata, + schemaDefs, + lane, + expectedVersion, +) { + if (metadata.version !== expectedVersion) { + throw new Error( + `[${lane}] Expected metadata protocol version ${expectedVersion}, ` + + `found ${JSON.stringify(metadata.version)}`, + ); + } + + const schemaMethods = { + agent: new Set(), + client: new Set(), + protocol: new Set(), + }; + for (const [name, schema] of Object.entries(schemaDefs)) { + const method = schema["x-method"]; + const side = schema["x-side"]; + if (method === undefined) continue; + if (typeof method !== "string") { + throw new Error( + `[${lane}] ${name} has a non-string x-method: ${JSON.stringify(method)}`, + ); + } + + if (side === "both") { + schemaMethods.agent.add(method); + schemaMethods.client.add(method); + } else if (side in schemaMethods) { + schemaMethods[side].add(method); + } else { + throw new Error( + `[${lane}] ${name} has x-method ${JSON.stringify(method)} but an ` + + `unsupported x-side: ${JSON.stringify(side)}`, + ); + } + } + + const metadataMethods = { + agent: metadataMethodSet(metadata.agentMethods, lane, "agentMethods"), + client: metadataMethodSet(metadata.clientMethods, lane, "clientMethods"), + protocol: metadataMethodSet( + metadata.protocolMethods, + lane, + "protocolMethods", + ), + }; + + for (const side of Object.keys(schemaMethods)) { + const missing = [...schemaMethods[side]] + .filter((method) => !metadataMethods[side].has(method)) + .sort(); + const extra = [...metadataMethods[side]] + .filter((method) => !schemaMethods[side].has(method)) + .sort(); + if (missing.length > 0 || extra.length > 0) { + throw new Error( + `[${lane}] ${side} method metadata does not match schema x-methods.` + + `${missing.length > 0 ? `\n missing: ${missing.join(", ")}` : ""}` + + `${extra.length > 0 ? `\n extra: ${extra.join(", ")}` : ""}`, + ); + } + } +} + +function metadataMethodSet(value, lane, field) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`[${lane}] Metadata ${field} must be an object`); + } + const methods = Object.values(value); + if (!methods.every((method) => typeof method === "string")) { + throw new Error(`[${lane}] Metadata ${field} values must all be strings`); + } + if (new Set(methods).size !== methods.length) { + throw new Error(`[${lane}] Metadata ${field} contains duplicate methods`); + } + return new Set(methods); +} + +async function compareGeneratedDirectories(expectedDir, generatedDir) { + const expected = await readDirectoryFiles(expectedDir); + const generated = await readDirectoryFiles(generatedDir); + const paths = [...new Set([...expected.keys(), ...generated.keys()])].sort(); + const drift = []; + + for (const path of paths) { + if (!expected.has(path)) { + drift.push(`added: ${path}`); + } else if (!generated.has(path)) { + drift.push(`removed: ${path}`); + } else if (!expected.get(path).equals(generated.get(path))) { + drift.push(`changed: ${path}`); + } + } + + return drift; +} + +async function readDirectoryFiles(root, relative = "") { + const files = new Map(); + const entries = await fs.readdir(`${root}/${relative}`, { + withFileTypes: true, + }); + + for (const entry of entries.sort((left, right) => + left.name.localeCompare(right.name), + )) { + const path = relative ? `${relative}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + for (const [childPath, contents] of await readDirectoryFiles( + root, + path, + )) { + files.set(childPath, contents); + } + } else if (entry.isFile()) { + files.set(path, await fs.readFile(`${root}/${path}`)); + } else { + throw new Error( + `Unsupported generated filesystem entry: ${root}/${path}`, + ); + } + } + + return files; +} + // Formats until prettier reaches a fixed point. Prettier's member-chain // heuristic keeps chains that arrive pre-broken, so formatting hey-api's raw // output once can produce a string that `prettier --check` would still