Files
HMDesign/packages/sw/tests/public-api.check.mjs
T
shkim4 1e05eaf1de test(sw): detect untracked source files and public API changes
- source-changes.mjs: source guards list untracked, staged, deleted and renamed files, not only git diff --name-only.
- overview / storybook-unused-removal: production source guards use the helper (assertions, paths, approved list unchanged).
- public-api.check.mjs + public-api.approved.json: the built @hm-design/sw entry must export exactly the approved
  names and kinds, keep the package.json export settings and keep every declaration reachable from dist/index.d.ts
  byte-identical. The approved surface was written from a build of 553e4df (approvedMainCommit).
2026-10-21 06:08:16 +09:00

110 lines
9.7 KiB
JavaScript

// 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 <dist-dir> <package.json> <out.json>
// 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 <dist-dir> <package.json> <out.json>")
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
}