// 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