// AI guide maintenance for the HMDesign packages. // node scripts/ai-docs.mjs --sync copy the shared docs/ai files into every package's ai/ folder // node scripts/ai-docs.mjs --check verify copies, package "files", catalog <-> public export parity and ai/ links // Package sources are read, never built; the check needs no dist. import { copyFile, 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 shared = ["AI_GUIDE.md", "AGENTS_SNIPPET.md"] // entries: public JS entrypoints (package.json "exports"). catalog: the document that must list every export. const packages = { ui: { entries: ["src/index.ts", "src/theme/index.ts"], catalog: "ai/COMPONENT_CATALOG.md" }, sw: { entries: ["src/index.ts"], catalog: "ai/COMPONENT_CATALOG.md" }, "org-chart": { entries: ["src/index.ts"], catalog: "ai/COMPONENT_CATALOG.md" }, "org-chart-react": { entries: ["src/index.ts", "src/hm-dialog.tsx"], catalog: "../org-chart/ai/COMPONENT_CATALOG.md" }, } // Sections whose names are intentionally not public API (excluded from the reverse check). const privateHeading = /공개하지 않은|없는 것|지원하지 않는|사용 금지/ const mode = process.argv[2] if (mode !== "--sync" && mode !== "--check") { console.error("Usage: node scripts/ai-docs.mjs --sync | --check") process.exit(2) } const packageDir = name => path.join(root, "packages", name) const failures = [] if (mode === "--sync") { for (const name of Object.keys(packages)) { for (const file of shared) await copyFile(path.join(root, "docs/ai", file), path.join(packageDir(name), "ai", file)) } console.log(`Synced ${shared.join(", ")} into ${Object.keys(packages).length} packages.`) process.exit(0) } // 1. Shared copies are identical to docs/ai. for (const file of shared) { const source = await readFile(path.join(root, "docs/ai", file), "utf8") for (const name of Object.keys(packages)) { const copy = path.join(packageDir(name), "ai", file) if (!existsSync(copy)) failures.push(`${name}: ai/${file} missing (run npm run sync:ai-docs)`) else if ((await readFile(copy, "utf8")) !== source) failures.push(`${name}: ai/${file} differs from docs/ai/${file} (run npm run sync:ai-docs)`) } } // 2. The ai/ folder is packed. for (const name of Object.keys(packages)) { const manifest = JSON.parse(await readFile(path.join(packageDir(name), "package.json"), "utf8")) if (!manifest.files?.includes("ai")) failures.push(`${name}: package.json "files" does not include "ai"`) } // 3. Catalog <-> public exports. const resolveModule = (from, specifier) => { const base = path.resolve(path.dirname(from), specifier.replace(/\.js$/, "")) return [".ts", ".tsx", "/index.ts", "/index.tsx"].map(ext => base + ext).find(existsSync) } async function collectExports(file, seen = new Set()) { if (seen.has(file)) return new Set() seen.add(file) const text = (await readFile(file, "utf8")).replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, "") const names = new Set() for (const [, body] of text.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}/g)) { for (const part of body.split(",")) { const item = part.trim().replace(/^type\s+/, "") if (!item) continue const alias = item.split(/\s+as\s+/).pop().trim() if (alias !== "default") names.add(alias) } } for (const [, name] of text.matchAll(/export\s+(?:declare\s+)?(?:async\s+)?(?:const|let|function\*?|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/g)) names.add(name) for (const [, specifier] of text.matchAll(/export\s+(?:type\s+)?\*\s+from\s+["']([^"']+)["']/g)) { if (!specifier.startsWith(".")) continue // re-export of another package (documented by that package) const target = resolveModule(file, specifier) if (!target) { failures.push(`cannot resolve ${specifier} from ${path.relative(root, file)}`); continue } for (const name of await collectExports(target, seen)) names.add(name) } return names } function codeTokens(markdown, { publicOnly }) { const tokens = new Set() let isPrivate = false for (const line of markdown.split("\n")) { const heading = line.match(/^#{1,6}\s+(.*)/) if (heading) isPrivate = privateHeading.test(heading[1]) if (publicOnly && (isPrivate || /^\s*(없는 것|공개하지 않은)/.test(line))) continue for (const [, span] of line.matchAll(/`([^`]+)`/g)) for (const word of span.match(/[A-Za-z_$][\w$]*/g) ?? []) tokens.add(word) } return tokens } const catalogExports = new Map() for (const [name, config] of Object.entries(packages)) { const catalogPath = path.resolve(packageDir(name), config.catalog) const exports = new Set() for (const entry of config.entries) for (const item of await collectExports(path.join(packageDir(name), entry))) exports.add(item) if (!catalogExports.has(catalogPath)) catalogExports.set(catalogPath, new Set()) for (const item of exports) catalogExports.get(catalogPath).add(item) const markdown = await readFile(catalogPath, "utf8") const mentioned = codeTokens(markdown, { publicOnly: true }) const missing = [...exports].filter(item => !mentioned.has(item)) if (missing.length) failures.push(`${name}: exports missing from ${path.relative(root, catalogPath)}: ${missing.join(", ")}`) } // Reverse: a code name that extends an exported name (e.g. DialogFoo, SWHeaderBar) must itself be exported, // unless it sits in a "not public" section. Catches invented components that look like real ones. for (const [catalogPath, exports] of catalogExports) { const markdown = await readFile(catalogPath, "utf8") const sorted = [...exports].filter(item => /^[A-Z]/.test(item) && item.length >= 4) const invented = [...codeTokens(markdown, { publicOnly: true })].filter(token => /^[A-Z]/.test(token) && !exports.has(token) && sorted.some(item => token.startsWith(item) && token !== item)) if (invented.length) failures.push(`${path.relative(root, catalogPath)}: names that look like exports but are not: ${invented.join(", ")}`) } // 4. Links inside every ai/ document resolve, and never point at repository-only docs (they are not packed). for (const name of Object.keys(packages)) { const aiDir = path.join(packageDir(name), "ai") const { readdir } = await import("node:fs/promises") for (const file of (await readdir(aiDir)).filter(item => item.endsWith(".md"))) { const markdown = await readFile(path.join(aiDir, file), "utf8") for (const [, target] of markdown.matchAll(/\]\(([^)\s]+)\)/g)) { if (/^(https?:|mailto:|#)/.test(target)) continue const resolved = path.resolve(aiDir, target.split("#")[0]) const relativeToPackages = path.relative(path.join(root, "packages"), resolved) const [targetPackage, folder] = relativeToPackages.split(path.sep) if (relativeToPackages.startsWith("..") || (targetPackage !== name && folder !== "ai")) failures.push(`${name}/ai/${file}: link leaves the packed ai/ docs: ${target}`) else if (!existsSync(resolved)) failures.push(`${name}/ai/${file}: broken link ${target}`) } } } if (failures.length) { console.error(`AI docs check FAILED (${failures.length})`) for (const failure of failures) console.error(`- ${failure}`) process.exit(1) } console.log(`AI docs check PASS: ${shared.length} shared files x ${Object.keys(packages).length} packages, catalogs match public exports, ai/ links resolve.`)