- Shared entry point docs/ai/AI_GUIDE.md (type selection Web / SW / OrgChart / mixed, steps 1-7, package style separation, report format, examples A-D) and AGENTS_SNIPPET.md, copied into every package ai/ folder - New ai/ docs for @hm-design/ui and @hm-design/org-chart (+ org-chart-react pointer): AI_USAGE, COMPONENT_CATALOG, DESIGN_RULES, COMPOSITION_PATTERNS - SW ai/ linked to the shared guide; catalog lists every Props type explicitly - "ai" added to package files of ui, org-chart, org-chart-react - scripts/ai-docs.mjs: sync:ai-docs / check:ai-docs (copies, files, catalog <-> exports, links) - Maintenance guide and verification record under docs/ai
139 lines
7.3 KiB
JavaScript
139 lines
7.3 KiB
JavaScript
// 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.`)
|