feat: improve lightweight AI generation guard
This commit is contained in:
1 parent
c86d12bebc
commit
4daca6d357
7 files changed
+327
-162
No files matched your search
@@ -17,13 +17,13 @@ const within = (root, p) => { const r = path.relative(root, p); return 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) {
|
||||
function walk(node, fn, parent = null, field = null) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (node.type) fn(node);
|
||||
if (node.type) fn(node, parent, field);
|
||||
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);
|
||||
if (Array.isArray(value)) value.forEach(x => walk(x, fn, node, key));
|
||||
else if (value && typeof value === 'object') walk(value, fn, node, key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ function argumentsFrom(argv) {
|
||||
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}`);
|
||||
if (!['--target', '--scope', '--mode', '--entry', '--files', '--result'].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;
|
||||
@@ -57,6 +57,7 @@ async function inspect(options) {
|
||||
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 changedFile = p => !selected.length || 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 };
|
||||
@@ -100,6 +101,10 @@ async function inspect(options) {
|
||||
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 };
|
||||
};
|
||||
// Public component names supply lightweight slot vocabulary, without reading implementations.
|
||||
const componentNames = new Set([...contracts.values()].flatMap(c => [...c.entries.values()].flatMap(names => names ? [...names].filter(n => /^[A-Z]/.test(n)) : [])));
|
||||
const slots = new Set([...componentNames].map(n => n.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()));
|
||||
const knownSlot = value => typeof value === 'string' && (slots.has(value) || /^(?:select|gnb|lnb|dashboard)-/.test(value));
|
||||
const aliases = [];
|
||||
// JSONC paths are read as data. Config files are never executed.
|
||||
for (const file of ['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json']) {
|
||||
@@ -171,8 +176,8 @@ async function inspect(options) {
|
||||
} 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() };
|
||||
if (graph.has(file) || privatePath(file)) return;
|
||||
const record = { file, edges: [], bindings: new Map(), exports: new Map(), stars: [], renders: [], members: [], classes: [], references: new Set(), mounts: false, 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; }
|
||||
@@ -199,6 +204,8 @@ async function inspect(options) {
|
||||
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');
|
||||
if ([...n.selector.matchAll(/data-slot\s*=\s*["']?([\w-]+)/g)].some(m => knownSlot(m[1]))) add('NOT VERIFIED', 'REUSE-003', file, n, 'HM slot styling: confirm approved customization, not a CSS replica');
|
||||
if ([...n.selector.matchAll(/\.hm-([\w-]+)/g)].some(m => knownSlot(m[1]))) add('NOT VERIFIED', 'REUSE-003', file, n, 'HM-named custom CSS: confirm it is not a component replica');
|
||||
});
|
||||
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); }
|
||||
@@ -221,7 +228,10 @@ async function inspect(options) {
|
||||
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 => {
|
||||
walk(ast, (n, parent, field) => {
|
||||
if (n.type === 'Identifier' && !parent?.type.startsWith('Import') && !parent?.type.startsWith('TS') && !((parent?.type === 'MemberExpression' || parent?.type === 'ObjectProperty' || parent?.type === 'ObjectMethod') && field === (parent.type === 'MemberExpression' ? 'property' : 'key') && !parent.computed) && !(parent?.type === 'VariableDeclarator' && field === 'id')) record.references.add(n.name);
|
||||
if (n.type === 'JSXIdentifier' && /^[A-Z]/.test(n.name)) record.references.add(n.name);
|
||||
if (n.type === 'CallExpression' && /^(?:(?:ReactDOM|ReactDOMClient)\.)?(?:createRoot|hydrateRoot)$|^ReactDOM\.(?:render|hydrate)$/.test(tagName(n.callee))) record.mounts = true;
|
||||
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'); }
|
||||
@@ -245,12 +255,14 @@ async function inspect(options) {
|
||||
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')];
|
||||
const htmlFiles = options.result ? [path.resolve(target, options.result)] : 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(/<!--[\s\S]*?-->/g, '');
|
||||
const markup = html.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, s => s.replace(/[^\n]/g, ' '));
|
||||
for (const m of markup.matchAll(/<([a-z][\w-]*)\b[^>]*\bdata-slot\s*=\s*["']([^"']+)["'][^>]*>/gi)) if (knownSlot(m[2])) add('FAIL', 'REUSE-001', entry, { loc: { start: { line: markup.slice(0, m.index).split('\n').length } } }, `Native <${m[1]}> claims HM slot ${m[2]}; use the public React component`, true);
|
||||
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]]));
|
||||
@@ -266,6 +278,13 @@ async function inspect(options) {
|
||||
entries.push({ file: entry, roots });
|
||||
}
|
||||
entries.forEach(e => e.roots.forEach(visit));
|
||||
if (options.result) {
|
||||
const entry = entries[0];
|
||||
const records = entry ? closure(entry.roots) : [];
|
||||
const react = records.some(r => r.mounts && r.edges.some(e => /^react-dom(?:\/client)?$/.test(e.spec)));
|
||||
if (!react) add('NOT VERIFIED', 'RENDER-001', entry?.file ?? path.resolve(target, options.result), null, 'Final artifact has no proven React mount; HTML imitation is not React rendering evidence', true);
|
||||
if (react) add('NOT VERIFIED', 'RENDER-002', entry.file, null, 'React bootstrap found; confirm this artifact in the browser once (layout / utilities / interaction)', true);
|
||||
}
|
||||
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);
|
||||
@@ -303,6 +322,20 @@ async function inspect(options) {
|
||||
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);
|
||||
for (const r of graph.values()) {
|
||||
if (!changedFile(r.file) || privatePath(r.file) || isStory(r.file)) continue;
|
||||
for (const render of r.renders) {
|
||||
const slot = literal(render.attrs.get('data-slot'));
|
||||
if (knownSlot(slot) && origin(r.file, render.name)?.pkg !== '@hm-design/ui' && origin(r.file, render.name)?.pkg !== '@hm-design/center-graphic') add(/^[a-z]/.test(render.name) ? 'FAIL' : 'NOT VERIFIED', 'REUSE-001', r.file, render.node, `${render.name} claims HM slot ${slot}; confirm public component provenance`);
|
||||
if (render.attrs.has('data-slot') && slot === undefined) add('NOT VERIFIED', 'REUSE-005', r.file, render.node, 'Computed data-slot requires component provenance confirmation');
|
||||
if (/^(?:select|input|textarea|button)$/.test(render.name)) add('NOT VERIFIED', 'REUSE-004', r.file, render.node, `Native <${render.name}>: confirm it does not replace an indexed HM component`);
|
||||
}
|
||||
for (const e of r.edges) for (const b of e.names) {
|
||||
const identity = origin(r.file, b.local);
|
||||
if (e.node.type !== 'ImportDeclaration' || !e.runtime || b.typeOnly || !(identity?.pkg || packageRef(e.spec)) || !(componentNames.has(identity?.name ?? b.imported) || b.imported === '*')) continue;
|
||||
if (!r.references.has(b.local)) add('FAIL', 'REUSE-002', r.file, e.node, `${identity?.name ?? b.imported} imported but unused; import alone is not component reuse`);
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
@@ -327,7 +360,7 @@ async function inspect(options) {
|
||||
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 e of r.edges.filter(e => e.runtime && packageRef(e.spec))) for (const b of e.names) if (!b.typeOnly && r.references.has(b.local) && 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) {
|
||||
@@ -375,6 +408,7 @@ async function inspect(options) {
|
||||
for (let i = issues.length - 1; i >= 0; i--) {
|
||||
const item = issues[i];
|
||||
const file = path.resolve(target, item.file);
|
||||
if (item.code.startsWith('REUSE-') && selected.length && !changedFile(file) && !(options.result && file === path.resolve(target, options.result))) { issues.splice(i, 1); continue; }
|
||||
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);
|
||||
}
|
||||
@@ -386,21 +420,20 @@ async function inspect(options) {
|
||||
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.' };
|
||||
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: 'Static contracts only. Native controls / CSS intent, actual rendered component usage, browser layout and behavior require verification. --result checks a final artifact bootstrap, not browser evidence.' };
|
||||
}
|
||||
}
|
||||
|
||||
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.');
|
||||
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)] [--result FINAL_HTML_OR_BOOTSTRAP] [--json]\n--result selects the submitted React artifact. PARTIAL checks reachable selected files; 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 if (!report.issues.length) console.log(report.status);
|
||||
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}`);
|
||||
console.log(report.status);
|
||||
for (const issue of report.issues.slice(0, 5)) console.log(`${issue.severity} ${issue.code} ${issue.file}:${issue.line} ${issue.message}`);
|
||||
if (report.issues.length > 5) console.log(`Issues: ${report.issues.length} (showing 5)`);
|
||||
}
|
||||
if (report.status === 'FAIL') process.exitCode = 1;
|
||||
|
||||
@@ -9,7 +9,6 @@ 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-'));
|
||||
@@ -55,10 +54,7 @@ test('1. normal GNB_ONLY; indirect CSS, aliases, unrelated new UI and unused Sto
|
||||
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/);
|
||||
assert.deepEqual(lines, ['PASS']);
|
||||
console.log(`Normal GNB wall time: ${result.wallMs} ms`);
|
||||
});
|
||||
});
|
||||
@@ -113,9 +109,9 @@ test('5. private paths for both packages and a non-public named export are detec
|
||||
});
|
||||
});
|
||||
|
||||
test('6. external app PARTIAL is read-only and never infers a Navigation mode', () => {
|
||||
assert.ok(fs.existsSync(external), 'External test project must exist');
|
||||
const names = ['src/App.tsx', 'src/main.tsx', 'src/index.css', 'src/hm-theme.css', 'package.json', 'AGENTS.md'];
|
||||
test('6. PARTIAL is read-only and never infers a Navigation mode', () => {
|
||||
fixture({ 'src/App.tsx': 'import { Button, GNB, GNBMobileMenu } from "@hm-design/ui"; export default () => <GNB><GNBMobileMenu/><Button>OK</Button></GNB>;' }, external => {
|
||||
const names = ['src/App.tsx', 'src/main.tsx', 'src/styles.css', 'src/tokens.css', 'tsconfig.app.json', 'index.html'];
|
||||
const hashes = () => names.map(name => createHash('sha256').update(fs.readFileSync(path.join(external, name))).digest('hex'));
|
||||
const before = hashes();
|
||||
const { status, report } = run(external, ['--scope', 'PARTIAL', '--files', 'src/App.tsx']);
|
||||
@@ -127,4 +123,94 @@ test('6. external app PARTIAL is read-only and never infers a Navigation mode',
|
||||
assert.ok(report.checkedHMElements.includes('GNB'));
|
||||
assert.deepEqual(hashes(), before);
|
||||
console.log(`External PARTIAL: ${report.status}; ${report.checkedHMElements.length} HM elements; ${report.durationMs} ms`);
|
||||
});
|
||||
});
|
||||
|
||||
test('7. native Select with fake data-slot and import-only reuse fails', () => {
|
||||
fixture({ 'src/App.tsx': 'import { Select as Choice } from "@hm-design/ui";\nexport default () => <select data-slot="select"><option>A</option></select>;' }, root => {
|
||||
const { status, report } = run(root, ['--scope', 'PARTIAL', '--files', 'src/App.tsx']);
|
||||
assert.equal(status, 1);
|
||||
for (const code of ['REUSE-001', 'REUSE-002']) assert.ok(report.issues.some(i => i.code === code && i.file === 'src/App.tsx'));
|
||||
assert.ok(!report.checkedHMElements.includes('Select'));
|
||||
});
|
||||
});
|
||||
|
||||
test('8. fake slots without an HM import, createElement and namespace unused', () => {
|
||||
for (const source of [
|
||||
'export default () => <div data-slot="select-trigger"/>;',
|
||||
'import React from "react"; export default () => React.createElement("select", { "data-slot": "select" });',
|
||||
'import * as HM from "@hm-design/ui"; export default () => <div/>;',
|
||||
]) fixture({ 'src/App.tsx': source }, root => {
|
||||
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
|
||||
assert.equal(report.status, 'FAIL');
|
||||
assert.ok(report.issues.some(i => /^REUSE-00[12]$/.test(i.code)));
|
||||
});
|
||||
});
|
||||
|
||||
test('9. official aliases, namespace, forwarding, types and barrels are not unused', () => {
|
||||
fixture({
|
||||
'src/hm.ts': 'export { Select as Choice } from "@hm-design/ui";',
|
||||
'src/App.tsx': 'import { Choice } from "./hm"; import { Button } from "@hm-design/ui"; import type { ButtonAppearance } from "@hm-design/ui"; import * as HM from "@hm-design/ui"; const Forward = ({ component: Component }) => <Component/>; export default () => <><Choice/><HM.Input/><Forward component={Button}/></>;',
|
||||
}, root => {
|
||||
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
|
||||
assert.equal(report.status, 'PASS');
|
||||
});
|
||||
});
|
||||
|
||||
test('10. native controls and CSS customization need review, not FAIL', () => {
|
||||
fixture({ 'src/App.tsx': 'export default () => <select className="hm-select"/>;', 'src/styles.css': '.hm-select, [data-slot="select-trigger"] { color: red; }' }, root => {
|
||||
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
|
||||
assert.equal(report.status, 'NOT VERIFIED');
|
||||
for (const code of ['REUSE-003', 'REUSE-004']) assert.ok(report.issues.some(i => i.code === code));
|
||||
assert.ok(report.issues.every(i => i.severity !== 'FAIL'));
|
||||
});
|
||||
});
|
||||
|
||||
test('11. PARTIAL excludes unchanged sibling errors', () => {
|
||||
fixture({
|
||||
'src/App.tsx': 'import { Button } from "@hm-design/ui"; import Old from "./Old"; export default () => <><Button/><Old/></>;',
|
||||
'src/Old.tsx': 'export default () => <select data-slot="select"/>;',
|
||||
}, root => {
|
||||
assert.equal(run(root, ['--scope', 'FULL_SCREEN']).report.status, 'FAIL');
|
||||
assert.equal(run(root, ['--scope', 'PARTIAL', '--files', 'src/App.tsx']).report.status, 'PASS');
|
||||
// Selecting a leaf checks that leaf; its unchanged siblings stay outside scope.
|
||||
assert.equal(run(root, ['--scope', 'PARTIAL', '--files', 'src/Old.tsx']).report.issues.some(i => i.file === 'src/App.tsx' && i.code.startsWith('REUSE')), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('12. final HTML imitation is flagged; React preview remains browser NOT VERIFIED', () => {
|
||||
fixture({
|
||||
'preview.html': '<select data-slot="select"><option>A</option></select>',
|
||||
'react-preview.html': '<div id="root"></div><script type="module" src="/src/main.tsx"></script>',
|
||||
'src/main.tsx': 'import { createRoot } from "react-dom/client"; import App from "./App"; import "./styles.css"; createRoot(document.getElementById("root")).render(<App/>);',
|
||||
'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button/>;',
|
||||
}, root => {
|
||||
const fake = run(root, ['--scope', 'FULL_SCREEN', '--result', 'preview.html']).report;
|
||||
assert.equal(fake.status, 'FAIL');
|
||||
assert.ok(fake.issues.some(i => i.code === 'RENDER-001'));
|
||||
const real = run(root, ['--scope', 'FULL_SCREEN', '--result', 'react-preview.html']).report;
|
||||
assert.equal(real.status, 'NOT VERIFIED');
|
||||
assert.deepEqual(real.issues.map(i => i.code), ['RENDER-002']);
|
||||
});
|
||||
});
|
||||
|
||||
test('13. design system implementation is exempt', () => {
|
||||
const internal = fileURLToPath(new URL('../packages/ui/src/components/ui/select.tsx', import.meta.url));
|
||||
const { report } = run(internal, ['--scope', 'PARTIAL']);
|
||||
assert.ok(!report.issues.some(i => i.severity === 'FAIL'));
|
||||
});
|
||||
|
||||
test('14. wrappers and inline HTML strings are not falsely marked FAIL; unused barrel consumer fails', () => {
|
||||
fixture({
|
||||
'src/hm.ts': 'export { Select as Choice } from "@hm-design/ui";',
|
||||
'src/App.tsx': 'import { Choice } from "./hm"; const Wrap = props => <Choice {...props}/>; export default () => <Wrap data-slot="select"/>;',
|
||||
'preview.html': '<script>const example = \'<select data-slot="select"></select>\';</script>',
|
||||
}, root => {
|
||||
const wrapped = run(root, ['--scope', 'FULL_SCREEN']).report;
|
||||
assert.equal(wrapped.status, 'NOT VERIFIED');
|
||||
assert.ok(wrapped.issues.every(i => i.severity !== 'FAIL'));
|
||||
assert.ok(!run(root, ['--scope', 'FULL_SCREEN', '--result', 'preview.html']).report.issues.some(i => i.code === 'REUSE-001'));
|
||||
fs.writeFileSync(path.join(root, 'src/App.tsx'), 'import { Choice } from "./hm"; export default () => <div/>;');
|
||||
assert.ok(run(root, ['--scope', 'PARTIAL', '--files', 'src/App.tsx']).report.issues.some(i => i.code === 'REUSE-002'));
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ const root = process.cwd();
|
||||
const out = path.join(root, 'HMDesignSystem-AI-Reference-Test');
|
||||
const zip = path.join(root, 'HMDesignSystem-AI-Reference-Test.zip');
|
||||
|
||||
// Recursive cleanup is restricted to this script's fixed output directory.
|
||||
if (path.dirname(path.resolve(out)) !== root || path.basename(out) !== 'HMDesignSystem-AI-Reference-Test') throw new Error('Unsafe package output path');
|
||||
fs.rmSync(out, { recursive: true, force: true });
|
||||
fs.rmSync(zip, { force: true });
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
@@ -50,6 +52,17 @@ copy('README.md', 'docs/README.md');
|
||||
copy('package.json', 'docs/root-package.json');
|
||||
copy('package-lock.json', 'docs/package-lock.json');
|
||||
|
||||
// Executable Guard and its contract sources share the original workspace paths.
|
||||
// Keep legacy docs locations for existing reference consumers.
|
||||
copy('AGENTS.md', 'source/AGENTS.md');
|
||||
copy('docs', 'source/docs');
|
||||
copy('package.json', 'source/package.json');
|
||||
copy('package-lock.json', 'source/package-lock.json');
|
||||
copy('scripts/ai-generation-guard.mjs', 'source/scripts/ai-generation-guard.mjs');
|
||||
copy('scripts/ai-generation-guard.test.mjs', 'source/scripts/ai-generation-guard.test.mjs');
|
||||
copy('src', 'source/src');
|
||||
copy('themes', 'source/themes');
|
||||
|
||||
copy('src/exports', 'examples/react-and-html/src/exports');
|
||||
copy('src/exports/dashboard', 'examples/react-and-html/src/exports/dashboard');
|
||||
copy('index.html', 'examples/react-and-html/index.html');
|
||||
@@ -75,13 +88,15 @@ const startHere = `# HM Design System AI Reference
|
||||
|
||||
## AI 참조 순서
|
||||
|
||||
1. \`docs/AGENTS.md\`와 \`docs/README.md\`를 읽습니다.
|
||||
2. \`docs/source/components\`, \`docs/source/patterns\`, \`docs/source/guidelines\`에서 사용 규칙을 확인합니다.
|
||||
3. \`source/packages/ui/src\`와 \`source/packages/center-graphic/src\`의 실제 TSX 구현을 재사용합니다.
|
||||
1. \`source/AGENTS.md\`의 짧은 필수 규칙부터 읽습니다.
|
||||
2. \`source/docs/guidelines/component-pattern-reference.md\`에서 필요한 항목만 찾습니다.
|
||||
3. 선택한 Component/Pattern 문서·공개 API와 대표 Story만 확인합니다. HTML/CSS 모방이나 import만으로 재사용을 주장하지 않습니다.
|
||||
4. \`storybook/source\`의 Story에서 상태·조합·레이아웃을 확인합니다.
|
||||
5. \`theme/source\` 및 \`theme/generated\`의 Token/CSS Custom Properties를 사용합니다.
|
||||
6. \`packages/ui-dist\`와 declarations는 소비자 Export 형태를 확인할 때 사용합니다.
|
||||
|
||||
Guard는 \`source/\`에서 \`node scripts/ai-generation-guard.mjs --target <앱> --scope FULL_SCREEN|PARTIAL\`을 1회 실행합니다. PARTIAL은 \`--files\`, 최종 React artifact는 \`--result\`를 전달합니다. parser 의존성이 없으면 NOT VERIFIED로 보고합니다. 개발자가 실행할 때만 기존 lockfile로 의존성을 복원하며, 포터블 Storybook 테스트 사용자에게 npm 설치를 요구하지 않습니다. PASS는 정적 계약만 의미하며 실제 React 브라우저 결과를 별도로 최소 1회 확인합니다.
|
||||
|
||||
임의의 색상·spacing·radius·Typography 값을 추가하거나 디자인을 재해석하지 않습니다. 신규 화면은 기존 공개 Component/Pattern과 Theme 계약을 우선 재사용합니다.
|
||||
|
||||
## 구조
|
||||
|
||||
Reference in new issue
Block a user