diff --git a/AGENTS.md b/AGENTS.md index 4974e06..74919a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,12 @@ # HMDesignSystem 작업 지침 +## AI Generation Guard FAST CORE + +- AI 화면 생성 또는 부분 수정 완료 후 실제 재사용한 HMDesign 요소에 대해 `node scripts/ai-generation-guard.mjs --target <앱 경로> --scope FULL_SCREEN|PARTIAL`을 1회 실행한다. Navigation을 재사용하면 확정된 `--mode`를 전달하고, PARTIAL은 `--files <수정 파일>`로 범위를 지정한다. +- FAIL이 발생한 항목만 수정한다. +- 수정 후에는 수정한 범위만 PARTIAL로 다시 검사한다. +- 정상 결과에 대해 추가 검사를 반복하지 않는다. + ## AI 화면 구현 Quick Start 화면 구현 요청을 받으면 아래 순서를 그대로 따른다. 파일이나 API의 존재를 확인할 수 없으면 추측하지 말고 미확인으로 기록한다. diff --git a/scripts/ai-generation-guard.mjs b/scripts/ai-generation-guard.mjs new file mode 100644 index 0000000..cb2d141 --- /dev/null +++ b/scripts/ai-generation-guard.mjs @@ -0,0 +1,413 @@ +#!/usr/bin/env node +// FAST CORE only. Read-only static contracts; PASS does not verify browser quality. +// Run from this workspace (existing Babel/PostCSS dependencies; no target install needed). +// --entry selects a production bootstrap; --files narrows PARTIAL findings to changed files. +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { performance } from 'node:perf_hooks'; + +const workspace = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const groups = ['Navigation', 'Storybook / Production', 'Production CSS Entry', 'Official Component Import']; +const modes = ['GNB_ONLY', 'LNB_ONLY', 'DASHBOARD_ONLY']; +const normalize = p => p.replaceAll('\\', '/'); +const isFile = p => { try { return fs.statSync(p).isFile(); } catch { return false; } }; +const within = (root, p) => { const r = path.relative(root, p); return r === '' || (!r.startsWith('..') && !path.isAbsolute(r)); }; +const literal = n => n?.type === 'StringLiteral' ? n.value : n?.type === 'TemplateLiteral' && n.expressions.length === 0 ? n.quasis[0].value.cooked : undefined; +const nameOf = n => n?.name ?? n?.value; +const tagName = n => n?.type === 'JSXMemberExpression' || n?.type === 'MemberExpression' ? `${tagName(n.object)}.${nameOf(n.property)}` : nameOf(n); +function walk(node, fn) { + if (!node || typeof node !== 'object') return; + if (node.type) fn(node); + for (const [key, value] of Object.entries(node)) { + if (['loc', 'start', 'end', 'comments', 'tokens', 'extra'].includes(key)) continue; + if (Array.isArray(value)) value.forEach(x => walk(x, fn)); + else if (value && typeof value === 'object') walk(value, fn); + } +} + +function argumentsFrom(argv) { + const options = { scope: 'FULL_SCREEN', entry: [], files: [], json: false }; + for (let i = 0; i < argv.length; i++) { + const key = argv[i]; + if (key === '--json' || key === '--help') { options[key.slice(2)] = true; continue; } + if (!['--target', '--scope', '--mode', '--entry', '--files'].includes(key) || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`Invalid option: ${key}`); + const value = argv[++i]; + if (key === '--entry' || key === '--files') options[key.slice(2)].push(...value.split(',').filter(Boolean)); + else options[key.slice(2)] = value; + } + if (!options.help && (!options.target || !['FULL_SCREEN', 'PARTIAL'].includes(options.scope) || (options.mode && !modes.includes(options.mode)))) throw new Error('Use --target PATH --scope FULL_SCREEN|PARTIAL [--mode GNB_ONLY|LNB_ONLY|DASHBOARD_ONLY]'); + if (options.files.length && options.scope !== 'PARTIAL') throw new Error('--files is for PARTIAL scope only'); + return options; +} + +async function inspect(options) { + const start = performance.now(); + const targetPath = path.resolve(options.target); + if (!fs.existsSync(targetPath)) throw new Error('Target does not exist'); + const target = isFile(targetPath) ? path.dirname(targetPath) : targetPath; + const issues = []; + const contextualIssues = new Set(); + let selectedDependencies = null; + let applicableFiles = null; + let groupApplicability = [false, false, false, false]; + const checkedElements = new Set(); + const seenIssues = new Set(); + const selected = options.files.map(p => path.resolve(target, p)); + if (options.scope === 'PARTIAL' && isFile(targetPath) && !selected.length) selected.push(targetPath); + const selectedFile = p => !selected.length || selectedDependencies?.has(p) || selected.some(s => s === p || (!isFile(s) && within(s, p))); + const add = (severity, code, file, node, message, contextual = false) => { + const line = node?.loc?.start?.line ?? node?.source?.start?.line ?? 1; + const issue = { severity, code, file: file ? normalize(path.relative(target, file)) || path.basename(file) : '.', line, message }; + const key = JSON.stringify(issue); + if (!seenIssues.has(key)) { seenIssues.add(key); issues.push(issue); if (contextual) contextualIssues.add(issue); } + }; + let parse, postcss; + try { ({ parse } = await import('@babel/parser')); postcss = (await import('postcss')).default; } + catch { add('NOT VERIFIED', 'CORE-001', null, null, 'Workspace parser dependencies unavailable; restore workspace dependencies', true); return finish(); } + if (options.scope === 'PARTIAL' && !selected.length) { + add('NOT VERIFIED', 'SCOPE-002', null, null, 'PARTIAL requires --files changed-path,... or a file --target; changed region is unknown', true); + return finish(); + } + const parseJS = (text, file) => parse(text, { sourceType: 'unambiguous', plugins: [...(/\.[cm]?tsx?$/.test(file) ? ['typescript'] : []), 'jsx'], createImportExpressions: true }); + const contracts = new Map(); + // Read only public barrels and manifest exports, never component implementations. + for (const [pkg, dir] of [['@hm-design/ui', 'ui'], ['@hm-design/center-graphic', 'center-graphic']]) { + const base = path.join(workspace, 'packages', dir); + const manifest = JSON.parse(fs.readFileSync(path.join(base, 'package.json'), 'utf8')); + const entries = new Map(); + for (const [key, spec] of Object.entries(manifest.exports)) { + if (key.endsWith('.css')) { entries.set(key, null); continue; } + const exported = typeof spec === 'string' ? spec : spec.import ?? spec.default; + const source = path.join(base, exported.replace(/^\.\/dist\//, 'src/').replace(/\.js$/, '.ts')); + const names = new Set(); + if (isFile(source)) { + for (const stmt of parseJS(fs.readFileSync(source, 'utf8'), source).program.body) { + if (stmt.type === 'ExportNamedDeclaration') { + stmt.specifiers.forEach(s => names.add(nameOf(s.exported))); + if (stmt.declaration?.id) names.add(stmt.declaration.id.name); + stmt.declaration?.declarations?.forEach(d => { if (d.id.type === 'Identifier') names.add(d.id.name); }); + } else if (stmt.type === 'ExportDefaultDeclaration') names.add('default'); + else if (stmt.type === 'ExportAllDeclaration') add('NOT VERIFIED', 'IMP-004', null, null, `${pkg}${key.slice(1)} uses an unexpanded public barrel`, true); + } + entries.set(key, names); + } else { entries.set(key, undefined); add('NOT VERIFIED', 'IMP-004', null, null, `${pkg}${key.slice(1)} public barrel unavailable`, true); } + } + contracts.set(pkg, { base, entries }); + } + const packageRef = spec => { + spec = spec.split(/[?#]/)[0]; + for (const [pkg, contract] of contracts) if (spec === pkg || spec.startsWith(`${pkg}/`)) return { pkg, key: spec === pkg ? '.' : `.${spec.slice(pkg.length)}`, contract }; + }; + const aliases = []; + // JSONC paths are read as data. Config files are never executed. + for (const file of ['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json']) { + const config = path.join(target, file); + if (!isFile(config)) continue; + try { + const ast = parseJS(`(${fs.readFileSync(config, 'utf8')})`, 'config.js'); + const toData = n => n.type === 'ObjectExpression' ? Object.fromEntries(n.properties.filter(p => p.type === 'ObjectProperty').map(p => [nameOf(p.key), toData(p.value)])) : n.type === 'ArrayExpression' ? n.elements.map(toData) : literal(n); + const compiler = toData(ast.program.body[0].expression).compilerOptions ?? {}; + for (const [key, values] of Object.entries(compiler.paths ?? {})) aliases.push({ key, values: values.map(v => path.resolve(target, compiler.baseUrl ?? '.', v)) }); + if (compiler.baseUrl) aliases.push({ key: '*', values: [path.join(target, compiler.baseUrl, '*')] }); + if (toData(ast.program.body[0].expression).extends) add('NOT VERIFIED', 'ENTRY-003', config, null, 'Inherited path aliases are not expanded', true); + } catch { add('NOT VERIFIED', 'ENTRY-003', config, null, 'Cannot read path alias configuration statically', true); } + } + // Support common Vite aliases: { '@': path.resolve(import.meta.dirname, './src') }. + for (const file of ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs']) { + const config = path.join(target, file); + if (!isFile(config)) continue; + try { + walk(parseJS(fs.readFileSync(config, 'utf8'), config), n => { + if (n.type !== 'ObjectProperty' || nameOf(n.key) !== 'alias') return; + if (n.value.type !== 'ObjectExpression') { add('NOT VERIFIED', 'ENTRY-003', config, n, 'Computed Vite aliases are not expanded', true); return; } + for (const prop of n.value.properties) { + const v = literal(prop.value); + const call = prop.value?.type === 'CallExpression' && tagName(prop.value.callee) === 'path.resolve' ? prop.value : null; + const relative = call && literal(call.arguments.at(-1)); + const base = call?.arguments[0]; + if (v || (relative && (tagName(base) === '__dirname' || (base?.type === 'MemberExpression' && base.object.type === 'MetaProperty' && nameOf(base.property) === 'dirname')))) aliases.unshift({ key: `${nameOf(prop.key)}/*`, values: [path.join(path.resolve(target, v ?? relative), '*')] }); + else add('NOT VERIFIED', 'ENTRY-003', config, prop, 'Vite alias needs runtime evaluation', true); + } + }); + } catch { add('NOT VERIFIED', 'ENTRY-003', config, null, 'Cannot parse Vite aliases', true); } + } + function resolve(spec, from) { + const clean = spec.split(/[?#]/)[0]; + if (/^(?:https?:|data:|\/\/)/.test(clean)) return null; + const bases = []; + if (clean.startsWith('.')) bases.push(path.resolve(path.dirname(from), clean)); + else if (clean.startsWith('/')) bases.push(path.resolve(target, `.${clean}`)); + else for (const { key, values } of aliases) { + const [prefix, suffix = ''] = key.split('*'); + if (key.includes('*') ? clean.startsWith(prefix) && clean.endsWith(suffix) : clean === key) { + const part = key.includes('*') ? clean.slice(prefix.length, suffix ? -suffix.length : undefined) : ''; + bases.push(...values.map(v => v.replace('*', part))); + } + } + for (const base of bases) { + const candidates = [base, ...['.tsx', '.ts', '.jsx', '.js', '.mts', '.mjs', '.cts', '.cjs', '.css'].map(e => base + e), ...['index.tsx', 'index.ts', 'index.jsx', 'index.js'].map(e => path.join(base, e))]; + if (/\.[cm]?js$/.test(base)) candidates.push(base.replace(/\.[cm]?js$/, '.ts'), base.replace(/\.[cm]?js$/, '.tsx')); + const found = candidates.find(isFile); + if (found) return fs.realpathSync(found); + } + return null; + } + const isStory = s => /(?:^|\/)(?:\.storybook|storybook)(?:\/|$)|\.stories\.[cm]?[jt]sx?$|^@storybook(?:\/|$)|(?:^|\/)(?:story-page|story-board|center-shared|center-stories)(?:\.|\/|$)/i.test(normalize(s)); + const privatePath = p => [...contracts.values()].some(c => within(c.base, p) || /\/packages\/(?:ui|center-graphic)\/(?:src|dist)\//.test(normalize(p))); + const graph = new Map(); + const checkImport = (record, edge) => { + if (!edge.runtime) return; + const ref = packageRef(edge.spec); + if (isStory(edge.spec) || isStory(edge.resolved ?? '') || ref?.pkg === '@hm-design/center-graphic' && ref.key === './reference') add('FAIL', 'SB-001', record.file, edge.node, 'Production cannot import a Storybook-only entry'); + if (edge.names.some(n => /^Story(?:Page|Section|Board)/.test(n.imported))) add('FAIL', 'SB-002', record.file, edge.node, 'Production cannot use StoryPage / StorySection helpers'); + if (ref) { + if (!ref.contract.entries.has(ref.key)) add('FAIL', 'IMP-001', record.file, edge.node, `${edge.spec} is not a public package export`); + else { + const names = ref.contract.entries.get(ref.key); + if (names) for (const binding of edge.names) if (binding.imported !== '*' && !names.has(binding.imported)) add('FAIL', 'IMP-002', record.file, edge.node, `${binding.imported} is not exported by ${edge.spec}`); + } + } else if (edge.resolved && privatePath(edge.resolved)) add('FAIL', 'IMP-001', record.file, edge.node, 'Use the public HM package entry instead of an internal source path'); + }; + function visit(file) { + if (graph.has(file)) return; + const record = { file, edges: [], bindings: new Map(), exports: new Map(), stars: [], renders: [], members: [], classes: [], status: false, css: false, features: new Set() }; + graph.set(file, record); + let text; + try { text = fs.readFileSync(file, 'utf8'); } catch { add('NOT VERIFIED', 'ENTRY-002', file, null, 'Cannot read production dependency', true); return; } + const edge = (spec, node, names = [], runtime = true) => { + // Vite raw/inline/url CSS imports yield a value, not a connected stylesheet. + const cssValueOnly = /\.css(?:[?#]|$)/.test(spec) && /[?&](?:raw|inline|url)(?:[=&]|$)/.test(spec); + const item = { spec, node, names, runtime: runtime && !cssValueOnly, resolved: packageRef(spec) ? null : resolve(spec, file) }; + record.edges.push(item); + checkImport(record, item); + const aliased = aliases.some(a => spec.startsWith(a.key.split('*')[0])); + if (runtime && !packageRef(spec) && !item.resolved && (/^[./#]/.test(spec) || aliased || /\.(?:css|s[ac]ss|less)$/.test(spec))) add('NOT VERIFIED', 'ENTRY-002', file, node, `Unresolved production import: ${spec}`, true); + return item; + }; + if (/\.css$/.test(file)) { + record.css = true; + try { + const css = postcss.parse(text, { from: file }); + css.walkAtRules('import', n => { + const match = n.params.match(/^(?:url\(\s*)?["']([^"']+)["']/); + if (match) edge(match[1], n); + else add('NOT VERIFIED', 'CSS-005', file, n, 'CSS import cannot be resolved statically', true); + if (/\b(?:supports|layer)\s*\(|\)\s+(?:screen|print)|["']\s+(?:screen|print)/.test(n.params)) add('NOT VERIFIED', 'CSS-005', file, n, 'Conditional CSS entry requires runtime confirmation', true); + }); + css.walkAtRules('tailwind', n => { if (n.params === 'utilities') record.features.add('utilities'); }); + css.walkRules(n => { + if (/\.sb-(?:page(?:-inner|-header)?|section|board)(?![\w-])/.test(n.selector)) add('FAIL', 'SB-003', file, n, 'Production uses Storybook-only CSS'); + }); + css.walkDecls(n => { if (n.value.includes('--hm-dashboard-status-')) record.status = true; }); + } catch { add('NOT VERIFIED', 'CSS-005', file, null, 'Cannot parse production CSS', true); } + } else if (/\.[cm]?[jt]sx?$/.test(file)) { + let ast; + try { ast = parseJS(text, file); } catch { add('NOT VERIFIED', 'ENTRY-004', file, null, 'Cannot parse production module', true); return; } + for (const stmt of ast.program.body) { + if (stmt.type === 'ImportDeclaration') { + const names = stmt.specifiers.map(s => ({ local: s.local.name, imported: s.type === 'ImportSpecifier' ? nameOf(s.imported) : s.type === 'ImportDefaultSpecifier' ? 'default' : '*', typeOnly: stmt.importKind === 'type' || s.importKind === 'type' })); + const e = edge(stmt.source.value, stmt, names, stmt.importKind !== 'type' && (!names.length || names.some(n => !n.typeOnly))); + for (const b of names) record.bindings.set(b.local, { edge: e, imported: b.imported }); + } else if ((stmt.type === 'ExportNamedDeclaration' || stmt.type === 'ExportAllDeclaration') && stmt.source) { + const names = (stmt.specifiers ?? []).map(s => ({ imported: nameOf(s.local) ?? '*', local: nameOf(s.exported), typeOnly: stmt.exportKind === 'type' || s.exportKind === 'type' })); + const e = edge(stmt.source.value, stmt, names, stmt.exportKind !== 'type'); + if (stmt.type === 'ExportAllDeclaration') record.stars.push(e); + else names.forEach(b => record.exports.set(b.local, { edge: e, imported: b.imported })); + } else if (stmt.type === 'ExportNamedDeclaration') { + stmt.specifiers.forEach(s => record.exports.set(nameOf(s.exported), { local: nameOf(s.local) })); + if (stmt.declaration?.id) record.exports.set(stmt.declaration.id.name, { local: stmt.declaration.id.name }); + stmt.declaration?.declarations?.forEach(d => { if (d.id.type === 'Identifier') record.exports.set(d.id.name, { local: d.id.name }); }); + } else if (stmt.type === 'ExportDefaultDeclaration') record.exports.set('default', { local: stmt.declaration.id?.name ?? (stmt.declaration.type === 'Identifier' ? stmt.declaration.name : 'default') }); + } + walk(ast, n => { + if (n.type === 'ImportExpression' || n.type === 'CallExpression' && n.callee.type === 'Identifier' && n.callee.name === 'require') { + const value = literal(n.source ?? n.arguments[0]); + if (value) { edge(value, n); if (packageRef(value)) add('NOT VERIFIED', 'IMP-003', file, n, 'HM dynamic import / require bindings need runtime confirmation'); } + else add('NOT VERIFIED', 'ENTRY-005', file, n, 'Computed import needs runtime resolution', true); + } + if (n.type === 'CallExpression' && tagName(n.callee) === 'import.meta.glob') add('NOT VERIFIED', 'ENTRY-005', file, n, 'Glob imports need bundler resolution', true); + if (n.type === 'VariableDeclarator' && n.id.type === 'Identifier' && ['Identifier', 'MemberExpression'].includes(n.init?.type)) record.bindings.set(n.id.name, { local: tagName(n.init) }); + if (n.type === 'MemberExpression' && n.object.type === 'Identifier') record.members.push({ name: tagName(n), node: n }); + if (n.type === 'JSXOpeningElement') { + const attrs = new Map(n.attributes.filter(a => a.type === 'JSXAttribute').map(a => [nameOf(a.name), a.value?.type === 'JSXExpressionContainer' ? a.value.expression : a.value])); + record.renders.push({ name: tagName(n.name), node: n, attrs }); + for (const key of ['className', 'class']) if (attrs.has(key)) { + walk(attrs.get(key), x => { const v = literal(x); if (v !== undefined) record.classes.push({ value: v, node: x }); }); + } + if (['active', 'hold', 'done', 'issue'].includes(literal(attrs.get('data-status')))) record.status = true; + } + if (n.type === 'CallExpression' && /^(?:React\.)?createElement$|^(?:jsx|jsxs|jsxDEV|_jsx|_jsxs)$/.test(tagName(n.callee))) record.renders.push({ name: tagName(n.arguments[0]), node: n, attrs: new Map((n.arguments[1]?.properties ?? []).filter(p => p.type === 'ObjectProperty').map(p => [nameOf(p.key), p.value])) }); + }); + for (const c of record.classes) if (c.value.split(/\s+/).some(v => /^sb-(?:page(?:-inner|-header)?|section|board)$/.test(v))) add('FAIL', 'SB-003', file, c.node, 'Production uses Storybook-only class names'); + } else if (/\.(?:scss|sass|less)$/.test(file)) add('NOT VERIFIED', 'CSS-005', file, null, 'CSS preprocessor entry is not statically expanded', true); + for (const e of record.edges) if (e.runtime && e.resolved && !privatePath(e.resolved) && !normalize(e.resolved).includes('/node_modules/')) visit(e.resolved); + } + const entries = []; + const htmlFiles = options.entry.length ? options.entry.map(e => path.resolve(target, e)) : isFile(targetPath) ? [targetPath] : [path.join(target, 'index.html')]; + for (const entry of htmlFiles) { + if (!isFile(entry)) { add('NOT VERIFIED', 'ENTRY-001', entry, null, 'Production entry unavailable; specify --entry bootstrap-path', true); continue; } + if (isStory(entry)) { add('NOT VERIFIED', 'ENTRY-001', entry, null, 'Storybook is not a production entry', true); continue; } + if (!entry.endsWith('.html')) { entries.push({ file: entry, roots: [entry] }); continue; } + const html = fs.readFileSync(entry, 'utf8').replace(//g, ''); + const roots = []; + for (const m of html.matchAll(/<(script|link)\b([^>]+)>/gi)) { + const attrs = Object.fromEntries([...m[2].matchAll(/([\w-]+)\s*=\s*["']([^"']*)["']/g)].map(a => [a[1].toLowerCase(), a[2]])); + const spec = m[1].toLowerCase() === 'script' ? attrs.src : attrs.rel === 'stylesheet' ? attrs.href : null; + if (!spec) continue; + if (isStory(spec)) add('FAIL', 'SB-001', entry, null, 'Production HTML references a Storybook entry'); + const resolved = resolve(spec.startsWith('/') || spec.startsWith('.') || /^(?:https?:|\/\/)/.test(spec) ? spec : `./${spec}`, entry); + if (resolved) roots.push(resolved); + else add('NOT VERIFIED', 'ENTRY-002', entry, null, `Unresolved HTML entry: ${spec}`, true); + } + if (/<(?:script|style)\b[^>]*>\s*[^<\s]/i.test(html)) add('NOT VERIFIED', 'ENTRY-005', entry, null, 'Inline script/style needs separate runtime verification', true); + if (!roots.length) add('NOT VERIFIED', 'ENTRY-001', entry, null, 'No static production bootstrap found', true); + entries.push({ file: entry, roots }); + } + entries.forEach(e => e.roots.forEach(visit)); + for (const s of selected) { + if (!fs.existsSync(s)) add('NOT VERIFIED', 'SCOPE-001', s, null, 'Selected change path unavailable', true); + else if (![...graph.keys()].some(f => s === f || within(s, f))) add('NOT VERIFIED', 'SCOPE-001', s, null, 'Selected path is outside the proven production graph; supply --entry', true); + } + function exportedOrigin(file, exported, seen = new Set()) { + const key = `${file}:${exported}`; + if (seen.has(key)) return null; + seen.add(key); + const r = graph.get(file); + const binding = r?.exports.get(exported); + if (binding) return binding.edge ? edgeOrigin(binding.edge, binding.imported, seen) : origin(file, binding.local, seen); + for (const e of r?.stars ?? []) { const found = edgeOrigin(e, exported, seen); if (found) return found; } + return null; + } + function edgeOrigin(e, imported, seen) { + const ref = packageRef(e.spec); + if (ref) return ref.contract.entries.get(ref.key)?.has(imported) ? { name: imported, pkg: ref.pkg } : null; + return e.resolved ? exportedOrigin(e.resolved, imported, seen) : null; + } + function origin(file, local, seen = new Set()) { + if (typeof local !== 'string') return null; + const [base, member] = local.split('.'); + const b = graph.get(file)?.bindings.get(base); + if (!b) return { name: local }; + if (b.local) { const key = `${file}:local:${local}`; if (seen.has(key)) return null; seen.add(key); return origin(file, b.local, seen); } + return edgeOrigin(b.edge, member ?? b.imported, seen); + } + function closure(roots) { + const result = new Set(); + const follow = f => { if (result.has(f) || !graph.has(f)) return; result.add(f); for (const e of graph.get(f).edges) if (e.runtime && e.resolved) follow(e.resolved); }; + roots.forEach(follow); return [...result].map(f => graph.get(f)); + } + if (selected.length) { + const changedRoots = [...graph.keys()].filter(selectedFile); + selectedDependencies = new Set(closure(changedRoots).map(r => r.file)); + } + const directlyReused = r => r.edges.some(e => e.runtime && (packageRef(e.spec) || e.resolved && privatePath(e.resolved))) || r.renders.some(x => origin(r.file, x.name)?.pkg); + // Related ancestors supply bootstrap / CSS evidence. A new sibling UI is not + // made an HM element simply because the screen also renders an HM component. + applicableFiles = new Set(); + for (const r of graph.values()) if (closure([r.file]).some(directlyReused)) applicableFiles.add(r.file); + for (const r of graph.values()) if (applicableFiles.has(r.file)) for (const e of r.edges) if (e.resolved && /\.(?:css|scss|sass|less)$/.test(e.resolved)) closure([e.resolved]).forEach(c => applicableFiles.add(c.file)); + // Namespace access is checked against the same public barrel as named imports. + for (const r of graph.values()) for (const member of r.members) { + const binding = r.bindings.get(member.node.object.name); + const ref = binding?.edge && packageRef(binding.edge.spec); + if (!ref || binding.imported !== '*') continue; + if (member.node.computed && literal(member.node.property) === undefined) add('NOT VERIFIED', 'IMP-003', r.file, member.node, 'Computed HM namespace access cannot be verified'); + else if (ref.contract.entries.get(ref.key) && !ref.contract.entries.get(ref.key).has(nameOf(member.node.property))) add('FAIL', 'IMP-002', r.file, member.node, `${nameOf(member.node.property)} is not exported by ${binding.edge.spec}`); + } + const remember = name => { if (/^[A-Z][a-z]|^(?:GNB|LNB)(?:$|[A-Z])/.test(name) && !name.includes('_')) checkedElements.add(name); }; + for (const entry of entries) { + const records = closure(entry.roots); + const focus = records.filter(r => selectedFile(r.file)); + const reused = focus.filter(r => directlyReused(r) || r.edges.some(e => e.runtime && e.resolved && applicableFiles.has(e.resolved))); + if (!reused.length) continue; + groupApplicability[1] = groupApplicability[3] = true; + let selectedNavigation = false; + let selectedDashboardLayout = null; + let officialDashboardHeader = false; + for (const r of reused) { + for (const e of r.edges.filter(e => e.runtime && packageRef(e.spec))) for (const b of e.names) if (!b.typeOnly && packageRef(e.spec).contract.entries.get(packageRef(e.spec).key)?.has(b.imported)) remember(b.imported); + for (const render of r.renders) { const identity = origin(r.file, render.name); if (identity?.pkg) remember(identity.name); } + } + for (const r of records) for (const render of r.renders) { + const identity = origin(r.file, render.name); + const name = identity?.name ?? render.name; + if (/^Story(?:Page|Section|Board)/.test(name) && applicableFiles.has(r.file)) add('FAIL', 'SB-002', r.file, render.node, 'Production renders a Storybook-only helper'); + if (identity?.pkg !== '@hm-design/ui') continue; + if (name === 'DashboardHeader') officialDashboardHeader = true; + if (name === 'DashboardLayout' && selectedFile(r.file)) selectedDashboardLayout = { record: r, render }; + const category = /^GNB/.test(name) ? 'GNB_ONLY' : /^LNB/.test(name) ? 'LNB_ONLY' : /^Dashboard(?:Header|Layout|Main|Content|Sidebar)$/.test(name) ? 'DASHBOARD_ONLY' : null; + if (category && selectedFile(r.file)) selectedNavigation = true; + if (category && render.node.attributes?.some(a => a.type === 'JSXSpreadAttribute')) add('NOT VERIFIED', 'NAV-005', r.file, render.node, 'Reused HM Navigation spread props need runtime confirmation'); + const sidebar = name === 'DashboardSidebar'; + const sidebarProp = render.attrs.get('sidebar'); + const sidebarSlot = name === 'DashboardLayout' && render.attrs.has('sidebar'); + const hasSidebarProp = sidebarSlot && ['JSXElement', 'JSXFragment', 'StringLiteral', 'ArrayExpression', 'ObjectExpression'].includes(sidebarProp?.type); + const emptySidebarProp = sidebarProp?.type === 'NullLiteral' || sidebarProp?.type === 'BooleanLiteral' && sidebarProp.value === false || tagName(sidebarProp) === 'undefined'; + if (sidebarSlot && !hasSidebarProp && !emptySidebarProp) add('NOT VERIFIED', 'NAV-005', r.file, render.node, 'DashboardLayout sidebar slot value is not statically proven'); + if (options.mode && category && category !== options.mode) add('FAIL', 'NAV-002', r.file, render.node, `${options.mode} cannot render ${name}`); + if (['GNB_ONLY', 'DASHBOARD_ONLY'].includes(options.mode) && (sidebar || hasSidebarProp)) add('FAIL', 'NAV-001', r.file, render.node, `${options.mode} cannot render Sidebar / LNB`); + if (options.mode === 'LNB_ONLY' && sidebar) add('FAIL', 'NAV-002', r.file, render.node, 'LNB_ONLY cannot render DashboardSidebar'); + } + if (selectedNavigation) groupApplicability[0] = true; + if (!options.mode && selectedNavigation) add('NOT VERIFIED', 'NAV-003', entry.file, null, 'Reused HM Navigation mode is unspecified; no mode inferred', true); + if (options.mode === 'DASHBOARD_ONLY' && selectedDashboardLayout && !officialDashboardHeader) add('NOT VERIFIED', 'NAV-006', selectedDashboardLayout.record.file, selectedDashboardLayout.render.node, 'Reused DashboardLayout official DashboardHeader connection is not proven'); + // Evidence is collected from the whole bootstrap graph, requirements from the selected region. + const cssFeatures = new Set(records.flatMap(r => [...r.features])); + for (const r of records) for (const e of r.edges.filter(e => e.runtime)) { + if (e.spec === '@hm-design/ui/theme.css') cssFeatures.add('theme'); + if (e.spec === '@hm-design/ui/dashboard-status.css') cssFeatures.add('status'); + if (e.spec === 'tailwindcss' || e.spec === 'tailwindcss/utilities' || e.spec === 'tailwindcss/utilities.css') cssFeatures.add('utilities'); + if (e.resolved && fs.realpathSync(e.resolved) === fs.realpathSync(path.join(workspace, 'packages/ui/src/theme.css'))) cssFeatures.add('theme'); + } + const runtimeUI = reused.some(r => r.edges.some(e => e.runtime && packageRef(e.spec)?.pkg === '@hm-design/ui' && !packageRef(e.spec).key.endsWith('.css'))); + const uiComponents = focus.some(r => r.renders.some(x => origin(r.file, x.name)?.pkg === '@hm-design/ui')); + const utilities = uiComponents; + const statusName = name => /^(?:dashboard(?:Status|Filter)|countDashboardFilter|matchesDashboardFilter)/.test(name); + const status = reused.some(r => r.status && r.renders.some(x => origin(r.file, x.name)?.pkg === '@hm-design/ui' && origin(r.file, x.name)?.name === 'Badge') || r.edges.some(e => e.runtime && e.spec === '@hm-design/ui' && e.names.some(b => !b.typeOnly && statusName(b.imported))) || r.members.some(m => { const id = origin(r.file, m.name); return id?.pkg === '@hm-design/ui' && statusName(id.name); })); + if (runtimeUI || uiComponents || status) groupApplicability[2] = true; + for (const [required, feature, code, label] of [[runtimeUI || uiComponents, 'theme', 'CSS-001', 'Theme CSS'], [utilities, 'utilities', 'CSS-002', 'Tailwind utility CSS entry'], [status, 'status', 'CSS-003', 'Public @hm-design/ui/dashboard-status.css entry']]) if (required && !cssFeatures.has(feature)) add('NOT VERIFIED', code, focus[0]?.file ?? entry.file, null, `${label} connection is not proven; check bootstrap / indirect imports`, true); + } + return finish(); + function finish() { + if (applicableFiles) { + for (let i = issues.length - 1; i >= 0; i--) { + const item = issues[i]; + const file = path.resolve(target, item.file); + if (selected.length && !contextualIssues.has(item) && !selectedFile(file)) { issues.splice(i, 1); continue; } + if (/^(?:SB|CSS|NAV)-/.test(item.code) && !applicableFiles.has(file) && !item.code.startsWith('NAV-003')) issues.splice(i, 1); + } + } + const priority = { FAIL: 0, 'NOT VERIFIED': 1, WARNING: 2 }; + issues.sort((a, b) => priority[a.severity] - priority[b.severity] || a.code.localeCompare(b.code) || a.file.localeCompare(b.file) || a.line - b.line); + const statusFor = list => list.some(i => i.severity === 'FAIL') ? 'FAIL' : list.some(i => i.severity === 'NOT VERIFIED') ? 'NOT VERIFIED' : list.length ? 'WARNING' : 'PASS'; + const groupResults = groups.map((name, i) => { + const related = issues.filter(x => x.code.startsWith(['NAV', 'SB', 'CSS', 'IMP'][i]) || x.code.startsWith('ENTRY') || x.code.startsWith('CORE') || x.code.startsWith('SCOPE')); + return { name, status: related.length ? statusFor(related) : groupApplicability[i] ? 'PASS' : 'NOT APPLICABLE' }; + }); + return { status: issues.length ? statusFor(issues) : groupApplicability.some(Boolean) ? 'PASS' : 'NOT APPLICABLE', scope: options.scope, mode: options.mode ?? null, target, contractSource: workspace, checkedGroups: groupResults, checkedHMElements: [...checkedElements].sort(), issues, durationMs: Math.round((performance.now() - start) * 100) / 100, note: 'Reused HM elements only. New UI/layout design, browser rendering, generated utility output and runtime behavior are not verified.' }; + } +} + +try { + const options = argumentsFrom(process.argv.slice(2)); + if (options.help) console.log('node scripts/ai-generation-guard.mjs --target PATH --scope FULL_SCREEN|PARTIAL [--mode GNB_ONLY|LNB_ONLY|DASHBOARD_ONLY]\n[--entry BOOTSTRAP] [--files CHANGED_FILE,... (PARTIAL)] [--json]\nPARTIAL checks reachable selected files; no Navigation creation required. PASS covers static contracts only.'); + else { + const report = await inspect(options); + if (options.json) console.log(JSON.stringify(report, null, 2)); + else if (!report.issues.length) console.log(`HMDesign FAST CORE: ${report.status}\nHMDesign: ${report.checkedHMElements.join(', ') || 'none'}\nChecked: 4 groups; Issues: 0`); + else { + console.log(`HMDesign FAST CORE: ${report.status}`); + console.log(`HMDesign: ${report.checkedHMElements.join(', ') || 'none'}`); + for (const issue of report.issues.slice(0, 5)) console.log(`${issue.severity} ${issue.code} ${issue.file}:${issue.line}\n${issue.message}`); + if (report.issues.length > 5) console.log(`Issues: ${report.issues.length} (showing 5)`); + } + if (report.status === 'FAIL') process.exitCode = 1; + } +} catch (error) { + // Configuration / tooling failures are not evidence of a contract violation. + if (process.argv.includes('--json')) console.log(JSON.stringify({ status: 'NOT VERIFIED', checkedHMElements: [], issues: [{ severity: 'NOT VERIFIED', code: 'CORE-002', file: '.', line: 1, message: error.message }] }, null, 2)); + else console.log(`HMDesign FAST CORE: NOT VERIFIED\nCORE-002 .:1\n${error.message}`); + process.exitCode = 2; +} diff --git a/scripts/ai-generation-guard.test.mjs b/scripts/ai-generation-guard.test.mjs new file mode 100644 index 0000000..96d4594 --- /dev/null +++ b/scripts/ai-generation-guard.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; +import test from 'node:test'; + +const guard = fileURLToPath(new URL('./ai-generation-guard.mjs', import.meta.url)); +const external = String.raw`D:\디자인시스템\HMDesign-main\hmdesign`; +function fixture(files, callback) { + const tempRoot = fs.realpathSync(os.tmpdir()); + const root = fs.mkdtempSync(path.join(tempRoot, 'hm-fast-core-')); + assert.equal(path.dirname(root), tempRoot); + try { + for (const [name, text] of Object.entries({ + 'index.html': '', + 'src/main.tsx': 'import App from "./App"; import "./styles.css"; export default App;', + 'src/styles.css': '@import "./tokens.css";\n/* app-specific styles are valid */ .custom { color: coral; }', + 'src/tokens.css': '@import "tailwindcss";\n@import "@hm-design/ui/theme.css";', + 'tsconfig.app.json': '{ /* JSONC */ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }', + ...files, + })) { + const dest = path.join(root, name); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, text); + } + callback(root); + } finally { + // Only the verified mkdtemp directory created by this test is removed. + assert.equal(path.dirname(path.resolve(root)), tempRoot); + assert.ok(path.basename(root).startsWith('hm-fast-core-')); + fs.rmSync(root, { recursive: true, force: true }); + } +} +function run(target, extra, json = true) { + const started = performance.now(); + const result = spawnSync(process.execPath, [guard, '--target', target, ...extra, ...(json ? ['--json'] : [])], { encoding: 'utf8' }); + assert.ifError(result.error); + assert.equal(result.stderr, ''); + const report = json ? JSON.parse(result.stdout) : null; + return { ...result, report, wallMs: Math.round(performance.now() - started) }; +} + +test('1. normal GNB_ONLY; indirect CSS, aliases, unrelated new UI and unused Stories', () => { + fixture({ + 'src/hm.ts': 'export { GNB as Header, GNBMobileMenu as Menu, Button } from "@hm-design/ui";', + 'src/App.tsx': 'import { Header, Menu, Button } from "@/hm"; import NewUI from "./NewUI";\nexport default () => <>
;', + 'src/NewUI.tsx': 'import "./new.css"; function Sidebar(){ return }\nexport default Sidebar;', + 'src/new.css': '.sb-page-inner { color: purple; } .sidebar { display: flex; }', + 'src/Unused.stories.tsx': 'import { GNB, DashboardSidebar } from "@hm-design/ui"; export const Example = () => <>;', + }, root => { + const result = run(root, ['--scope', 'FULL_SCREEN', '--mode', 'GNB_ONLY'], false); + assert.equal(result.status, 0); + const lines = result.stdout.trim().split(/\r?\n/); + assert.equal(lines.length, 3); + assert.equal(lines[0], 'HMDesign FAST CORE: PASS'); + assert.equal(lines[1], 'HMDesign: Button, GNB, GNBMobileMenu'); + assert.match(lines[2], /Issues: 0/); + console.log(`Normal GNB wall time: ${result.wallMs} ms`); + }); +}); + +test('2. normal LNB_ONLY; namespace and local alias resolve to the official LNB', () => { + fixture({ + 'src/App.tsx': 'import * as HM from "@hm-design/ui"; const Menu = HM.LNB;\nexport default () => Home;', + }, root => { + const { status, report } = run(root, ['--scope', 'FULL_SCREEN', '--mode', 'LNB_ONLY']); + assert.equal(status, 0); + assert.equal(report.status, 'PASS'); + assert.deepEqual(report.checkedHMElements, ['LNB', 'LNBItem', 'LNBNav']); + assert.ok(report.checkedGroups.every(g => g.status === 'PASS')); + console.log(`Normal LNB static analysis: ${report.durationMs} ms`); + }); +}); + +test('3. GNB_ONLY detects a reused official Sidebar through a re-export alias', () => { + fixture({ + 'src/hm.ts': 'export { GNB, DashboardSidebar as Side } from "@hm-design/ui";', + 'src/App.tsx': 'import { GNB, Side } from "./hm";\nexport default () => <>;', + }, root => { + const { status, report } = run(root, ['--scope', 'FULL_SCREEN', '--mode', 'GNB_ONLY']); + assert.equal(status, 1); + assert.equal(report.status, 'FAIL'); + assert.ok(report.issues.some(i => i.code === 'NAV-001' && i.file === 'src/App.tsx' && i.line === 2)); + assert.ok(report.checkedHMElements.includes('DashboardSidebar')); + }); +}); + +test('4. Production Story import, StoryPage helper and Storybook CSS are detected', () => { + fixture({ + 'src/App.tsx': 'import { Button } from "@hm-design/ui";\nimport { Demo } from "./Button.stories";\nimport { StoryPage } from "./story-page";\nexport default () =>