docs(ai): add packaged AI guides for Web, SW and OrgChart

- 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
This commit is contained in:
shkim4 committed 2026-10-19 17:13:36 +09:00
1 parent e8409eff44
commit fb74cd756f
34 files changed
+2396 -6

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
// 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.`)