// Public API guard of @hm-design/sw (2026-10-21): the built package must expose exactly the approved surface. // Usage: node packages/sw/tests/public-api.check.mjs [dist-dir] [package.json] (build first: npm run build -w @hm-design/sw) // node packages/sw/tests/public-api.check.mjs --snapshot // The approved surface is public-api.approved.json, written with --snapshot from a build of a reviewed commit // (approvedCommit, read from that build's build-info.json). It records: // 1. manifest: the package.json fields that decide what a consumer can import (exports, main, types, module, files). // 2. exports: every name the entry exports, "value" when dist/index.js exports it at runtime, otherwise "type". // 3. declarations: SHA-256 of every .d.ts reachable from dist/index.d.ts through relative imports. A changed public // type, an internal type a public one references, or an internal module newly reachable from the entry all change it. // Internal modules the entry never reaches (e.g. components/shared) and implementation-only edits leave it unchanged. // The check never rewrites the snapshot. Change it only in a reviewed commit that approves the API change, written from // a build of that commit; never regenerate it from an arbitrary HEAD to make a failing check pass. import { createHash } from "node:crypto" import { execFileSync } from "node:child_process" import { existsSync } from "node:fs" import { readFile, writeFile } from "node:fs/promises" import path from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" import { approvedMainCommit } from "./storybook-inventory.mjs" const packageRoot = fileURLToPath(new URL("../", import.meta.url)) const snapshotFile = fileURLToPath(new URL("./public-api.approved.json", import.meta.url)) const manifestFields = ["exports", "main", "types", "module", "files"] // Names a declaration file exports, following `export *` and `export { } from` into other reachable files. const nameList = text => text.split(",").map(part => part.trim()).filter(Boolean) .map(part => ({ type: /^type\s/.test(part), local: part.replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim(), name: part.replace(/^type\s+/, "").split(/\s+as\s+/).pop().trim() })) async function surface(dist) { const files = new Map() const load = async file => { if (!files.has(file)) { if (!existsSync(path.join(dist, file))) throw new Error(`missing declaration ${file}`) const text = (await readFile(path.join(dist, file), "utf8")).replace(/\r\n?/g, "\n") files.set(file, text) for (const match of text.matchAll(/(?:from\s+|import\()["'](\.\.?\/[^"']+)["']/g)) await load(target(file, match[1])) } return files.get(file) } const target = (from, specifier) => path.posix.join(path.posix.dirname(from), specifier.replace(/\.js$/, ".d.ts")) const exportsOf = async (file, seen = new Set()) => { if (seen.has(file)) return new Map() seen.add(file) const text = await load(file), names = new Map() for (const match of text.matchAll(/^export\s+(?:declare\s+)?(?:abstract\s+)?(const|let|var|function|class|enum|interface|type|namespace)\s+(\w+)/gm)) names.set(match[2], match[1] === "interface" || match[1] === "type" ? "type" : "value") if (/^export\s+default\b/m.test(text)) names.set("default", "value") for (const match of text.matchAll(/^export\s+(type\s+)?\{([^}]*)\}\s*from\s*["']([^"']+)["']/gm)) { const inner = await exportsOf(target(file, match[3]), new Set(seen)) for (const item of nameList(match[2])) names.set(item.name, match[1] || item.type ? "type" : inner.get(item.local) ?? "missing") } for (const match of text.matchAll(/^export\s+(type\s+)?\{([^}]*)\}\s*;?\s*$/gm)) for (const item of nameList(match[2])) names.set(item.name, match[1] || item.type || new RegExp(`(?:^|\s)(?:interface|type)\s+${item.local}\b`, "m").test(text) ? "type" : "value") for (const match of text.matchAll(/^export\s+\*\s+from\s+["']([^"']+)["']/gm)) for (const [name, kind] of await exportsOf(target(file, match[1]), new Set(seen))) if (name !== "default" && !names.has(name)) names.set(name, kind) for (const match of text.matchAll(/^export\s+\*\s+as\s+(\w+)\s+from/gm)) names.set(match[1], "value") return names } const declared = await exportsOf("index.d.ts") const runtime = Object.keys(await import(pathToFileURL(path.join(dist, "index.js")).href)) const exports = Object.fromEntries([...declared.keys(), ...runtime].sort().map(name => [name, runtime.includes(name) ? "value" : declared.has(name) ? "type" : "untyped"])) const declarations = Object.fromEntries([...files].sort(([a], [b]) => a.localeCompare(b)).map(([file, text]) => [file, createHash("sha256").update(text).digest("hex")])) // Declared values the runtime lacks, or runtime values without a declaration, would break consumers either way. const mismatch = [...declared].filter(([name, kind]) => kind === "missing" || (kind === "value" && !runtime.includes(name))).map(([name]) => name) return { exports, declarations, mismatch } } const manifestOf = async file => { const json = JSON.parse(await readFile(file, "utf8")) return { name: json.name, ...Object.fromEntries(manifestFields.map(field => [field, json[field] ?? null])) } } if (process.argv[2] === "--snapshot") { const [dist, manifestFile, out] = process.argv.slice(3).map(item => path.resolve(item)) if (!out) throw new Error("Pass --snapshot ") const info = JSON.parse(await readFile(path.join(dist, "build-info.json"), "utf8")) const commit = info.commit.split("+")[0] if (!/^[0-9a-f]{40}$/.test(commit) || info.dirty === true) throw new Error(`build-info.json must name one clean commit, got ${info.commit} (dirty ${info.dirty})`) const { mismatch, ...api } = await surface(dist) if (mismatch.length || Object.values(api.exports).includes("untyped")) throw new Error(`declarations and runtime disagree: ${JSON.stringify({ mismatch, exports: api.exports })}`) await writeFile(out, JSON.stringify({ package: info.name, approvedCommit: commit, manifest: await manifestOf(manifestFile), ...api }, null, 2) + "\n") console.log(`wrote ${out}: ${Object.keys(api.exports).length} exports, ${Object.keys(api.declarations).length} declaration files from ${commit}`) } else { const dist = path.resolve(process.argv[2] ?? path.join(packageRoot, "dist")) const manifestFile = path.resolve(process.argv[3] ?? path.join(packageRoot, "package.json")) const approved = JSON.parse(await readFile(snapshotFile, "utf8")) const results = [] const check = (name, ok, data) => { results.push(ok); console.log(ok ? "PASS" : "FAIL", name, data === undefined ? "" : JSON.stringify(data)) } const ancestor = (a, b) => { try { execFileSync("git", ["merge-base", "--is-ancestor", a, b], { cwd: packageRoot, stdio: "ignore" }); return true } catch { return false } } check("approved surface comes from a reviewed commit (ancestor of approvedMainCommit and HEAD)", ancestor(approved.approvedCommit, approvedMainCommit) && ancestor(approved.approvedCommit, "HEAD"), { approvedCommit: approved.approvedCommit, approvedMainCommit }) const manifest = await manifestOf(manifestFile) const changedFields = ["name", ...manifestFields].filter(field => JSON.stringify(manifest[field]) !== JSON.stringify(approved.manifest[field])) check("package.json export settings unchanged (exports, main, types, module, files)", changedFields.length === 0, Object.fromEntries(changedFields.map(field => [field, { approved: approved.manifest[field], current: manifest[field] }]))) const targets = JSON.stringify(manifest.exports).match(/"\.\/[^"]+"/g)?.map(item => item.slice(1, -1)) ?? [] const missingTargets = [...new Set([...targets, manifest.main, manifest.types].filter(item => item?.startsWith("./dist/")))].filter(item => !existsSync(path.join(path.dirname(manifestFile), item))) check("every package.json export target exists in dist", missingTargets.length === 0, { missing: missingTargets }) const current = await surface(dist) const added = Object.keys(current.exports).filter(name => !(name in approved.exports)) const removed = Object.keys(approved.exports).filter(name => !(name in current.exports)) const kindChanged = Object.keys(current.exports).filter(name => name in approved.exports && current.exports[name] !== approved.exports[name]) // A rename shows up as one removed and one added name; both sides are listed. check(`entry exports exactly the ${Object.keys(approved.exports).length} approved names (no addition, removal or rename)`, added.length === 0 && removed.length === 0, { added, removed }) check("each export keeps its kind (runtime value vs type only); runtime and declarations agree", kindChanged.length === 0 && current.mismatch.length === 0, { kindChanged: kindChanged.map(name => ({ name, approved: approved.exports[name], current: current.exports[name] })), declaredWithoutRuntime: current.mismatch }) const newFiles = Object.keys(current.declarations).filter(file => !(file in approved.declarations)) const goneFiles = Object.keys(approved.declarations).filter(file => !(file in current.declarations)) const changedFiles = Object.keys(current.declarations).filter(file => file in approved.declarations && current.declarations[file] !== approved.declarations[file]) check("declarations reachable from the entry are the approved files (no internal module newly exposed)", newFiles.length === 0 && goneFiles.length === 0, { newlyReachable: newFiles, noLongerReachable: goneFiles }) check("reachable declarations byte-identical to the approved build (no public type change)", changedFiles.length === 0, { changed: changedFiles }) console.log(`${results.filter(Boolean).length}/${results.length} PASS`) process.exitCode = results.every(Boolean) ? 0 : 1 }