Files
HMDesign/packages/sw/tests/ai-docs.check.mjs
T

58 lines
4.8 KiB
JavaScript

// Checks that the shipped AI guide matches the built public API (2026-10-19).
// Usage: node packages/sw/tests/ai-docs.check.mjs (build first: npm run build -w @hm-design/sw)
// 1. Every SW identifier named in ai/*.md is a real export, unless the doc lists it as not public.
// 2. Every import from "@hm-design/sw" in ai/examples is a real export; no deep package import.
// 3. Every relative link in ai/*.md and README.md resolves inside the package (tarball-safe).
import { readdir, readFile } from "node:fs/promises"
import { existsSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const root = fileURLToPath(new URL("../", import.meta.url))
const declarations = await readFile(path.join(root, "dist/index.d.ts"), "utf8")
const nested = [...declarations.matchAll(/from\s+["'](\.[^"']+)["']/g)].map(match => match[1])
let exported = declarations
for (const file of nested) exported += await readFile(path.join(root, "dist", file.replace(/\.js$/, ".d.ts")), "utf8")
const names = new Set()
for (const block of exported.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}/g)) for (const part of block[1].split(",")) { const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop(); if (name) names.add(name) }
for (const match of exported.matchAll(/export\s+(?:declare\s+)?(?:const|function|interface|type)\s+(\w+)/g)) names.add(match[1])
// Only names re-exported by the entry are public: collect the entry's re-export lists.
const publicNames = new Set()
for (const block of declarations.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}/g)) for (const part of block[1].split(",")) { const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop(); if (name) publicNames.add(name) }
for (const match of declarations.matchAll(/export\s+\*\s+from\s+["']([^"']+)["']/g)) {
const text = await readFile(path.join(root, "dist", match[1].replace(/\.js$/, ".d.ts")), "utf8")
for (const block of text.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}/g)) for (const part of block[1].split(",")) { const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop(); if (name) publicNames.add(name) }
}
void names
const results = []
const check = (name, ok, data) => { results.push(ok); console.log(ok ? "PASS" : "FAIL", name, data === undefined ? "" : JSON.stringify(data)) }
const docs = (await readdir(path.join(root, "ai"))).filter(name => name.endsWith(".md"))
for (const doc of docs) {
const text = await readFile(path.join(root, "ai", doc), "utf8")
const notPublic = new Set([...(text.split(/^## .*공개하지 않은 것.*$/m)[1]?.split(/^## /m)[0] ?? "").matchAll(/`(\w+)`/g)].map(match => match[1]))
const mentioned = [...new Set([...text.matchAll(/`((?:SW|parseSW|swHeader|useSW)\w+)(?![\w*])/g)].map(match => match[1]))]
const unknown = mentioned.filter(name => !publicNames.has(name) && !notPublic.has(name) && !/^SWSearchControl/.test(name))
check(`${doc}: named SW identifiers are public exports`, unknown.length === 0, { mentioned: mentioned.length, unknown })
const leaked = [...notPublic].filter(name => publicNames.has(name))
check(`${doc}: "not public" list contains no export`, leaked.length === 0, leaked)
}
for (const file of (await readdir(path.join(root, "ai/examples"))).filter(name => /\.tsx?$/.test(name))) {
const text = await readFile(path.join(root, "ai/examples", file), "utf8")
const imports = [...text.matchAll(/import\s*\{([^}]*)\}\s*from\s*"@hm-design\/sw"/g)].flatMap(match => match[1].split(",").map(part => part.trim().replace(/^type\s+/, "")).filter(Boolean))
const deep = /from\s*"@hm-design\/sw\/(?!styles\.css)/.test(text)
check(`examples/${file}: imports only public exports`, !deep && imports.every(name => publicNames.has(name)), { imports, missing: imports.filter(name => !publicNames.has(name)), deep })
}
for (const doc of [...docs.map(name => path.join("ai", name)), "README.md"]) {
const text = await readFile(path.join(root, doc), "utf8")
const links = [...text.matchAll(/\]\((\.\/[^)#\s]+)/g)].map(match => match[1])
const broken = links.filter(link => !existsSync(path.resolve(root, path.dirname(doc), link)))
check(`${doc}: package-relative links resolve`, broken.length === 0, { links: links.length, broken })
// Any other relative link (e.g. ../../docs/...) points outside the published package and breaks once installed.
const relative = [...text.matchAll(/\]\(([^)\s]+)\)/g)].map(match => match[1]).filter(link => !/^(?:[a-z][a-z0-9+.-]*:|#|\/|\.\/)/i.test(link))
const outside = relative.filter(link => path.relative(root, path.resolve(root, path.dirname(doc), link.split("#")[0])).startsWith(".."))
check(`${doc}: no links outside the package`, outside.length === 0, { links: relative.length, outside })
}
console.log(`${results.filter(Boolean).length}/${results.length} PASS`)
process.exitCode = results.every(Boolean) ? 0 : 1