diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0ab4f1d..2d230b07 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 @@ -48,6 +51,9 @@ jobs: - name: Build run: npm run build + - name: Check package contents + run: npm run package:check + - name: Run tests run: npm test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8d575cc0..057f0bf7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -52,4 +52,5 @@ jobs: run: npm install -g npm@latest - run: npm ci - run: npm run build + - run: npm run package:check - run: npm publish diff --git a/eslint.config.js b/eslint.config.js index c96c212b..9c12fbb8 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 eabd9339..349648a1 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,13 @@ "license": "Apache-2.0", "author": "Zed Industries", "files": [ - "typescript", "dist", + "!dist/**/*.test.*", + "!dist/examples", + "!dist/test-support", "schema/schema.json", "schema/v2/schema.unstable.json", - "LICENSE-APACHE" + "LICENSE" ], "type": "module", "main": "dist/acp.js", @@ -66,14 +68,16 @@ "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", + "package:check": "node scripts/check-package.js", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint", "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 package:check && 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/check-package.js b/scripts/check-package.js new file mode 100644 index 00000000..9fff4a89 --- /dev/null +++ b/scripts/check-package.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const packageJson = JSON.parse(await readFile("package.json", "utf8")); +const { stdout } = await execFileAsync( + process.env.npm_execpath ?? "npm", + ["pack", "--dry-run", "--json"], + { + env: { + ...process.env, + npm_config_loglevel: "silent", + }, + maxBuffer: 10 * 1024 * 1024, + }, +); +const packs = JSON.parse(stdout); +if (!Array.isArray(packs) || packs.length !== 1) { + throw new Error(`Expected one npm pack result, received ${packs.length}`); +} + +const files = new Set(packs[0].files.map(({ path }) => path)); +const forbidden = [...files].filter( + (path) => + path.startsWith("dist/examples/") || + path.startsWith("dist/test-support/") || + /\.test\.(?:d\.ts|js|js\.map)$/.test(path), +); +if (forbidden.length > 0) { + throw new Error( + `The package contains test-only files:\n${forbidden + .sort() + .map((path) => ` ${path}`) + .join("\n")}`, + ); +} + +const required = new Set([ + "LICENSE", + "README.md", + "package.json", + packageJson.main, + packageJson.types, + ...exportTargets(packageJson.exports), +]); +const missing = [...required].filter((path) => !files.has(path)).sort(); +if (missing.length > 0) { + throw new Error( + `The package is missing public entrypoints:\n${missing + .map((path) => ` ${path}`) + .join("\n")}`, + ); +} + +const unexpected = [...files] + .filter( + (path) => + !path.startsWith("dist/") && + !path.startsWith("schema/") && + path !== "LICENSE" && + path !== "README.md" && + path !== "package.json", + ) + .sort(); +if (unexpected.length > 0) { + throw new Error( + `The package contains files outside its public allowlist:\n${unexpected + .map((path) => ` ${path}`) + .join("\n")}`, + ); +} + +console.log( + `Package contents verified: ${files.size} files, ${packs[0].unpackedSize} bytes unpacked`, +); + +function exportTargets(exports) { + const targets = []; + visit(exports); + return targets; + + function visit(value) { + if (typeof value === "string") { + targets.push(value.replace(/^\.\//, "")); + } else if (value && typeof value === "object") { + for (const child of Object.values(value)) visit(child); + } + } +} diff --git a/scripts/generate.js b/scripts/generate.js index 20e529f4..ae7c3dee 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