feat: integrate latest HMDesign design system updates

This commit is contained in:
DESKTOP-KPCP6AL\ㅅㅎ committed 2026-10-11 11:09:03 +09:00
1 parent 99faec189e
commit 1c4e0bb9a1
209 files changed
+8845 -552

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { build } from 'vite';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const cache = new Map();
async function load(source) {
if (!cache.has(source)) {
const output = path.resolve('_local/accessibility-component-test', path.basename(source, '.tsx'));
await build({ configFile: false, logLevel: 'silent', build: { outDir: output, emptyOutDir: false, lib: { entry: source, formats: ['es'], fileName: 'module' }, rolldownOptions: { external: id => !id.startsWith('.') && !path.isAbsolute(id) } } });
cache.set(source, import(pathToFileURL(path.join(output, 'module.js')).href));
}
return cache.get(source);
}
const render = (component, props) => renderToStaticMarkup(React.createElement(component, props));
test('Combobox announces its dialog popup without changing names, IDs or disabled state', async () => {
const { Combobox } = await load('packages/ui/src/components/ui/combobox.tsx');
const html = render(Combobox, { options: [{ value: 'work', label: '업무' }], id: 'owner', 'aria-labelledby': 'owner-label', disabled: true });
assert.match(html, /role="combobox"/);
assert.match(html, /aria-haspopup="dialog"/);
assert.match(html, /aria-expanded="false"/);
assert.match(html, /id="owner"/);
assert.match(html, /aria-labelledby="owner-label"/);
assert.match(html, /disabled=""/);
});
for (const [name, file] of [['GNBUser', 'gnb/gnb'], ['LNBProfile', 'lnb/lnb']]) {
test(`${name} preserves visible text in its name and respects consumer labels`, async () => {
const component = (await load(`packages/ui/src/patterns/${file}.tsx`))[name];
const withDescription = render(component, { name: '김한맥', description: '프로젝트 관리자' });
assert.match(withDescription, /aria-label="김한맥 프로젝트 관리자 사용자 메뉴"/);
assert.match(withDescription, /김한맥 <\/(?:span|strong)>/);
assert.match(render(component, { name: '김한맥' }), /aria-label="김한맥 사용자 메뉴"/);
assert.match(render(component, { name: '김한맥', description: '프로젝트 관리자', 'aria-label': '김한맥 프로젝트 관리자 계정 설정' }), /aria-label="김한맥 프로젝트 관리자 계정 설정"/);
assert.match(render(component, { name: '김한맥', 'aria-labelledby': 'visible-account-label' }), /aria-labelledby="visible-account-label"/);
});
}
+101 -33
View File
@@ -7,9 +7,13 @@ import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { performance } from 'node:perf_hooks';
import { navigationAnalysis } from './guard-navigation-analysis.mjs';
import { themeAnalysis } from './guard-theme-analysis.mjs';
import { sourceFingerprint } from './guard-evidence.mjs';
import { styleAnalysis, verdict } from './guard-style-analysis.mjs';
const workspace = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const groups = ['Navigation', 'Storybook / Production', 'Production CSS Entry', 'Official Component Import'];
const groups = ['Navigation', 'Storybook / Production', 'Production CSS Entry', 'Official Component Import', 'Theme Token', 'Component Reuse'];
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; } };
@@ -28,17 +32,19 @@ function walk(node, fn, parent = null, field = null) {
}
function argumentsFrom(argv) {
const options = { scope: 'FULL_SCREEN', entry: [], files: [], json: false };
const options = { scope: 'FULL_SCREEN', profile: 'PRODUCTION', 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', '--result'].includes(key) || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`Invalid option: ${key}`);
if (!['--target', '--scope', '--profile', '--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;
}
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');
if (!['PRODUCTION', 'STORYBOOK'].includes(options.profile)) throw new Error('--profile must be PRODUCTION|STORYBOOK');
if (options.profile === 'STORYBOOK' && options.result) throw new Error('--result is a Production artifact option');
return options;
}
@@ -47,11 +53,21 @@ async function inspect(options) {
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 storybook = options.profile === 'STORYBOOK';
const issues = [];
const graph = new Map();
const entries = [];
const contextualIssues = new Set();
let selectedDependencies = null;
let applicableFiles = null;
let groupApplicability = [false, false, false, false];
let groupApplicability = [false, false, false, false, false, false];
const tokenEvidence = [];
let styleChecks = [];
const contractFiles = ['packages/ui/src/index.ts', 'packages/ui/package.json', 'packages/center-graphic/src/index.ts', 'packages/center-graphic/package.json', 'scripts/ai-generation-guard.mjs', 'scripts/guard-navigation-analysis.mjs', 'scripts/guard-theme-analysis.mjs', 'scripts/guard-style-analysis.mjs', 'scripts/guard-style-evidence.mjs', 'scripts/guard-evidence.mjs'];
// Hash package implementation identity; these files are never consumer graph roots.
const identityFiles = dir => fs.readdirSync(path.join(workspace, dir), { withFileTypes: true }).flatMap(e => e.isDirectory() ? identityFiles(`${dir}/${e.name}`) : /\.(?:css|tsx?|json)$/.test(e.name) ? [`${dir}/${e.name}`] : []);
contractFiles.push(...identityFiles('packages/ui/src'), ...identityFiles('packages/center-graphic/src'));
const contractFingerprint = sourceFingerprint(workspace, contractFiles);
const checkedElements = new Set();
const seenIssues = new Set();
const selected = options.files.map(p => path.resolve(target, p));
@@ -73,7 +89,8 @@ async function inspect(options) {
}
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.
// Parse public barrels and manifest exports for import contracts.
// Implementation text is only a conservative blocker for CSS scope exclusions.
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'));
@@ -104,7 +121,7 @@ async function inspect(options) {
// 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 knownSlot = value => typeof value === 'string' && (slots.has(value) || /^(?:select|tabs|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']) {
@@ -161,12 +178,11 @@ async function inspect(options) {
}
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 (!storybook && (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 (!storybook && 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 {
@@ -203,7 +219,7 @@ 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 (!storybook && /\.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');
});
@@ -212,6 +228,7 @@ async function inspect(options) {
} 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; }
record.ast = ast;
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' }));
@@ -250,15 +267,34 @@ async function inspect(options) {
}
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');
for (const c of record.classes) if (!storybook && 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.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')];
let storyEntries = [];
let storyPreview;
if (storybook) {
const { default: glob } = await import('fast-glob');
const config = ['main.ts', 'main.js', 'main.mjs'].map(f => path.join(target, '.storybook', f)).find(isFile);
if (options.entry.length) storyEntries = options.entry.map(e => path.resolve(target, e));
else if (config) {
try {
walk(parseJS(fs.readFileSync(config, 'utf8'), config), n => {
if (n.type !== 'ObjectProperty' || nameOf(n.key) !== 'stories') return;
if (n.value.type !== 'ArrayExpression' || n.value.elements.some(x => literal(x) === undefined)) { add('NOT VERIFIED', 'SB-004', config, n, 'Computed Storybook discovery requires explicit --entry story paths', true); return; }
for (const pattern of n.value.elements.map(literal)) storyEntries.push(...glob.sync(pattern, { cwd: path.dirname(config), onlyFiles: true, followSymbolicLinks: false, ignore: ['**/node_modules/**', '**/_local/**', '**/dist/**'] }).map(f => path.resolve(path.dirname(config), f)));
});
} catch { add('NOT VERIFIED', 'SB-004', config, null, 'Storybook discovery unavailable; supply explicit --entry story paths', true); }
}
const preview = ['preview.tsx', 'preview.ts', 'preview.jsx', 'preview.js'].map(f => path.join(target, '.storybook', f)).find(isFile);
if (preview) { storyEntries.push(preview); storyPreview = preview; }
else add('NOT VERIFIED', 'SB-004', null, null, 'Storybook preview unavailable; CSS/decorator connection is not proven', true);
if (!storyEntries.some(isStory)) add('NOT VERIFIED', 'SB-004', config, null, 'No Storybook entries discovered', true);
}
const htmlFiles = storybook ? [...new Set(storyEntries)] : 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 (!storybook && 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, ' '));
@@ -278,6 +314,12 @@ async function inspect(options) {
entries.push({ file: entry, roots });
}
entries.forEach(e => e.roots.forEach(visit));
// Each Story has the shared preview, but does not borrow CSS evidence from
// another lazily loaded Story. Discovery never extends the Production roots.
if (storybook && entries.length) {
const storyRoots = entries.filter(e => e.file !== storyPreview);
if (storyRoots.length) entries.splice(0, entries.length, ...storyRoots.map(e => ({ ...e, roots: [...e.roots, ...(storyPreview ? [storyPreview] : [])] })));
}
if (options.result) {
const entry = entries[0];
const records = entry ? closure(entry.roots) : [];
@@ -287,7 +329,7 @@ async function inspect(options) {
}
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);
else if (![...graph.keys()].some(f => s === f || within(s, f))) add('NOT VERIFIED', 'SCOPE-001', s, null, `Selected path is outside the proven ${storybook ? 'Storybook' : 'production'} graph; package implementations need separate build/tests; supply --entry for consumer files`, true);
}
function exportedOrigin(file, exported, seen = new Set()) {
const key = `${file}:${exported}`;
@@ -323,12 +365,19 @@ async function inspect(options) {
}
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;
if (!changedFile(r.file) || privatePath(r.file) || !storybook && 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 (knownSlot(slot) && origin(r.file, render.name)?.pkg !== '@hm-design/ui' && origin(r.file, render.name)?.pkg !== '@hm-design/center-graphic') {
// A Reference can document internal layout wrappers that have no public
// component counterpart. Review these; never count them as reuse or
// grant the same allowance to a Production consumer.
const referenceWrapper = storybook && !slots.has(slot);
add(/^[a-z]/.test(render.name) && !referenceWrapper ? 'FAIL' : 'NOT VERIFIED', 'REUSE-001', r.file, render.node, referenceWrapper ? `${render.name} uses internal Reference slot ${slot} without a public component counterpart; verify documented composition` : `${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`);
if (/^(?:select|input|textarea|button|label)$/.test(render.name)) add('NOT VERIFIED', 'REUSE-004', r.file, render.node, `Native <${render.name}>: confirm it does not replace an indexed HM component`);
if (/^[a-z]/.test(render.name) && ['button', 'checkbox', 'switch', 'combobox', 'dialog', 'alertdialog'].includes(literal(render.attrs.get('role')))) add('NOT VERIFIED', 'REUSE-006', r.file, render.node, `Native ${literal(render.attrs.get('role'))} widget: confirm why the public HM component cannot be composed`);
}
for (const e of r.edges) for (const b of e.names) {
const identity = origin(r.file, b.local);
@@ -352,10 +401,15 @@ async function inspect(options) {
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 graphIncomplete = issues.some(issue => /^(?:ENTRY-00[1245]|CSS-005|CORE-|SB-004)/.test(issue.code) && (records.some(r => normalize(path.relative(target, r.file)) === issue.file) || issue.file === normalize(path.relative(target, entry.file))));
const tokens = themeAnalysis({ records, workspace, postcss, walk, selectedFile, add, graphIncomplete });
tokenEvidence.push({ entry: normalize(path.relative(target, entry.file)), definitionCount: tokens.definitions.length, definitions: tokens.dynamic.length ? tokens.definitions : undefined, references: tokens.references.map(r => ({ ...r, file: normalize(path.relative(target, r.file)) })), dynamic: tokens.dynamic.map(r => ({ ...r, file: normalize(path.relative(target, r.file)), id: r.id.replace(r.file, normalize(path.relative(target, r.file))) })), incomplete: tokens.incomplete });
if (tokens.references.length) groupApplicability[4] = true;
const navigation = !storybook ? navigationAnalysis(records, entry.roots, origin, walk) : null;
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;
groupApplicability[1] = groupApplicability[3] = groupApplicability[4] = groupApplicability[5] = true;
let selectedNavigation = false;
let selectedDashboardLayout = null;
let officialDashboardHeader = false;
@@ -363,11 +417,17 @@ async function inspect(options) {
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) {
const identity = origin(r.file, render.name);
for (const r of records) for (const render of r.renders.flatMap(render => (navigation?.identities.get(render.node) ?? [null]).map(identity => ({ ...render, identity })))) {
const identity = render.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 (!storybook && /^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;
// Reference Storybook combinations preserve their API. An explicit mode
// opts a Storybook audit into the same single-mode navigation checks.
if (storybook && !options.mode) continue;
const reach = navigation?.state.get(render.node) ?? 'active';
if (!storybook && reach === 'dead') continue;
const navSeverity = !storybook && reach === 'maybe' ? 'NOT VERIFIED' : 'FAIL';
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;
@@ -376,12 +436,13 @@ async function inspect(options) {
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';
const slotValue = navigation?.sidebarValues.get(render.node);
const hasSidebarProp = sidebarSlot && (slotValue === 'present' || !slotValue && ['JSXElement', 'JSXFragment', 'StringLiteral', 'ArrayExpression', 'ObjectExpression'].includes(sidebarProp?.type));
const emptySidebarProp = slotValue === 'empty' || !slotValue && (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 (options.mode && category && category !== options.mode) add(navSeverity, 'NAV-002', r.file, render.node, `${options.mode} cannot render ${name}${navSeverity === 'NOT VERIFIED' ? '; render path unresolved' : ''}`);
if (['GNB_ONLY', 'DASHBOARD_ONLY'].includes(options.mode) && (sidebar || hasSidebarProp)) add(navSeverity, 'NAV-001', r.file, render.node, `${options.mode} cannot render Sidebar / LNB${navSeverity === 'NOT VERIFIED' ? '; render path unresolved' : ''}`);
if (options.mode === 'LNB_ONLY' && sidebar) add(navSeverity, '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);
@@ -400,33 +461,40 @@ async function inspect(options) {
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);
const incompleteCSSGraph = issues.some(issue => /^(?:ENTRY-|CORE-|CSS-005|SB-004)/.test(issue.code)
&& (records.some(record => normalize(path.relative(target, record.file)) === issue.file) || issue.file === normalize(path.relative(target, entry.file)) || !issue.file));
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(incompleteCSSGraph ? 'NOT VERIFIED' : 'FAIL', code, focus[0]?.file ?? entry.file, null, incompleteCSSGraph ? `${label} connection is not proven; resolve the incomplete bootstrap / CSS graph` : `Required ${label} is missing from the resolved bootstrap / CSS graph`, true);
}
styleChecks = styleAnalysis({ records: [...graph.values()].filter(r => applicableFiles.has(r.file)), target, postcss, walk, selectedFile, knownSlot, origin, add, storybook, scopeRecords: [...graph.values()], implementationFiles: contractFiles.filter(f => f.startsWith('packages/')).map(f => path.join(workspace, f)), entries: entries.map(e => e.file), scopeIncomplete: storybook || issues.some(i => /^(?:ENTRY-|CORE-|CSS-005|SB-004)/.test(i.code)) });
return finish();
function finish() {
const styleEvidence = { staticStatus: verdict(styleChecks.map(c => c.staticStatus)), runtimeStatus: 'NOT VERIFIED', designIntentStatus: verdict(styleChecks.map(c => c.designIntentStatus)), finalStatus: styleChecks.some(c => c.staticStatus === 'FAIL') ? 'FAIL' : 'NOT VERIFIED', checks: styleChecks };
if (applicableFiles) {
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);
if (!contextualIssues.has(item) && /^(?: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' };
const related = issues.filter(x => x.code.startsWith(['NAV', 'SB', 'CSS', 'IMP', 'TOKEN', 'REUSE'][i]) || i === 5 && x.code.startsWith('STYLE-') || x.code.startsWith('ENTRY') || x.code.startsWith('CORE') || x.code.startsWith('SCOPE'));
return { name: storybook && i === 2 ? 'Storybook CSS Entry' : name, status: related.length ? statusFor(related) : groupApplicability[i] ? 'PASS' : 'NOT APPLICABLE', evidence: related.map(x => ({ code: x.code, file: x.file, line: x.line })), basis: related.length ? 'See issue evidence' : groupApplicability[i] ? 'Resolved entry import graph and current public contracts' : 'No applicable usage in selected scope' };
});
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.' };
const staticIssues = issues.filter(x => !x.code.startsWith('RENDER-'));
const fingerprintFiles = [...graph.keys(), ...entries.map(e => e.file)].filter(isFile).map(f => normalize(path.relative(target, f)));
const identity = { fingerprintFiles, sourceFingerprint: sourceFingerprint(target, fingerprintFiles), contractFiles, contractFingerprint };
return { ...identity, schemaVersion: 2, status: issues.length ? statusFor(issues) : groupApplicability.some(Boolean) ? 'PASS' : 'NOT APPLICABLE', staticStatus: staticIssues.length ? statusFor(staticIssues) : groupApplicability.some(Boolean) ? 'PASS' : 'NOT APPLICABLE', runtimeStatus: 'NOT VERIFIED', runtimeRequired: Boolean(options.result), profile: options.profile, scope: options.scope, mode: options.mode ?? null, target, contractSource: workspace, checkedFiles: typeof graph === 'undefined' ? [] : [...graph.keys()].map(f => normalize(path.relative(target, f))).sort(), checkedGroups: groupResults, checkedHMElements: [...checkedElements].sort(), tokenEvidence, styleEvidence, 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)] [--result FINAL_HTML_OR_BOOTSTRAP] [--json]\n--result selects the submitted React artifact. PARTIAL checks reachable selected files; PASS covers static contracts only.');
if (options.help) console.log('node scripts/ai-generation-guard.mjs --target PATH --scope FULL_SCREEN|PARTIAL [--profile PRODUCTION|STORYBOOK] [--mode GNB_ONLY|LNB_ONLY|DASHBOARD_ONLY]\n[--entry BOOTSTRAP_OR_STORY] [--files CHANGED_FILE,... (PARTIAL)] [--result FINAL_HTML_OR_BOOTSTRAP (PRODUCTION)] [--json]\nStorybook discovery uses .storybook/main stories and preview; it does not extend the Production graph. PASS covers static contracts only.');
else {
const report = await inspect(options);
if (options.json) console.log(JSON.stringify(report, null, 2));
+647
View File
@@ -8,6 +8,193 @@ import { createHash } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import test from 'node:test';
test('64. finite conditional templates and concatenation validate every Token branch', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; const name = location.hash ? "fg-primary" : "bg-content"; export default () => <Button style={{ color: `var(--hm-sem-${name})` }}/>' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.ok(!r.issues.some(i => i.code.startsWith('TOKEN')));
assert.ok(r.tokenEvidence[0].references.some(t => t.name === '--hm-sem-fg-primary'));
});
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; const name = location.hash ? "fg-primary" : "missing"; export default () => <Button style={{ color: "var(--hm-sem-" + name + ")" }}/>' }, root => {
assert.ok(run(root, ['--scope', 'FULL_SCREEN']).report.issues.some(i => i.code === 'TOKEN-001' && i.severity === 'FAIL'));
});
});
test('65. fallback never hides unknown Token and declared local variables are inventoried', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button style={{ color: "var(--hm-missing, #fff)" }}/>', 'src/styles.css': '@import "./tokens.css"; :root { --local: var(--hm-sem-fg-primary); } .content { color: var(--local); }' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.ok(r.issues.some(i => i.code === 'TOKEN-001' && i.severity === 'FAIL'));
assert.ok(r.tokenEvidence[0].references.some(t => t.name === '--local' && t.status === 'PASS'));
});
});
test('66. external props, style spread and className inputs retain site uncertainty', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default ({ color, styles, classes }) => <Button className={classes} style={{ color, ...styles }}/>' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
const sites = r.tokenEvidence[0].dynamic;
assert.ok(sites.some(s => s.kind === 'CLASS_NAME'));
assert.ok(sites.some(s => s.kind === 'STYLE_VALUE'));
assert.ok(sites.some(s => s.kind === 'STYLE_SPREAD'));
assert.equal(r.staticStatus, 'NOT VERIFIED');
});
});
test('67. shadowed constants and partial dynamic templates never become fake literal FAILs', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; const name="fg-primary"; export default ({ name }) => <Button style={{ color: `var(--hm-sem-${name})` }}/>' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.ok(r.issues.some(i => i.code === 'TOKEN-002'));
assert.ok(!r.issues.some(i => i.code === 'TOKEN-001'));
});
});
test('68. explicit Component HEX RGB HSL spacing and radius overrides fail', () => {
for (const value of ['#abcdef', 'rgb(1,2,3)', 'hsl(1 2% 3%)']) fixture({ 'src/styles.css': `@import "./tokens.css"; [data-slot="button"] { color: ${value}; }` }, root => {
assert.ok(run(root, ['--scope', 'FULL_SCREEN']).report.issues.some(i => i.code === 'STYLE-001' && i.severity === 'FAIL'));
});
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button className="rounded-[13px]" style={{ padding: "17px" }}/>' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.equal(r.styleEvidence.staticStatus, 'FAIL');
assert.equal(r.status, 'FAIL');
});
});
test('69. documented Token override keeps static measurements and intent separate', () => {
fixture({ 'src/styles.css': '@import "./tokens.css"; [data-slot="badge"] { --hm-badge-outline-text: var(--hm-sem-fg-primary); }' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.equal(r.styleEvidence.staticStatus, 'PASS');
assert.equal(r.styleEvidence.designIntentStatus, 'NOT VERIFIED');
assert.equal(r.styleEvidence.runtimeStatus, 'NOT VERIFIED');
assert.ok(r.issues.some(i => i.code === 'REUSE-003'));
});
});
test('70. general layout and global CSS are inventoried without claiming a Component violation', () => {
fixture({ 'src/styles.css': '@import "./tokens.css"; body { margin: 0; } .content { padding: 17px; color: #abcdef; }' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.ok(!r.issues.some(i => i.code === 'STYLE-001'));
assert.ok(r.styleEvidence.checks.some(c => c.scope === 'GLOBAL'));
assert.equal(r.styleEvidence.designIntentStatus, 'NOT VERIFIED');
});
});
test('71. disconnected and PARTIAL CSS do not expand style findings; Storybook stays reference', () => {
fixture({ 'src/disconnected.css': '[data-slot="button"] { color: #abcdef; }' }, root => {
assert.ok(!run(root, ['--scope', 'FULL_SCREEN']).report.issues.some(i => i.code === 'STYLE-001'));
});
storyFixture({ 'src/Example.stories.tsx': 'import { Button } from "@hm-design/ui"; export const All = () => <Button style={{ color: "#abcdef" }}/>' }, root => {
assert.ok(!run(root, ['--profile', 'STORYBOOK', '--scope', 'FULL_SCREEN']).report.issues.some(i => i.code === 'STYLE-001'));
});
});
test('72. malformed var() and numeric / constant Component overrides remain FAIL', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; const color="#abcdef"; export default () => <Button style={{ color, borderRadius: 13 }}/>', 'src/styles.css': '@import "./tokens.css"; .content { color: var(--hm-sem-fg-primary broken); }' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.ok(r.issues.some(i => i.code === 'TOKEN-003'));
assert.ok(r.issues.some(i => i.code === 'STYLE-001'));
});
});
test('73. var fallback does not hide a hardcoded color elsewhere in Component styling', () => {
fixture({ 'src/styles.css': '@import "./tokens.css"; [data-slot="badge"] { background: color-mix(in srgb, #abcdef 50%, var(--hm-sem-bg-content)); border-top-left-radius: -1.5px; }' }, root => {
assert.equal(run(root, ['--scope', 'FULL_SCREEN']).report.styleEvidence.staticStatus, 'FAIL');
});
fixture({ 'src/App.tsx': 'import { Badge } from "@hm-design/ui"; export default () => <Badge style={{ color: "var(--hm-sem-fg-primary, #fff)" }}/>' }, root => {
assert.ok(!run(root, ['--scope', 'FULL_SCREEN']).report.issues.some(i => i.code === 'STYLE-001'));
});
});
test('74. unresolved dynamic branches do not hide a known hardcoded Component style', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default ({ value, classes }) => <Button className={`bg-[#abcdef] ${classes}`} style={{ color: value ? "#abcdef" : value }}/>' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.ok(r.issues.some(i => i.code === 'STYLE-001'));
assert.ok(r.issues.some(i => i.code === 'TOKEN-002'));
assert.equal(r.status, 'FAIL');
});
});
test('75. complete malformed var expressions stay FAIL and style findings appear in reuse group', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button style={{ color: "var(--hm-missing", padding: 17 }}/>' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.ok(r.issues.some(i => i.code === 'TOKEN-003' && i.severity === 'FAIL'));
assert.equal(r.checkedGroups.find(g => g.name === 'Component Reuse').status, 'FAIL');
assert.equal(r.status, 'FAIL');
});
});
test('52. connected public Theme catches unknown HM references in JSX and CSS', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button className="text-[var(--hm-sem-text-made-up)]"/>;', 'src/styles.css': '@import "./tokens.css"; .custom { border-color: var(--hm-sem-stroke-divider); }' }, root => {
const { report, status } = run(root, ['--scope', 'FULL_SCREEN']);
assert.equal(status, 1);
assert.equal(report.issues.filter(i => i.code === 'TOKEN-001').length, 2);
assert.equal(report.checkedGroups.find(g => g.name === 'Theme Token').status, 'FAIL');
});
});
test('53. valid Theme, shorthand and declared consumer variables are preserved', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button className="text-(--hm-sem-fg-primary)" style={{ color: "var(--hm-app-color)" }}/>;', 'src/styles.css': '@import "./tokens.css"; :root { --hm-app-color: var(--hm-sem-action-primary); }' }, root => {
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
assert.equal(report.status, 'PASS');
assert.ok(report.tokenEvidence[0].references.some(r => r.name === '--hm-sem-fg-primary'));
});
});
test('54. disconnected CSS cannot supply a Theme variable declaration', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button style={{ color: "var(--hm-external-token)" }}/>;', 'src/disconnected.css': ':root { --hm-external-token: green; }' }, root => {
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
assert.ok(report.issues.some(i => i.code === 'TOKEN-001'));
assert.ok(!report.checkedFiles.includes('src/disconnected.css'));
});
});
test('55. PARTIAL limits Token findings and keeps ancestor declarations', () => {
fixture({ 'src/App.tsx': 'import { Input } from "@hm-design/ui"; import Other from "./Other"; export default () => <><Input style={{ color: "var(--hm-sem-fg-primary)" }}/><Other/></>;', 'src/Other.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button className="text-[var(--hm-undefined)]"/>;' }, root => {
assert.equal(run(root, ['--scope', 'FULL_SCREEN']).report.status, 'FAIL');
assert.equal(run(root, ['--scope', 'PARTIAL', '--files', 'src/App.tsx']).report.status, 'FAIL'); // imported dependency included
assert.equal(run(root, ['--scope', 'PARTIAL', '--files', 'src/styles.css']).report.status, 'PASS');
});
});
test('56. missing Status CSS cannot borrow its tokens from the Theme', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button style={{ color: "var(--hm-dashboard-status-active-color)" }}/>;' }, root => {
assert.ok(run(root, ['--scope', 'FULL_SCREEN']).report.issues.some(i => i.code === 'TOKEN-001'));
});
});
test('57. dynamic HM variables remain uncertain without hiding a literal failure', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; const key = location.hash; export default () => <Button style={{ color: `var(--hm-${key})`, background: "var(--hm-unknown)" }}/>;' }, root => {
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
assert.equal(report.status, 'FAIL');
assert.ok(report.issues.some(i => i.code === 'TOKEN-002' && i.severity === 'NOT VERIFIED'));
});
});
test('58. staticStatus distinguishes proven React bootstrap from browser evidence', () => {
fixture({ '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>OK</Button>;' }, root => {
const { report } = run(root, ['--scope', 'FULL_SCREEN', '--result', 'index.html']);
assert.equal(report.status, 'NOT VERIFIED');
assert.equal(report.staticStatus, 'PASS');
assert.equal(report.runtimeStatus, 'NOT VERIFIED');
assert.ok(report.issues.some(i => i.code === 'RENDER-002'));
assert.ok(report.checkedGroups.every(g => g.basis && Array.isArray(g.evidence)));
});
});
test('59. staticStatus keeps CSS intent uncertainty and actual failures', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button/>;', 'src/styles.css': '@import "./tokens.css"; [data-slot="button"] { color: var(--hm-sem-fg-primary); }' }, root => {
assert.equal(run(root, ['--scope', 'FULL_SCREEN']).report.staticStatus, 'NOT VERIFIED');
});
});
test('60. Storybook token scope is isolated per Story with shared preview', () => {
storyFixture({ 'src/Example.stories.tsx': 'import { Button } from "@hm-design/ui"; export const All = () => <Button style={{ color: "var(--hm-story-only)" }}/>;', 'src/Other.stories.tsx': 'import "./other.css"; export const All = () => <div/>;', 'src/other.css': ':root { --hm-story-only: green; }' }, root => {
const { report } = run(root, ['--profile', 'STORYBOOK', '--scope', 'FULL_SCREEN']);
assert.ok(report.issues.some(i => i.code === 'TOKEN-001' && i.file === 'src/Example.stories.tsx'));
});
});
test('61. native role widgets require provenance review while content HTML stays allowed', () => {
fixture({ 'src/App.tsx': 'import { Card } from "@hm-design/ui"; export default () => <Card><div role="button" tabIndex={0}>Custom</div><p>Content</p></Card>;' }, root => {
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
assert.equal(report.status, 'NOT VERIFIED');
assert.equal(report.issues.filter(i => i.code === 'REUSE-006').length, 1);
assert.ok(report.issues.every(i => i.severity !== 'FAIL'));
});
});
test('62. an incomplete CSS graph cannot prove an HM variable is absent', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button style={{ color: "var(--hm-external-color)" }}/>;', 'src/styles.css': '@import "./tokens.css"; @import "./unresolved.css";' }, root => {
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
assert.equal(report.status, 'NOT VERIFIED');
assert.ok(report.issues.some(i => i.code === 'TOKEN-001' && i.severity === 'NOT VERIFIED'));
});
});
test('63. early PARTIAL scope diagnostics survive fingerprint reporting', () => {
fixture({ 'src/App.tsx': 'export default () => <div/>;' }, root => {
const { report, status } = run(root, ['--scope', 'PARTIAL']);
assert.equal(status, 0);
assert.ok(report.issues.some(i => i.code === 'SCOPE-002'));
assert.equal(report.staticStatus, 'NOT VERIFIED');
});
});
const guard = fileURLToPath(new URL('./ai-generation-guard.mjs', import.meta.url));
function fixture(files, callback) {
const tempRoot = fs.realpathSync(os.tmpdir());
@@ -214,3 +401,463 @@ test('14. wrappers and inline HTML strings are not falsely marked FAIL; unused b
assert.ok(run(root, ['--scope', 'PARTIAL', '--files', 'src/App.tsx']).report.issues.some(i => i.code === 'REUSE-002'));
});
});
const dashboardSource = `import { DashboardLayout, DashboardHeader, DashboardSidebar } from "@hm-design/ui";
export function Screen({ mode = "compact" }) {
const compact = mode === "compact";
const reference = <DashboardSidebar>Reference</DashboardSidebar>;
return <DashboardLayout header={<DashboardHeader/>} sidebar={compact ? undefined : reference}/>;
}`;
const dashboardFixture = (mode, callback) => fixture({
'src/Screen.tsx': dashboardSource,
'src/App.tsx': `import { Screen } from "./Screen"; export default () => <Screen ${mode}/>;`,
}, callback);
const dashArgs = ['--scope', 'FULL_SCREEN', '--mode', 'DASHBOARD_ONLY'];
test('15. compact prop proves a preserved Reference Sidebar value is not rendered', () => {
dashboardFixture('mode="compact"', root => {
const { report } = run(root, dashArgs);
assert.equal(report.status, 'PASS');
assert.ok(!report.issues.some(i => i.code.startsWith('NAV')));
});
});
test('16. the same Reference branch fails when selected; no broad Sidebar exception', () => {
dashboardFixture('mode="header-sidebar"', root => {
const { status, report } = run(root, dashArgs);
assert.equal(status, 1);
assert.ok(report.issues.some(i => i.code === 'NAV-001' && i.severity === 'FAIL'));
});
});
test('17. unknown conditional Sidebar stays NOT VERIFIED and cannot PASS', () => {
dashboardFixture('mode={window.mode}', root => {
const { report } = run(root, dashArgs);
assert.equal(report.status, 'NOT VERIFIED');
assert.ok(report.issues.some(i => i.code === 'NAV-001' && i.severity === 'NOT VERIFIED'));
assert.ok(report.issues.some(i => i.code === 'NAV-005'));
});
});
test('18. default props and aliased/barrel component calls preserve compact evidence', () => {
fixture({
'src/Screen.tsx': dashboardSource,
'src/barrel.ts': 'export { Screen as Board } from "./Screen";',
'src/App.tsx': 'import { Board } from "./barrel"; export default () => <Board/>;',
}, root => assert.equal(run(root, dashArgs).report.status, 'PASS'));
});
test('19. multiple call sites retain the violating Reference branch', () => {
fixture({
'src/Screen.tsx': dashboardSource,
'src/App.tsx': 'import { Screen } from "./Screen"; export default () => <><Screen mode="compact"/><Screen mode="sidebar"/></>;',
}, root => assert.equal(run(root, dashArgs).report.status, 'FAIL'));
});
test('20. JSX ternary and logical literal branches: true fails, false is absent', () => {
for (const [expression, expected] of [['true && <DashboardSidebar/>', 'FAIL'], ['false && <DashboardSidebar/>', 'PASS'], ['false ? <DashboardSidebar/> : null', 'PASS'], ['true ? <DashboardSidebar/> : null', 'FAIL']]) {
fixture({ 'src/App.tsx': `import { DashboardHeader, DashboardSidebar } from "@hm-design/ui"; export default () => <><DashboardHeader/>{${expression}}</>;` }, root => assert.equal(run(root, dashArgs).report.status, expected, expression));
}
});
test('21. spread, mutable conditions and opaque render callbacks cannot conceal Sidebar', () => {
for (const source of [
'import { Screen } from "./Screen"; export default () => <Screen {...window.props}/>;',
'import { Screen } from "./Screen"; export default () => <Screen mode={window.x ? "compact" : "sidebar"}/>;',
'import { DashboardSidebar } from "@hm-design/ui"; export default () => { let show = false; show = window.show; return show && <DashboardSidebar/>; };',
'import { DashboardSidebar } from "@hm-design/ui"; export default () => <External render={() => <DashboardSidebar/>}/>;',
'import { DashboardSidebar } from "@hm-design/ui"; export default () => { const side = <DashboardSidebar/>; return window.forward(side); };',
]) fixture({ 'src/Screen.tsx': dashboardSource, 'src/App.tsx': source }, root => assert.notEqual(run(root, dashArgs).report.status, 'PASS', source));
});
test('22. new mixed-navigation screen fails; PARTIAL retains selected Sidebar failures', () => {
fixture({ 'src/App.tsx': 'import { GNB, GNBMobileMenu, LNB, DashboardSidebar } from "@hm-design/ui"; export default () => <><GNB><GNBMobileMenu/></GNB><LNB/><DashboardSidebar/></>;' }, root => {
const args = ['--mode', 'GNB_ONLY'];
for (const scope of [['--scope', 'FULL_SCREEN'], ['--scope', 'PARTIAL', '--files', 'src/App.tsx']]) {
const { report } = run(root, [...scope, ...args]);
assert.equal(report.status, 'FAIL');
for (const code of ['NAV-001', 'NAV-002']) assert.ok(report.issues.some(i => i.code === code && i.severity === 'FAIL'));
}
});
});
const storyFixture = (extra, callback) => fixture({
'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button/>;',
'.storybook/main.ts': 'export default { stories: ["../src/**/*.stories.@(ts|tsx)"] };',
'.storybook/preview.tsx': 'import "../src/styles.css"; export default {};',
'src/Example.stories.tsx': 'import { Button } from "@hm-design/ui"; import { StoryPage } from "./story-page"; export default { title: "Components/Example" }; export const All = () => <StoryPage><Button/></StoryPage>;',
'src/story-page.tsx': 'export const StoryPage = ({children}) => <div className="sb-page">{children}</div>;',
...extra,
}, callback);
test('23. Storybook independently discovers CSF and preview without Production pollution', () => {
storyFixture({}, root => {
const production = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.equal(production.status, 'PASS');
assert.ok(!production.checkedFiles.some(f => f.endsWith('.stories.tsx')));
const stories = run(root, ['--profile', 'STORYBOOK', '--scope', 'PARTIAL', '--files', 'src/Example.stories.tsx']).report;
assert.equal(stories.status, 'PASS');
assert.ok(stories.checkedFiles.includes('src/Example.stories.tsx'));
assert.ok(!stories.issues.some(i => i.code === 'SCOPE-001'));
});
});
test('24. Storybook invalid public export and private import still fail', () => {
storyFixture({ 'src/Example.stories.tsx': 'import { NotPublic } from "@hm-design/ui"; import { Button } from "@hm-design/ui/src/components/ui/button"; export const All = () => <><NotPublic/><Button/></>;' }, root => {
const { report } = run(root, ['--profile', 'STORYBOOK', '--scope', 'FULL_SCREEN']);
for (const code of ['IMP-001', 'IMP-002']) assert.ok(report.issues.some(i => i.code === code));
assert.equal(report.status, 'FAIL');
});
});
test('25. Storybook preview supplies CSS; proven missing theme/utilities FAIL', () => {
storyFixture({ '.storybook/preview.tsx': 'export default {};' }, root => {
const { report } = run(root, ['--profile', 'STORYBOOK', '--scope', 'FULL_SCREEN']);
assert.equal(report.status, 'FAIL');
for (const code of ['CSS-001', 'CSS-002']) assert.ok(report.issues.some(i => i.code === code));
});
});
test('26. computed Storybook discovery cannot silently PASS', () => {
storyFixture({ '.storybook/main.ts': 'export default { stories: getStories() };' }, root => {
const { report } = run(root, ['--profile', 'STORYBOOK', '--scope', 'FULL_SCREEN']);
assert.equal(report.status, 'NOT VERIFIED');
assert.ok(report.issues.some(i => i.code === 'SB-004'));
});
});
test('27. Reference navigation is preserved; an explicit Storybook mode checks violations', () => {
storyFixture({ 'src/Example.stories.tsx': 'import { DashboardSidebar } from "@hm-design/ui"; export const All = () => <DashboardSidebar/>;' }, root => {
assert.equal(run(root, ['--profile', 'STORYBOOK', '--scope', 'FULL_SCREEN']).report.status, 'PASS');
assert.equal(run(root, ['--profile', 'STORYBOOK', '--scope', 'FULL_SCREEN', '--mode', 'DASHBOARD_ONLY']).report.status, 'FAIL');
});
});
test('28. normal Primary 80% gradient is parsed without modification; consumer slot intent is reviewed', () => {
fixture({
'src/App.tsx': 'import { Tabs } from "@hm-design/ui"; export default () => <Tabs/>;',
'src/styles.css': '@import "./tokens.css"; [data-slot="tabs-frame"][data-fill="true"][data-panel-surface="content"] { --hm-tab-fill-face: linear-gradient(to bottom, color-mix(in srgb, var(--hm-sem-palette-white) 80%, transparent) 0%, var(--hm-sem-bg-content) 100%); }',
}, root => {
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
assert.ok(!report.issues.some(i => i.severity === 'FAIL' || i.code === 'CSS-005'));
assert.ok(report.issues.some(i => i.code === 'REUSE-003'));
});
});
test('29. malformed CSS, forged component slots and CSS replicas do not PASS', () => {
fixture({ 'src/App.tsx': 'import { Tabs } from "@hm-design/ui"; export default () => <><Tabs/><div data-slot="tabs"/></>;', 'src/styles.css': '@import "./tokens.css"; .hm-tabs { color: red; }' }, root => {
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
assert.equal(report.status, 'FAIL');
for (const code of ['REUSE-001', 'REUSE-003']) assert.ok(report.issues.some(i => i.code === code));
});
fixture({ 'src/App.tsx': 'import { Tabs } from "@hm-design/ui"; export default () => <Tabs/>;', 'src/styles.css': '.hm-tabs {' }, root => {
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
assert.ok(report.issues.some(i => i.code === 'CSS-005'));
assert.notEqual(report.status, 'PASS');
});
});
test('30. raw CSS is not an entry; package implementation scope is never claimed as consumer PASS', () => {
fixture({ 'src/main.tsx': 'import App from "./App"; import css from "./tokens.css?raw"; export default App;', 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button/>;' }, root => {
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
for (const code of ['CSS-001', 'CSS-002']) assert.ok(report.issues.some(i => i.code === code));
});
const root = fileURLToPath(new URL('../', import.meta.url));
const { report } = run(root, ['--scope', 'PARTIAL', '--files', 'packages/ui/src/theme/tabs.css']);
assert.equal(report.status, 'NOT VERIFIED');
assert.ok(report.issues.some(i => i.code === 'SCOPE-001'));
});
test('31. opaque spread, destructuring, mutation and callback aliases cannot hide a violation', () => {
for (const source of [
'import { DashboardSidebar } from "@hm-design/ui"; export default () => <External {...{sidebar: <DashboardSidebar/>}}/>;',
'import { DashboardSidebar } from "@hm-design/ui"; export default () => { const props = { sidebar: <DashboardSidebar/> }; return <External {...props}/>; };',
'import { DashboardSidebar } from "@hm-design/ui"; export default () => { const [side] = [<DashboardSidebar/>]; return side; };',
'import { DashboardSidebar } from "@hm-design/ui"; export default () => { let side = <DashboardSidebar/>; return side; };',
'import { DashboardSidebar } from "@hm-design/ui"; export default () => { let side = <DashboardSidebar/>; side = window.forward(side); return side; };',
'import { DashboardSidebar } from "@hm-design/ui"; function Screen({mode}) { mode = "sidebar"; return mode === "compact" ? null : <DashboardSidebar/>; } export default () => <Screen mode="compact"/>;',
'import { DashboardSidebar } from "@hm-design/ui"; export default () => { const options = {show:false}; window.mutate(options); return options.show && <DashboardSidebar/>; };',
'import { DashboardSidebar } from "@hm-design/ui"; export default () => { const side = <DashboardSidebar/>; const callback = () => side; return window.render(callback); };',
'import { DashboardSidebar } from "@hm-design/ui"; export default () => <External options={{nav: <DashboardSidebar/>}}/>;',
'import { DashboardSidebar } from "@hm-design/ui"; export default () => { const obj = {...window.props, nav: <DashboardSidebar/>}; return obj.nav; };',
]) fixture({ 'src/App.tsx': source }, root => assert.notEqual(run(root, dashArgs).report.status, 'PASS', source));
});
test('32. internal Reference wrapper is reviewable; public-slot replicas and Production wrappers fail', () => {
storyFixture({ 'src/Example.stories.tsx': 'import { LNBMobileLink } from "@hm-design/ui"; export const All = () => <div data-slot="lnb-mobile-nav"><LNBMobileLink/></div>;' }, root => {
const reference = run(root, ['--profile', 'STORYBOOK', '--scope', 'FULL_SCREEN']).report;
assert.equal(reference.status, 'NOT VERIFIED');
assert.ok(reference.issues.some(i => i.code === 'REUSE-001' && i.severity === 'NOT VERIFIED'));
fs.writeFileSync(path.join(root, 'src/App.tsx'), 'import { LNBMobileLink } from "@hm-design/ui"; export default () => <div data-slot="lnb-mobile-nav"><LNBMobileLink/></div>;');
assert.equal(run(root, ['--scope', 'FULL_SCREEN']).report.status, 'FAIL');
fs.writeFileSync(path.join(root, 'src/Example.stories.tsx'), 'export const All = () => <select data-slot="select"/>;');
assert.equal(run(root, ['--profile', 'STORYBOOK', '--scope', 'FULL_SCREEN']).report.status, 'FAIL');
});
});
test('33. one lazy Story cannot supply missing CSS entry evidence to another Story', () => {
storyFixture({
'.storybook/preview.tsx': 'export default {};',
'src/First.stories.tsx': 'import "./styles.css"; import { Button } from "@hm-design/ui"; export const All = () => <Button/>;',
}, root => {
const { report } = run(root, ['--profile', 'STORYBOOK', '--scope', 'FULL_SCREEN']);
assert.equal(report.status, 'FAIL');
assert.ok(report.issues.some(i => i.code === 'CSS-001' && i.file === 'src/Example.stories.tsx'));
assert.ok(!report.issues.some(i => i.code.startsWith('CSS-') && i.file === 'src/First.stories.tsx'));
});
});
test('34. a possible violating call does not become certain through another safe call', () => {
fixture({
'src/Screen.tsx': dashboardSource,
'src/App.tsx': 'import { Screen } from "./Screen"; export default () => <><Screen mode="compact"/>{window.flag && <Screen mode="sidebar"/>}</>;',
}, root => {
const { report } = run(root, dashArgs);
assert.equal(report.status, 'NOT VERIFIED');
assert.ok(report.issues.some(i=>i.code === 'NAV-001' || i.code === 'NAV-005'));
});
});
test('35. a public Sidebar forwarded as a component prop still fails', () => {
for (const source of [
'import { DashboardSidebar } from "@hm-design/ui"; const Forward = ({component:Component}) => <Component/>; export default () => <Forward component={DashboardSidebar}/>;',
'import * as HM from "@hm-design/ui"; const Forward = ({component:Component}) => <Component/>; export default () => <Forward component={HM.DashboardSidebar}/>;',
]) fixture({'src/App.tsx':source},root=> {
const { report } = run(root,dashArgs);
assert.equal(report.status,'FAIL');
assert.ok(report.issues.some(i=>i.code === 'NAV-001' && i.severity === 'FAIL'));
});
});
test('36. aliased object mutation and non-primitive identity cannot prove a false dead branch', () => {
for (const body of [
'const options = {show:false}; const alias = options; alias.show = true; return options.show && <DashboardSidebar/>;',
'const options = {show:false}; const alias = options; window.mutate(alias); return options.show && <DashboardSidebar/>;',
'const value = <div/>; return value === value ? <DashboardSidebar/> : null;',
'const value = () => <div/>; return value.name === "value" ? <DashboardSidebar/> : null;',
]) fixture({'src/App.tsx':`import { DashboardSidebar } from "@hm-design/ui"; export default () => { ${body} };`},root=>assert.notEqual(run(root,dashArgs).report.status,'PASS',body));
});
test('37. resolved Production graph without HM theme is a required-style failure', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button/>;', 'src/tokens.css': '@import "tailwindcss";' }, root => {
const { status, report } = run(root, ['--scope', 'FULL_SCREEN']);
assert.equal(status, 1);
assert.ok(report.issues.some(issue => issue.code === 'CSS-001' && issue.severity === 'FAIL'));
});
});
test('38. missing Tailwind with a resolved HM component graph fails independently', () => {
fixture({ 'src/App.tsx': 'import { Input } from "@hm-design/ui"; export default () => <Input/>;', 'src/tokens.css': '@import "@hm-design/ui/theme.css";' }, root => {
const { report } = run(root, ['--scope', 'PARTIAL', '--files', 'src/App.tsx']);
assert.ok(report.issues.some(issue => issue.code === 'CSS-002' && issue.severity === 'FAIL'));
assert.ok(!report.issues.some(issue => issue.code === 'CSS-001'));
});
});
test('39. unresolved CSS graph preserves NOT VERIFIED rather than claiming absence', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button/>;', 'src/styles.css': '@import "./missing-theme.css";' }, root => {
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
assert.ok(report.issues.some(issue => issue.code === 'CSS-001' && issue.severity === 'NOT VERIFIED'));
assert.ok(report.issues.some(issue => issue.code === 'ENTRY-002'));
});
});
test('40. required status CSS absence fails without relaxing navigation or import checks', () => {
fixture({ 'src/App.tsx': 'import { Badge, dashboardStatusDefinitions } from "@hm-design/ui"; export default () => <Badge>{dashboardStatusDefinitions.active.label}</Badge>;' }, root => {
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
assert.ok(report.issues.some(issue => issue.code === 'CSS-003' && issue.severity === 'FAIL'));
});
});
test('41. Storybook PARTIAL detects disconnected preview styles for the selected Story', () => {
storyFixture({ '.storybook/preview.tsx': 'export default {};' }, root => {
const { status, report } = run(root, ['--profile', 'STORYBOOK', '--scope', 'PARTIAL', '--files', 'src/Example.stories.tsx']);
assert.equal(status, 1);
for (const code of ['CSS-001', 'CSS-002']) assert.ok(report.issues.some(i => i.code === code && i.severity === 'FAIL'));
});
});
test('42. explicit Story entry resolves computed discovery without claiming unselected coverage', () => {
storyFixture({
'.storybook/main.ts': 'export default { stories: getStories() };',
'src/Bad.stories.tsx': 'export const All = () => <select data-slot="select"/>;',
}, root => {
const { report } = run(root, ['--profile', 'STORYBOOK', '--scope', 'FULL_SCREEN', '--entry', 'src/Example.stories.tsx']);
assert.equal(report.status, 'PASS');
assert.ok(report.checkedFiles.includes('src/Example.stories.tsx'));
assert.ok(report.checkedFiles.includes('.storybook/preview.tsx'));
assert.ok(!report.checkedFiles.includes('src/Bad.stories.tsx'));
});
});
test('43. inherited alias configuration remains uncertain even with locally resolved imports', () => {
fixture({
'tsconfig.app.json': '{ "extends": "./base.json", "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }',
'base.json': '{ "compilerOptions": { "paths": { "external/*": ["./external/*"] } } }',
'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button/>;',
}, root => {
const { status, report } = run(root, ['--scope', 'FULL_SCREEN']);
assert.equal(status, 0);
assert.equal(report.status, 'NOT VERIFIED');
assert.ok(report.issues.some(i => i.code === 'ENTRY-003' && i.severity === 'NOT VERIFIED'));
});
});
test('44. internal Reference wrapper cannot downgrade a public-slot violation in the same Story', () => {
storyFixture({ 'src/Example.stories.tsx': 'import { LNBMobileLink } from "@hm-design/ui"; export const All = () => <div data-slot="lnb-mobile-nav"><LNBMobileLink/><div data-slot="select-trigger"/></div>;' }, root => {
const { report } = run(root, ['--profile', 'STORYBOOK', '--scope', 'PARTIAL', '--files', 'src/Example.stories.tsx']);
assert.equal(report.status, 'FAIL');
assert.ok(report.issues.some(i => i.code === 'REUSE-001' && i.severity === 'NOT VERIFIED'));
assert.ok(report.issues.some(i => i.code === 'REUSE-001' && i.severity === 'FAIL'));
});
});
test('45. proven compact navigation does not approve consumer CSS intent', () => {
fixture({
'src/Screen.tsx': dashboardSource,
'src/App.tsx': 'import { Screen } from "./Screen"; export default () => <Screen mode="compact"/>;',
'src/styles.css': '@import "./tokens.css"; [data-slot="dashboard-header"] { background: red; }',
}, root => {
const { report } = run(root, dashArgs);
assert.equal(report.status, 'NOT VERIFIED');
assert.ok(!report.issues.some(i => i.code.startsWith('NAV')));
assert.ok(report.issues.some(i => i.code === 'REUSE-003' && i.severity === 'NOT VERIFIED'));
});
});
test('46. selected package implementation stays outside consumer coverage in both profiles', () => {
const root = fileURLToPath(new URL('../', import.meta.url));
for (const profile of ['PRODUCTION', 'STORYBOOK']) {
const { report } = run(root, ['--profile', profile, '--scope', 'PARTIAL', '--files', 'packages/ui/src/components/ui/select.tsx']);
assert.equal(report.status, 'NOT VERIFIED');
assert.ok(report.issues.some(i => i.code === 'SCOPE-001' && i.severity === 'NOT VERIFIED'));
assert.ok(!report.checkedFiles.includes('packages/ui/src/components/ui/select.tsx'));
assert.ok(!report.issues.some(i => i.severity === 'FAIL'));
}
});
test('47. external native field Label needs review, with no blanket native HTML failure', () => {
fixture({ 'src/App.tsx': 'import { Input } from "@hm-design/ui"; export default () => <><label htmlFor="name">Name</label><Input id="name"/></>;' }, root => {
const { report, status } = run(root, ['--scope', 'FULL_SCREEN']);
assert.equal(status, 0);
assert.equal(report.status, 'NOT VERIFIED');
assert.ok(report.issues.some(i => i.code === 'REUSE-004' && i.message.includes('<label>') && i.severity === 'NOT VERIFIED'));
assert.ok(!report.issues.some(i => i.severity === 'FAIL'));
});
});
test('48. aliased public Label and linked controls are actual public consumption', () => {
fixture({ 'src/App.tsx': 'import { Label as FieldName, Input } from "@hm-design/ui"; export default () => <><FieldName htmlFor="name">Name</FieldName><Input id="name"/></>;' }, root => {
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
assert.equal(report.status, 'PASS');
assert.ok(report.checkedHMElements.includes('Label'));
assert.ok(!report.issues.some(i => i.code === 'REUSE-004'));
});
});
test('49. external React artifact is covered while browser verification stays separate', () => {
fixture({
'index.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 { GNB, GNBMobileMenu, Label, Input } from "@hm-design/ui"; export default () => <><GNB><GNBMobileMenu title="Menu"/></GNB><Label htmlFor="name">Name</Label><Input id="name"/></>;',
}, root => {
for (const scope of ['FULL_SCREEN', 'PARTIAL']) {
const args = ['--scope', scope, '--mode', 'GNB_ONLY', '--entry', 'index.html', '--result', 'index.html'];
if (scope === 'PARTIAL') args.push('--files', 'src/App.tsx');
const { report } = run(root, args);
assert.equal(report.status, 'NOT VERIFIED');
assert.ok(report.checkedFiles.includes('src/App.tsx'));
assert.ok(report.checkedGroups.every(g => g.status === 'PASS'));
assert.deepEqual(report.issues.map(i => i.code), ['RENDER-002']);
}
});
});
test('50. external artifact still fails actual Sidebar and missing Theme', () => {
fixture({
'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 { GNB, DashboardSidebar, Input } from "@hm-design/ui"; export default () => <><GNB/><DashboardSidebar/><Input/></>;',
'src/tokens.css': '@import "tailwindcss";',
}, root => {
const { report, status } = run(root, ['--scope', 'FULL_SCREEN', '--mode', 'GNB_ONLY', '--result', 'index.html']);
assert.equal(status, 1);
assert.equal(report.status, 'FAIL');
assert.ok(report.issues.some(i => i.code === 'NAV-001' && i.severity === 'FAIL'));
assert.ok(report.issues.some(i => i.code === 'CSS-001' && i.severity === 'FAIL'));
assert.ok(report.issues.some(i => i.code === 'RENDER-002'));
});
});
test('51. external orphan selection is scope omission, not browser-only uncertainty', () => {
fixture({ 'src/App.tsx': 'import {Button} from "@hm-design/ui"; export default () => <Button/>;', 'src/Orphan.tsx': 'import {Label} from "@hm-design/ui"; export default () => <Label/>;' }, root => {
const { report } = run(root, ['--scope', 'PARTIAL', '--files', 'src/Orphan.tsx', '--entry', 'index.html']);
assert.equal(report.status, 'NOT VERIFIED');
assert.ok(report.issues.some(i => i.code === 'SCOPE-001'));
assert.ok(!report.checkedFiles.includes('src/Orphan.tsx'));
assert.ok(!report.issues.some(i => i.code === 'RENDER-002'));
});
});
test('76. native hm-named content is not a public Component identity', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <><div className="hm-breadcrumb">Content</div><Button/></>;', 'src/styles.css': '@import "./tokens.css"; .hm-breadcrumb { color: #827b70; }' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.ok(!r.issues.some(i => i.code === 'STYLE-001'));
const check = r.styleEvidence.checks.find(c => c.selector === '.hm-breadcrumb');
assert.equal(check.component, null); assert.equal(check.designIntentStatus, 'NOT VERIFIED');
assert.ok(r.issues.some(i => i.code === 'REUSE-003'));
});
});
test('77. absent positive ancestor bounds only that entry and retains intent inventory', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <div className="work"><Button/></div>;', 'src/styles.css': '@import "./tokens.css"; .legacy [data-slot="button"] { gap: 6px; padding: 0 10px; border-radius: 4px; }' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.ok(!r.issues.some(i => i.code === 'STYLE-001'));
const checks = r.styleEvidence.checks.filter(c => c.selector.startsWith('.legacy'));
assert.equal(checks.length, 3); assert.ok(checks.every(c => c.applicability.status === 'NOT APPLICABLE' && c.designIntentStatus === 'NOT VERIFIED'));
assert.equal(r.styleEvidence.finalStatus, 'NOT VERIFIED');
});
});
test('78. present ancestor and class on a public Component preserve actual violations', () => {
for (const jsx of ['<div className="legacy"><Button/></div>', '<Button className="hm-button"/>']) fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => '+jsx+';', 'src/styles.css': '@import "./tokens.css"; .legacy [data-slot="button"], .hm-button { color: #abcdef; }' }, root => {
assert.ok(run(root, ['--scope', 'FULL_SCREEN']).report.issues.some(i => i.code === 'STYLE-001' && i.severity === 'FAIL'));
});
});
test('79. finite ancestor class branch cannot conceal a Component override', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; const name = location.hash ? "legacy" : "work"; export default () => <div className={name}><Button/></div>;', 'src/styles.css': '@import "./tokens.css"; .legacy [data-slot="button"] { padding: 17px; }' }, root => {
assert.ok(run(root, ['--scope', 'FULL_SCREEN']).report.issues.some(i => i.code === 'STYLE-001'));
});
});
test('80. dynamic classes spreads and DOM mutation retain strict detection', () => {
for (const app of [
'export default ({classes}) => <div className={classes}><Button/></div>;',
'export default ({props}) => <div {...props}><Button/></div>;',
'document.body.classList.add(location.hash); export default () => <Button/>;',
'document.body.setAttribute("class", location.hash); export default () => <Button/>;',
'document.body["className"] = location.hash; export default () => <Button/>;',
'document.body["classList"].add(location.hash); export default () => <Button/>;',
'document.body["setAttribute"]("class", location.hash); export default () => <Button/>;',
]) fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; '+app, 'src/styles.css': '@import "./tokens.css"; .legacy [data-slot="button"] { padding: 17px; }' }, root => {
assert.ok(run(root, ['--scope', 'FULL_SCREEN']).report.issues.some(i => i.code === 'STYLE-001'));
});
});
test('81. host HTML classes preserve scoped violation detection', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <div className="work"><Button/></div>;', 'index.html': '<body class="legacy"><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>', 'src/styles.css': '@import "./tokens.css"; .legacy [data-slot="button"] { padding: 17px; }' }, root => {
assert.ok(run(root, ['--scope', 'FULL_SCREEN']).report.issues.some(i => i.code === 'STYLE-001'));
});
});
test('82. selector alternatives pseudos and nesting never get broad exclusions', () => {
for (const selector of ['.legacy [data-slot="button"], [data-slot="button"]', ':not(.legacy) [data-slot="button"]', ':is(.legacy, .work) [data-slot="button"]', '.legacy :not(.work) [data-slot="button"]', '& .legacy [data-slot="button"]']) fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <div className="work"><Button/></div>;', 'src/styles.css': '@import "./tokens.css"; '+selector+' { color: #abcdef; }' }, root => {
assert.ok(run(root, ['--scope', 'FULL_SCREEN']).report.issues.some(i => i.code === 'STYLE-001'));
});
});
test('83. unresolved entry and HTML scripts prevent scope exclusions', () => {
for (const files of [{ 'src/main.tsx': 'import App from "./App"; import "./styles.css"; import "./missing"; export default App;' }, { 'index.html': '<script>document.body.className="legacy"</script><script type="module" src="/src/main.tsx"></script>' }]) fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <div className="work"><Button/></div>;', ...files, 'src/styles.css': '@import "./tokens.css"; .legacy [data-slot="button"] { padding: 17px; }' }, root => {
assert.ok(run(root, ['--scope', 'FULL_SCREEN']).report.issues.some(i => i.code === 'STYLE-001'));
});
});
test('84. aliases and createElement preserve class binding provenance', () => {
for (const app of ['import { Button as Action } from "@hm-design/ui"; export default () => <Action className="hm-button"/>;', 'import { Button } from "@hm-design/ui"; import React from "react"; export default () => React.createElement(Button, {className:"hm-button"});']) fixture({ 'src/App.tsx': app, 'src/styles.css': '@import "./tokens.css"; .hm-button { color: #abcdef; }' }, root => {
assert.ok(run(root, ['--scope', 'FULL_SCREEN']).report.issues.some(i => i.code === 'STYLE-001'));
});
});
test('85. independent entries and PARTIAL CSS scope never borrow exclusions', () => {
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <div className="work"><Button/></div>;', 'src/second.tsx': 'import { Button } from "@hm-design/ui"; import "./styles.css"; export default () => <div className="legacy"><Button/></div>;', 'src/styles.css': '@import "./tokens.css"; .legacy [data-slot="button"] { padding: 17px; }' }, root => {
const second = run(root, ['--scope', 'FULL_SCREEN', '--entry', 'src/second.tsx']).report;
assert.ok(second.issues.some(i => i.code === 'STYLE-001'));
const first = run(root, ['--scope', 'PARTIAL', '--files', 'src/styles.css']).report;
assert.ok(!first.issues.some(i => i.code === 'STYLE-001'));
});
});
test('86. Component implementation classes and unrelated graph mutations stay protected', () => {
fixture({ 'src/App.tsx': 'import { FilterBar } from "@hm-design/ui"; export default () => <FilterBar className="hm-list-search-basic"/>;', 'src/styles.css': '@import "./tokens.css"; .hm-list-search-basic { gap: 8px; }' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.ok(!r.issues.some(i => i.code === 'STYLE-001'));
assert.equal(r.styleEvidence.checks.find(c => c.selector === '.hm-list-search-basic').designIntentStatus, 'NOT VERIFIED');
});
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <div className="work"><Button/></div>;', 'src/styles.css': '@import "./tokens.css"; .group [data-slot="button"] { padding: 17px; }' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.ok(r.issues.some(i => i.code === 'STYLE-001'));
assert.equal(r.styleEvidence.checks.find(c => c.selector.startsWith('.group')).applicability.implementationClassPossible, true);
});
fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <div className="work"><Button/></div>;', 'src/main.tsx': 'import App from "./App"; import "./styles.css"; import "./mutation"; export default App;', 'src/mutation.ts': 'document.body.className = location.hash;', 'src/styles.css': '@import "./tokens.css"; .legacy [data-slot="button"] { padding: 17px; }' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.ok(r.issues.some(i => i.code === 'STYLE-001'));
assert.equal(r.styleEvidence.checks.find(c => c.selector.startsWith('.legacy')).applicability.classInputsComplete, false);
});
});
+59
View File
@@ -0,0 +1,59 @@
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { parse } from '@babel/parser';
import postcss from 'postcss';
const require = createRequire(import.meta.url);
const base = '0b2bc376aa8b6af9d2492b5620d8ae776391508d';
const output = '_local/quality-20261010';
const files = [...new Set(execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { encoding: 'utf8' }).split('\0'))].filter(Boolean);
const original = f => execFileSync('git', ['show', `${base}:${f}`], { encoding: 'utf8', maxBuffer: 10e6 });
const read = f => fs.readFileSync(f, 'utf8');
const hash = v => createHash('sha256').update(v).digest('hex');
const protectedFiles = ['themes/default.json', 'packages/ui/src/theme/default.css', 'packages/ui/src/theme/primitives.css', 'packages/ui/src/theme/default-values.generated.ts', 'packages/ui/src/components/ui/tabs.tsx', 'packages/ui/src/theme/tabs.css', 'packages/ui/src/components/ui/carousel.tsx', 'packages/ui/src/theme/carousel.css', 'docs/guidelines/publisher-additional-review-2026-10-10.md'];
const preservation = protectedFiles.map(file => ({ file, unchanged: hash(original(file)) === hash(read(file)) }));
const cssImports = [];
for (const file of files.filter(f => f.endsWith('.css'))) postcss.parse(read(file)).walkAtRules('import', n => {
const spec = n.params.match(/^["']([^"']+)["']/)?.[1];
if (!spec) return;
let resolved;
try { resolved = spec.startsWith('.') ? path.resolve(path.dirname(file), spec) : require.resolve(spec); } catch {}
if (!resolved && !spec.startsWith('.')) {
try {
const pieces = spec.split('/');
const pkg = pieces.slice(0, spec.startsWith('@') ? 2 : 1).join('/');
const directory = path.resolve('node_modules', pkg);
const manifest = JSON.parse(read(path.join(directory, 'package.json')));
const entry = manifest.exports?.[spec === pkg ? '.' : `.${spec.slice(pkg.length)}`];
if (entry?.style) resolved = path.join(directory, entry.style);
} catch {}
}
cssImports.push({ file, spec, resolved, exists: Boolean(resolved && fs.existsSync(resolved)) });
});
const exports = [];
for (const pkg of ['ui', 'center-graphic']) {
const dir = `packages/${pkg}`;
const manifest = JSON.parse(read(`${dir}/package.json`));
const check = (v, key) => typeof v === 'string' ? exports.push({ package: manifest.name, key, file: `${dir}/${v}`, exists: fs.existsSync(path.join(dir, v)) }) : Object.entries(v).forEach(([name, value]) => check(value, `${key}/${name}`));
Object.entries(manifest.exports).forEach(([key, value]) => check(value, key));
}
const walk = (n, fn) => { if (!n || typeof n !== 'object') return; if (n.type) fn(n); for (const [k, v] of Object.entries(n)) if (!['loc', 'start', 'end', 'comments'].includes(k)) Array.isArray(v) ? v.forEach(x => walk(x, fn)) : walk(v, fn); };
const clean = (key, value) => ['start', 'end', 'loc', 'extra', 'comments', 'leadingComments', 'trailingComments', 'innerComments'].includes(key) ? undefined : value;
const tests = text => { const result = new Map(); walk(parse(text, { sourceType: 'module' }), n => { if (n.type === 'CallExpression' && n.callee.name === 'test' && n.arguments[0]?.type === 'StringLiteral') result.set(n.arguments[0].value, JSON.stringify(n, clean)); }); return result; };
const testPreservation = ['scripts/ai-generation-guard.test.mjs', 'scripts/publisher-components.test.mjs'].map(file => { const before = tests(original(file)), after = tests(read(file)); return { file, before: before.size, after: after.size, unchangedBodies: [...before].filter(([name, value]) => after.get(name) === value).length }; });
const snapshot = 'D:/디자인시스템/HMDesign-AI-Generation-Test/retest-20261010/reference/storybook-static/index.json';
const beforeIDs = Object.keys(JSON.parse(read(snapshot)).entries).sort();
const afterIDs = Object.keys(JSON.parse(read('storybook-static/index.json')).entries).sort();
const crossTokens = [];
for (const id of ['02', '06', '07']) {
const target = `D:/디자인시스템/HMDesign-AI-Generation-Test/retest-20261010/cross-model/${id}`;
const r = spawnSync(process.execPath, ['scripts/ai-generation-guard.mjs', '--target', target, '--scope', 'FULL_SCREEN', '--mode', id === '07' ? 'DASHBOARD_ONLY' : 'GNB_ONLY', '--result', 'index.html', '--json'], { encoding: 'utf8' });
fs.writeFileSync(`${output}/cross-existing-${id}.json`, r.stdout);
const report = JSON.parse(r.stdout); crossTokens.push({ id, guard: report.status, tokenFindings: report.issues.filter(i => i.code.startsWith('TOKEN')) });
}
const result = { base, preservation, testPreservation, cssImports: { count: cssImports.length, missing: cssImports.filter(i => !i.exists) }, exports: { count: exports.length, missing: exports.filter(i => !i.exists) }, storyIDs: { before: beforeIDs.length, after: afterIDs.length, deleted: beforeIDs.filter(id => !afterIDs.includes(id)), added: afterIDs.filter(id => !beforeIDs.includes(id)) }, existingLowReasoningCases: { note: 'Reinspection of preserved historical output, not a new cross-model generation', cases: crossTokens } };
fs.writeFileSync(`${output}/contract-audit.json`, JSON.stringify(result, null, 2));
console.log(JSON.stringify(result, null, 2));
if (preservation.some(i => !i.unchanged) || testPreservation.some(i => i.before !== i.unchangedBodies) || result.cssImports.missing.length || result.exports.missing.length || result.storyIDs.deleted.length) process.exitCode = 1;
+3 -1
View File
@@ -77,7 +77,9 @@ if (name === "ui") {
await cp(path.join(root, "node_modules/shadcn/LICENSE.md"), path.join(output, "vendor/shadcn-LICENSE.md"))
const tailwind = (await readFile(path.join(packageRoot, "src/tailwind.css"), "utf8"))
.replace('"shadcn/tailwind.css"', '"./vendor/shadcn-tailwind.css"')
.replace(/@source "\.\/components";\r?\n@source "\.\/patterns";/, '@source "./index.js";')
// The compiled folder is self-contained. Scanning it also avoids the
// Windows file-source path case that drops index.js utility candidates.
.replace(/@source "\.\/components";\r?\n@source "\.\/patterns";/, '@source "./";')
await writeFile(path.join(output, "tailwind.css"), tailwind)
await mkdir(path.join(output, "fonts"), { recursive: true })
for (const file of ["NotoSansKR-variable.ttf", "NotoSansKR-OFL.txt"]) {
+34
View File
@@ -0,0 +1,34 @@
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
const root = process.cwd()
const files = [...new Set(execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { encoding: 'utf8' }).split('\0'))].filter(file => file.endsWith('.md'))
const stripCode = text => text.replace(/^```[^\n]*\n[\s\S]*?^```[^\n]*$/gm, '')
const slug = text => text.toLowerCase().replace(/`/g, '').replace(/[^\p{Letter}\p{Number}\p{Mark}_\- ]/gu, '').replace(/ /g, '-')
const anchors = file => {
const source = stripCode(fs.readFileSync(file, 'utf8'))
const used = new Map(), result = new Set()
for (const [, heading] of source.matchAll(/^#{1,6}\s+(.+)$/gm)) {
const name = slug(heading.trim()), count = used.get(name) ?? 0
result.add(count ? `${name}-${count}` : name); used.set(name, count + 1)
}
for (const [, name] of source.matchAll(/\b(?:id|name)=["']([^"']+)["']/g)) result.add(name)
return result
}
const problems = []; let internalLinks = 0
for (const file of files) for (const [, raw] of stripCode(fs.readFileSync(file, 'utf8')).matchAll(/!?\[[^\]\n]*\]\(([^)\n]+)\)/g)) {
const href = raw.trim().replace(/^<|>$/g, '')
if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(href)) continue
internalLinks++
try {
const [target, fragment] = href.split('#')
const resolved = target ? path.resolve(path.dirname(file), decodeURIComponent(target)) : path.resolve(file)
const relative = path.relative(root, resolved).replaceAll('\\', '/')
let reason = relative.startsWith('_local/') || relative === '_local' ? 'local evidence is excluded from Git Clone' : !fs.existsSync(resolved) ? 'missing path' : null
if (!reason && fragment && resolved.endsWith('.md') && !anchors(resolved).has(decodeURIComponent(fragment))) reason = 'missing heading'
if (reason) problems.push({ file, href, reason })
} catch (error) { problems.push({ file, href, reason: error.message }) }
}
console.log(JSON.stringify({ files: files.length, internalLinks, problems }, null, 2))
if (problems.length) process.exitCode = 1
+27
View File
@@ -0,0 +1,27 @@
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
// Includes only repository source and named built artifacts, never node_modules,
// credentials, backups, Git metadata or unrelated local validation output.
const root = process.cwd()
const output = path.resolve(root, '_local/publisher-release/HMDesign-Publisher-2026-10-10')
if (path.dirname(output) !== path.resolve(root, '_local/publisher-release')) throw Error('Unsafe output')
fs.rmSync(output, { recursive: true, force: true })
fs.mkdirSync(output, { recursive: true })
const sources = execFileSync('git', ['ls-files', '-co', '--exclude-standard', '-z'], { encoding: 'utf8' }).split('\0').filter(Boolean)
for (const file of sources) {
if (/^(?:\.claude|\.codex|\.agents|\.git)(?:\/|$)|(?:^|\/)\.env(?:\.|$)|\.(?:zip|log|tsbuildinfo)$/.test(file)) continue
const destination = path.join(output, file)
fs.mkdirSync(path.dirname(destination), { recursive: true })
fs.copyFileSync(path.join(root, file), destination)
}
for (const dir of ['storybook-static', 'dist', 'dist-dashboard', 'dist-recruit', 'packages/ui/dist', 'packages/center-graphic/dist', 'examples/publisher-list/dist']) {
if (!fs.existsSync(dir)) throw Error(`Build required: ${dir}`)
fs.cpSync(dir, path.join(output, dir), { recursive: true })
}
for (const file of ['start-preview.bat', 'preview-server.cjs']) fs.copyFileSync(`scripts/portable-preview/${file}`, path.join(output, file))
fs.writeFileSync(path.join(output, 'START-HERE.md'), '# HMDesign 퍼블리셔 배포\n\nNode.js 20.19+ 또는 22.12+를 설치한 Windows에서 `start-preview.bat`를 실행하세요. 정적 열람에는 npm 설치가 필요하지 않습니다. 출력된 주소의 `/example/`은 실제 React 신규 화면입니다.\n\n소스 수정·새 화면 실행은 [전체 사용자 안내](docs/guidelines/publisher-distribution-guide.md)를 따릅니다. 검증과 제한은 [추가 수정 보고서](docs/guidelines/publisher-additional-review-2026-10-10.md)를 확인하세요.\n')
const manifest = sources.filter(file => fs.existsSync(path.join(output, file)))
fs.writeFileSync(path.join(output, 'DISTRIBUTION.json'), JSON.stringify({ date: '2026-10-10', sourcePaths: manifest, nodeRequirement: 'Node.js 20.19+ or 22.12+', bundledNode: false, npmRequiredForStaticPreview: false }, null, 2))
console.log(output)
+85
View File
@@ -0,0 +1,85 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { styleVerification } from './guard-style-evidence.mjs';
export function sourceFingerprint(target, files) {
const hash = createHash('sha256');
for (const file of [...new Set(files)].sort()) {
hash.update(file.replaceAll('\\', '/')); hash.update('\0');
hash.update(fs.readFileSync(path.resolve(target, file))); hash.update('\0');
}
return hash.digest('hex');
}
// Optional measurements describe styles, never approval of a consumer override.
export function linkCssEvidence(guard, runtime, linked) {
const supplied = runtime.cssEvidence;
if (supplied === undefined) return { status: 'NOT VERIFIED', checks: [], intentReviews: [], reasons: ['CSS measurements were not supplied'] };
if (!linked) return { status: 'NOT VERIFIED', checks: [], intentReviews: [], reasons: ['CSS evidence is stale or unlinked'] };
if (!supplied || typeof supplied !== 'object') return { status: 'NOT VERIFIED', checks: [], intentReviews: [], reasons: ['Invalid CSS evidence object'] };
const reasons = [];
const checks = [];
const intentReviews = [];
if (!Array.isArray(supplied.checks) || !supplied.checks.length) reasons.push('Named CSS measurements are missing');
for (const check of Array.isArray(supplied.checks) ? supplied.checks : []) {
const valid = check && typeof check.name === 'string' && check.name.trim() &&
['COMPUTED_STYLE', 'CSS_VARIABLE'].includes(check.kind) &&
[390, 768, 1280, 1440].includes(check.width) && runtime.viewports?.some(v => v.width === check.width && v.status === 'PASS') &&
typeof check.selector === 'string' && check.selector.trim() && typeof check.property === 'string' && check.property.trim() &&
(check.kind !== 'CSS_VARIABLE' || check.property.startsWith('--')) &&
typeof check.actual === 'string' && check.actual.trim() && typeof check.expected === 'string' && check.expected.trim();
if (!valid) { reasons.push('Incomplete or unsupported CSS measurement'); continue; }
checks.push({ ...check, status: check.actual === check.expected ? 'PASS' : 'FAIL' });
}
for (const review of Array.isArray(supplied.intentReviews) ? supplied.intentReviews : []) {
const issue = guard.issues.find(i => i.code === 'REUSE-003' && i.code === review?.issue?.code && i.file === review.issue.file && i.line === review.issue.line);
const reference = review?.reference;
let matches = false;
// References stay inside the explicit contract source, including real paths.
try {
const root = fs.realpathSync(guard.contractSource);
const file = fs.realpathSync(path.resolve(root, reference.file));
const relative = path.relative(root, file);
matches = relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative) &&
createHash('sha256').update(fs.readFileSync(file)).digest('hex') === reference.sha256;
} catch { /* Missing / stale references remain unverified. */ }
let lineCount = 0;
try { lineCount = fs.readFileSync(path.resolve(guard.contractSource, reference.file), 'utf8').split('\n').length; } catch { /* Invalid reference. */ }
if (!issue || !matches || typeof review.rationale !== 'string' || !review.rationale.trim() || !Number.isInteger(reference?.line) || reference.line < 1 || reference.line > lineCount) {
reasons.push('CSS intent review has an unmatched issue or unavailable / stale reference'); continue;
}
intentReviews.push({ issue: review.issue, rationale: review.rationale, reference, status: 'NOT VERIFIED', basis: 'Documented usage review; design intent is not automatically approved' });
}
return { status: checks.some(c => c.status === 'FAIL') ? 'FAIL' : checks.length && !reasons.length ? 'PASS' : 'NOT VERIFIED', checks, intentReviews, reasons };
}
// Combines independent results without altering the original Guard issues.
export function combineEvidence(guard, runtime) {
const reasons = [];
let fresh = false;
try { fresh = guard.sourceFingerprint === sourceFingerprint(guard.target, guard.fingerprintFiles) && guard.contractFingerprint === sourceFingerprint(guard.contractSource, guard.contractFiles); }
catch { reasons.push('Guard source or contract files are unavailable'); }
const linked = fresh && runtime.sourceFingerprint === guard.sourceFingerprint && runtime.contractFingerprint === guard.contractFingerprint;
if (!linked) reasons.push('Runtime evidence is stale or does not match the Guard source / contract');
const expected = [390, 768, 1280, 1440];
const viewportComplete = expected.every(width => runtime.viewports?.some(v => v.width === width && v.status === 'PASS'));
const interactionComplete = Array.isArray(runtime.interactions) && runtime.interactions.length > 0 && runtime.interactions.every(i => i.status === 'PASS' && typeof i.name === 'string' && i.name.trim());
const cssEvidence = linkCssEvidence(guard, runtime, linked);
const styles = styleVerification(guard, runtime, cssEvidence, linked);
const failed = runtime.viewports?.some(v => v.status === 'FAIL') || runtime.interactions?.some(i => i.status === 'FAIL') || cssEvidence.status === 'FAIL' || styles.runtimeStatus === 'FAIL';
const provenBootstrap = guard.runtimeRequired === true && !guard.issues.some(i => i.code === 'RENDER-001');
if (!provenBootstrap) reasons.push('Final React bootstrap was not proven with --result');
const runtimeStatus = !linked || !provenBootstrap ? 'NOT VERIFIED' : failed ? 'FAIL' : viewportComplete && interactionComplete ? 'PASS' : 'NOT VERIFIED';
if (!viewportComplete) reasons.push('Four required viewport results are missing or incomplete');
if (!interactionComplete) reasons.push('Named core interaction checks are missing or incomplete');
const status = guard.issues.some(i => i.severity === 'FAIL') || guard.staticStatus === 'FAIL' || runtimeStatus === 'FAIL' || styles.finalStatus === 'FAIL' ? 'FAIL' : guard.staticStatus === 'PASS' && styles.staticStatus === 'PASS' && runtimeStatus === 'PASS' && (!guard.styleEvidence || styles.designIntentStatus === 'PASS') ? 'PASS' : 'NOT VERIFIED';
return { schemaVersion: 1, scope: guard.scope, status, staticStatus: guard.staticStatus, rawGuardStatus: guard.status, runtimeStatus, linked, reasons, cssEvidence, styleVerification: styles, unresolvedStaticIssues: guard.issues.filter(i => !i.code.startsWith('RENDER-')), limitations: ['Scope is inherited from Guard; PARTIAL never covers the entire screen', 'Native/CSS intent findings are not cleared by browser evidence', 'Runtime assertions are supplied evidence, not independent attestation', 'CSS measurements cover only the named selector / property / viewport; they do not approve design intent or dynamic token states', 'Screen reader speech, physical touch, backend and all states are NOT VERIFIED'] };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
const [guardFile, runtimeFile] = process.argv.slice(2);
const result = combineEvidence(JSON.parse(fs.readFileSync(guardFile)), JSON.parse(fs.readFileSync(runtimeFile)));
console.log(JSON.stringify(result, null, 2));
if (result.status === 'FAIL') process.exitCode = 1;
} catch (error) { console.error(error.message); process.exitCode = 2; }
}
+146
View File
@@ -0,0 +1,146 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createHash } from 'node:crypto';
import { sourceFingerprint, combineEvidence, linkCssEvidence } from './guard-evidence.mjs';
function evidence(callback) {
const parent = fs.realpathSync(os.tmpdir());
const dir = fs.mkdtempSync(path.join(parent, 'hm-evidence-'));
try {
fs.writeFileSync(path.join(dir, 'main.tsx'), 'original');
fs.writeFileSync(path.join(dir, 'contract.ts'), 'public contract');
const guard = { scope: 'FULL_SCREEN', runtimeRequired: true, target: dir, contractSource: dir, fingerprintFiles: ['main.tsx'], contractFiles: ['contract.ts'], sourceFingerprint: sourceFingerprint(dir, ['main.tsx']), contractFingerprint: sourceFingerprint(dir, ['contract.ts']), staticStatus: 'PASS', status: 'NOT VERIFIED', issues: [{ code: 'RENDER-002', severity: 'NOT VERIFIED' }] };
const runtime = { sourceFingerprint: guard.sourceFingerprint, contractFingerprint: guard.contractFingerprint, viewports: [390, 768, 1280, 1440].map(width => ({ width, status: 'PASS' })), interactions: [{ name: 'Submit', status: 'PASS' }] };
callback(guard, runtime, dir);
} finally { assert.equal(path.dirname(dir), parent); assert.ok(path.basename(dir).startsWith('hm-evidence-')); fs.rmSync(dir, { recursive: true, force: true }); }
}
test('matching independent browser evidence leaves original Guard untouched', () => evidence((g, r) => {
const before = JSON.stringify(g); assert.equal(combineEvidence(g, r).status, 'PASS'); assert.equal(JSON.stringify(g), before);
}));
test('changed source and changed public contract invalidate previous browser evidence', () => evidence((g, r, dir) => {
fs.writeFileSync(path.join(dir, 'main.tsx'), 'modified'); assert.equal(combineEvidence(g, r).status, 'NOT VERIFIED');
fs.writeFileSync(path.join(dir, 'main.tsx'), 'original'); fs.writeFileSync(path.join(dir, 'contract.ts'), 'modified'); assert.equal(combineEvidence(g, r).status, 'NOT VERIFIED');
}));
test('missing viewport or interaction cannot claim runtime PASS', () => evidence((g, r) => {
assert.equal(combineEvidence(g, { ...r, viewports: r.viewports.slice(1) }).status, 'NOT VERIFIED');
assert.equal(combineEvidence(g, { ...r, interactions: [] }).status, 'NOT VERIFIED');
}));
test('browser evidence cannot clear static FAIL or CSS intent uncertainty', () => evidence((g, r) => {
assert.equal(combineEvidence({ ...g, staticStatus: 'FAIL', issues: [{ code: 'TOKEN-001' }] }, r).status, 'FAIL');
assert.equal(combineEvidence({ ...g, staticStatus: 'NOT VERIFIED', issues: [{ code: 'REUSE-003' }] }, r).status, 'NOT VERIFIED');
assert.equal(combineEvidence(g, { ...r, interactions: [{ status: 'FAIL' }] }).status, 'FAIL');
}));
test('unproven React mount cannot become PASS and PARTIAL coverage stays PARTIAL', () => evidence((g, r) => {
assert.equal(combineEvidence({ ...g, issues: [{ code: 'RENDER-001' }] }, r).status, 'NOT VERIFIED');
assert.equal(combineEvidence({ ...g, runtimeRequired: false }, r).status, 'NOT VERIFIED');
assert.equal(combineEvidence({ ...g, scope: 'PARTIAL' }, r).scope, 'PARTIAL');
}));
const cssCheck = { name: 'Badge text', kind: 'COMPUTED_STYLE', width: 390, selector: '[data-slot="badge"]', property: 'color', actual: 'rgb(255, 255, 255)', expected: 'rgb(255, 255, 255)' };
const styleSite = { id: 'main.tsx:1:color', ruleId: 'STYLE-002', file: 'main.tsx', line: 1, component: 'Badge', selector: '[data-slot="badge"]', property: 'color', staticStatus: 'PASS', designIntentStatus: 'NOT VERIFIED' };
function siteChecks(site = styleSite) { return [390, 768, 1280, 1440].map(width => ({ ...cssCheck, width, height: 900, siteId: site.id, ruleId: site.ruleId, file: site.file, component: 'Badge', state: 'default', browser: { name: 'Chrome', version: 'test' } })); }
test('site-specific CSS has independent STATIC RUNTIME DESIGN_INTENT FINAL verdicts', () => evidence((g, r) => {
g.styleEvidence = { checks: [styleSite] };
const result = combineEvidence(g, { ...r, cssEvidence: { checks: siteChecks() } });
assert.equal(result.styleVerification.staticStatus, 'PASS');
assert.equal(result.styleVerification.runtimeStatus, 'PASS');
assert.equal(result.styleVerification.designIntentStatus, 'NOT VERIFIED');
assert.equal(result.status, 'NOT VERIFIED');
}));
test('wrong sites, source, rule, browser and state cannot cover CSS uncertainty', () => evidence((g, r) => {
g.styleEvidence = { checks: [styleSite] };
for (const change of [{ siteId: 'other' }, { file: 'other.tsx' }, { ruleId: 'OTHER' }, { browser: null }, { state: '' }, { property: 'backgroundColor' }]) {
const result = combineEvidence(g, { ...r, cssEvidence: { checks: siteChecks().map(c => ({ ...c, ...change })) } });
assert.equal(result.styleVerification.runtimeStatus, 'NOT VERIFIED');
}
}));
test('observed dynamic Token validates declarations but never approves unobserved branches', () => evidence((g, r) => {
const site = { id: 'dynamic:1', file: 'main.tsx', line: 1, ruleId: 'TOKEN-002', status: 'NOT VERIFIED' };
g.tokenEvidence = [{ definitions: ['--hm-valid'], dynamic: [site] }];
g.staticStatus = 'NOT VERIFIED';
const checks = siteChecks(site).map(c => ({ ...c, actualExpression: 'var(--hm-valid)', tokens: ['--hm-valid'] }));
const result = combineEvidence(g, { ...r, cssEvidence: { checks } });
assert.equal(result.styleVerification.checks[0].runtimeStatus, 'PASS');
assert.equal(result.styleVerification.checks[0].finalStatus, 'NOT VERIFIED');
assert.equal(result.status, 'NOT VERIFIED');
}));
test('undefined dynamic Token is a runtime FAIL despite computed fallback equality', () => evidence((g, r) => {
const site = { id: 'dynamic:1', file: 'main.tsx', line: 1, ruleId: 'TOKEN-002', status: 'NOT VERIFIED' };
g.tokenEvidence = [{ definitions: ['--hm-valid'], dynamic: [site] }];
const checks = siteChecks(site).map(c => ({ ...c, status: 'PASS', actualExpression: 'var(--hm-missing, #fff)', tokens: ['--hm-missing'] }));
assert.equal(combineEvidence(g, { ...r, cssEvidence: { checks } }).status, 'FAIL');
}));
test('unknown dynamic expressions, incomplete declarations and missing width stay unverified', () => evidence((g, r) => {
const site = { id: 'dynamic:1', file: 'main.tsx', line: 1, ruleId: 'TOKEN-002', status: 'NOT VERIFIED' };
g.tokenEvidence = [{ definitions: ['--hm-valid'], dynamic: [site] }];
for (const checks of [siteChecks(site), siteChecks(site).map(c => ({ ...c, actualExpression: 'var(--hm-valid)', tokens: [] })), siteChecks(site).slice(1)]) {
assert.equal(combineEvidence(g, { ...r, cssEvidence: { checks } }).styleVerification.runtimeStatus, 'NOT VERIFIED');
}
}));
test('static issue FAIL dominates inconsistent summary PASS and runtime PASS', () => evidence((g, r) => {
g.issues.push({ severity: 'FAIL', code: 'TOKEN-001' });
assert.equal(combineEvidence(g, r).status, 'FAIL');
}));
test('document references with impossible line numbers are not accepted', () => evidence((g, r, dir) => {
g.issues.push({ code: 'REUSE-003', file: 'main.tsx', line: 1 });
const reference = { file: 'contract.ts', line: 1000, sha256: createHash('sha256').update(fs.readFileSync(path.join(dir, 'contract.ts'))).digest('hex') };
const result = combineEvidence(g, { ...r, cssEvidence: { checks: [cssCheck], intentReviews: [{ issue: { code: 'REUSE-003', file: 'main.tsx', line: 1 }, rationale: 'Recipe', reference }] } });
assert.equal(result.cssEvidence.intentReviews.length, 0);
}));
test('unnamed interactions and duplicate CSS names cannot borrow a successful result', () => evidence((g, r) => {
assert.equal(combineEvidence(g, { ...r, interactions: [{ status: 'PASS' }] }).status, 'NOT VERIFIED');
g.styleEvidence = { checks: [styleSite] };
const checks = siteChecks();
checks.push({ ...checks[0], actual: 'rgb(0, 0, 0)', status: 'PASS' });
assert.equal(combineEvidence(g, { ...r, cssEvidence: { checks } }).styleVerification.runtimeStatus, 'FAIL');
}));
test('CSS evidence must address the source selector and Component; static uncertainty dominates', () => evidence((g, r) => {
g.styleEvidence = { checks: [styleSite] };
for (const change of [{ selector: 'body' }, { component: 'Button' }]) assert.equal(combineEvidence(g, { ...r, cssEvidence: { checks: siteChecks().map(c => ({ ...c, ...change })) } }).styleVerification.runtimeStatus, 'NOT VERIFIED');
g.issues.push({ code: 'CSS-005', severity: 'NOT VERIFIED' });
assert.equal(combineEvidence(g, r).status, 'NOT VERIFIED');
}));
test('CSS measurements and exact documentation references never approve intent or mutate raw issues', () => evidence((g, r, dir) => {
g.staticStatus = 'NOT VERIFIED'; g.issues.push({ code: 'REUSE-003', file: 'main.tsx', line: 1 });
const before = JSON.stringify(g);
const review = { issue: { code: 'REUSE-003', file: 'main.tsx', line: 1 }, rationale: 'Documented composition', reference: { file: 'contract.ts', line: 1, sha256: createHash('sha256').update(fs.readFileSync(path.join(dir, 'contract.ts'))).digest('hex') } };
const result = combineEvidence(g, { ...r, cssEvidence: { checks: [cssCheck], intentReviews: [review] } });
assert.equal(result.cssEvidence.status, 'PASS'); assert.equal(result.runtimeStatus, 'PASS'); assert.equal(result.status, 'NOT VERIFIED');
assert.equal(result.cssEvidence.intentReviews[0].status, 'NOT VERIFIED'); assert.equal(JSON.stringify(g), before);
}));
test('CSS mismatch remains a runtime FAIL even when a supplied status claims PASS', () => evidence((g, r) => {
const result = combineEvidence(g, { ...r, cssEvidence: { checks: [{ ...cssCheck, actual: 'rgb(0, 0, 0)', status: 'PASS' }] } });
assert.equal(result.cssEvidence.status, 'FAIL'); assert.equal(result.runtimeStatus, 'FAIL'); assert.equal(result.status, 'FAIL');
}));
test('missing, dynamic or invalid style measurements cannot claim CSS verification', () => evidence((g, r) => {
for (const check of [{ ...cssCheck, actual: '' }, { ...cssCheck, kind: 'DESIGN_INTENT' }, { ...cssCheck, width: 320 }, { ...cssCheck, kind: 'CSS_VARIABLE', property: 'color' }]) {
assert.equal(combineEvidence(g, { ...r, cssEvidence: { checks: [check] } }).cssEvidence.status, 'NOT VERIFIED');
}
assert.equal(combineEvidence(g, { ...r, cssEvidence: null }).cssEvidence.status, 'NOT VERIFIED');
assert.equal(combineEvidence(g, r).cssEvidence.status, 'NOT VERIFIED');
}));
test('changed source rejects supplied CSS checks and stale references do not become intent evidence', () => evidence((g, r, dir) => {
const runtime = { ...r, cssEvidence: { checks: [cssCheck], intentReviews: [{ issue: { code: 'REUSE-003', file: 'main.tsx', line: 1 }, rationale: 'Example', reference: { file: 'contract.ts', line: 1, sha256: 'stale' } }] } };
assert.equal(combineEvidence(g, runtime).cssEvidence.intentReviews.length, 0);
assert.equal(combineEvidence(g, runtime).cssEvidence.status, 'NOT VERIFIED');
fs.writeFileSync(path.join(dir, 'main.tsx'), 'changed');
const result = combineEvidence(g, runtime); assert.equal(result.cssEvidence.checks.length, 0); assert.equal(result.runtimeStatus, 'NOT VERIFIED');
}));
test('resolved CSS variables are measured values and static violations still dominate', () => evidence((g, r) => {
const runtime = { ...r, cssEvidence: { checks: [{ ...cssCheck, kind: 'CSS_VARIABLE', property: '--hm-badge-outline-text', actual: '#fff', expected: '#fff' }] } };
assert.equal(combineEvidence(g, runtime).cssEvidence.status, 'PASS');
assert.equal(combineEvidence({ ...g, staticStatus: 'FAIL', issues: [{ code: 'TOKEN-001' }] }, runtime).status, 'FAIL');
}));
test('CSS review cannot link an outside path even with a correct hash; missing references stay unverified', () => evidence((g, r, dir) => {
fs.mkdirSync(path.join(dir, 'references'));
g.contractSource = path.join(dir, 'references');
g.issues.push({ code: 'REUSE-003', file: 'main.tsx', line: 1 });
const sha256 = createHash('sha256').update(fs.readFileSync(path.join(dir, 'main.tsx'))).digest('hex');
for (const reference of [{ file: '../main.tsx', line: 1, sha256 }, { file: path.join(dir, 'main.tsx'), line: 1, sha256 }, { file: 'missing.md', line: 1, sha256 }]) {
const result = linkCssEvidence(g, { ...r, cssEvidence: { checks: [cssCheck], intentReviews: [{ issue: { code: 'REUSE-003', file: 'main.tsx', line: 1 }, rationale: 'Recipe', reference }] } }, true);
assert.equal(result.status, 'NOT VERIFIED'); assert.equal(result.intentReviews.length, 0);
}
}));
+230
View File
@@ -0,0 +1,230 @@
// Conservative render-value analysis. Never executes consumer code.
// Only immutable local values, direct component calls and literal conditions
// prove a branch absent. Unsupported flows retain NOT VERIFIED navigation.
export function navigationAnalysis(records, roots, origin, walk) {
const unknown = Symbol('unknown');
const state = new Map();
const sidebarValues = new Map();
const identities = new Map();
const modules = new Map(records.filter(r => r.ast).map(r => [r.file, r]));
const active = new Set();
const rank = { dead: 0, maybe: 1, active: 2 };
const mark = (node, value) => { if (!state.has(node) || rank[value] > rank[state.get(node)]) state.set(node, value); };
const fallback = (node, certainty = 'maybe') => walk(node, n => { if (n.type === 'JSXOpeningElement' || n.type === 'CallExpression' && /createElement|^(?:_?jsx|_?jsxs|jsxDEV)$/.test(n.callee?.name ?? n.callee?.property?.name ?? '')) mark(n, certainty); });
const unwrap = n => ['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ParenthesizedExpression'].includes(n?.type) ? unwrap(n.expression) : n;
function uncertain(node, env, seen = new Set()) {
if (!node || seen.has(node)) return;
seen.add(node); fallback(node);
walk(node, x => {
if (x.type !== 'Identifier') return;
const b = env.get(x.name);
if (b?.fn) uncertain(b.fn, b.env, seen);
if (b?.expr) uncertain(b.expr, b.env, seen);
});
}
function invalidate(body, env) {
const taint = (name, seen = new Set()) => {
if (seen.has(name)) return;
seen.add(name);
const previous = env.get(name);
if (previous?.expr) {
uncertain(previous.expr, previous.env);
if (previous.expr.type === 'Identifier') taint(previous.expr.name, seen);
}
env.set(name, unknown);
};
const objectBinding = (name, seen = new Set()) => {
if (seen.has(name)) return false;
seen.add(name); const b = env.get(name);
return b?.expr?.type === 'ObjectExpression' || b?.expr?.type === 'Identifier' && objectBinding(b.expr.name, seen) || b && typeof b === 'object' && !b.expr && !b.fn;
};
walk({ type: 'BlockStatement', body }, n => {
if (n.type === 'AssignmentExpression' || n.type === 'UpdateExpression') {
let lhs = n.left ?? n.argument;
while (lhs?.type === 'MemberExpression') lhs = lhs.object;
if (lhs?.type === 'Identifier') taint(lhs.name);
}
// An opaque call can mutate object/props arguments. This may over-report
// uncertainty, but must never prove a dead branch from a stale value.
if (n.type === 'CallExpression') for (const arg of n.arguments) if (arg.type === 'Identifier' && env.has(arg.name)) {
if (objectBinding(arg.name)) taint(arg.name);
}
});
}
function environment(r, body, parent = new Map()) {
const env = new Map(parent);
for (let stmt of body) {
stmt = stmt.declaration ?? stmt;
if (stmt.type === 'FunctionDeclaration' && stmt.id) env.set(stmt.id.name, { fn: stmt, r, env });
if (stmt.type === 'VariableDeclaration') for (const d of stmt.declarations) {
if (d.id.type === 'Identifier') env.set(d.id.name, { expr: d.init, r, env, mutable: stmt.kind !== 'const' });
else { fallback(d.init); for (const id of d.id.elements ?? []) if (id?.type === 'Identifier') env.set(id.name, unknown); }
}
}
// Even const objects / parameters can be mutated. Do not infer through writes.
invalidate(body, env);
return env;
}
for (const r of modules.values()) r.navEnv = environment(r, r.ast.program.body);
function exported(r, name, seen = new Set()) {
if (!r || seen.has(`${r.file}:${name}`)) return unknown;
seen.add(`${r.file}:${name}`);
const b = r.exports.get(name);
if (b?.edge) return exported(modules.get(b.edge.resolved), b.imported, seen);
if (b) {
if (b.local === 'default') {
const d = r.ast.program.body.find(n => n.type === 'ExportDefaultDeclaration')?.declaration;
return value(d, r, r.navEnv);
}
return value({ type: 'Identifier', name: b.local }, r, r.navEnv);
}
for (const e of r.stars) { const found = exported(modules.get(e.resolved), name, seen); if (found !== unknown) return found; }
return unknown;
}
const evaluating = new Set();
function value(raw, r, env) {
const n = unwrap(raw);
if (!n) return undefined;
if (['StringLiteral', 'NumericLiteral', 'BooleanLiteral'].includes(n.type)) return n.value;
if (n.type === 'NullLiteral') return null;
if (n.type === 'TemplateLiteral' && !n.expressions.length) return n.quasis[0].value.cooked;
if (n.type === 'Identifier') {
if (env.has(n.name)) {
const v = env.get(n.name);
if (!v?.expr) return v;
if (v.mutable || evaluating.has(v)) { uncertain(v.expr, v.env); return unknown; }
evaluating.add(v); const result = value(v.expr, v.r, v.env); evaluating.delete(v); return result;
}
if (n.name === 'undefined') return undefined;
const official = origin(r.file, n.name);
if (official?.pkg) return { official };
const b = r.bindings.get(n.name);
if (b?.edge) return exported(modules.get(b.edge.resolved), b.imported);
return unknown;
}
if (['ArrowFunctionExpression', 'FunctionExpression', 'FunctionDeclaration'].includes(n.type)) return { fn: n, r, env };
if (n.type === 'JSXElement' || n.type === 'JSXFragment') return { jsx: n, r, env };
if (n.type === 'ObjectExpression') {
const obj = Object.create(null);
for (const p of n.properties) { if (p.type !== 'ObjectProperty' || p.computed) return unknown; obj[p.key.name ?? p.key.value] = value(p.value, r, env); }
return obj;
}
if (n.type === 'MemberExpression') {
if (!n.computed && n.object.type === 'Identifier') {
const official = origin(r.file, `${n.object.name}.${n.property.name}`);
if (official?.pkg && !env.has(n.object.name)) return { official };
}
const obj = value(n.object, r, env); const key = n.computed ? value(n.property, r, env) : n.property.name;
if (obj?.fn || obj?.official || obj?.jsx || obj?.branches) return unknown;
return obj !== unknown && obj != null && key !== unknown ? obj[key] : unknown;
}
if (n.type === 'UnaryExpression' && n.operator === '!') { const v = value(n.argument, r, env); return v === unknown || v?.branches ? unknown : !v; }
if (n.type === 'BinaryExpression' && ['===', '!==', '==', '!='].includes(n.operator)) {
const a = value(n.left, r, env), b = value(n.right, r, env);
if (a === unknown || b === unknown || a !== null && ['object', 'function'].includes(typeof a) || b !== null && ['object', 'function'].includes(typeof b)) return unknown;
return n.operator === '===' ? a === b : n.operator === '!==' ? a !== b : n.operator === '==' ? a == b : a != b;
}
if (n.type === 'ConditionalExpression') { const test = value(n.test, r, env); return test === unknown || test?.branches ? { branches: [n.consequent, n.alternate], r, env } : value(test ? n.consequent : n.alternate, r, env); }
if (n.type === 'LogicalExpression') {
const a = value(n.left, r, env);
if (a === unknown || a?.branches) return { branches: [n.left, n.right], r, env };
return n.operator === '&&' ? a && value(n.right, r, env) : n.operator === '||' ? a || value(n.right, r, env) : a ?? value(n.right, r, env);
}
if (n.type === 'ArrayExpression') return { branches: n.elements, r, env, definite: true };
uncertain(n, env);
return unknown;
}
function bind(pattern, v, r, env) {
if (!pattern) return;
if (pattern.type === 'Identifier') env.set(pattern.name, v);
else if (pattern.type === 'AssignmentPattern') bind(pattern.left, v === undefined ? value(pattern.right, r, env) : v, r, env);
else if (pattern.type === 'ObjectPattern') for (const p of pattern.properties) {
if (p.type === 'ObjectProperty' && !p.computed) bind(p.value, v === unknown || v == null ? unknown : v[p.key.name ?? p.key.value], r, env);
else if (p.argument) bind(p.argument, unknown, r, env);
}
}
function invoke(v, props, certainty) {
if (!v?.fn) return;
const { fn, r } = v;
if (active.has(fn)) { fallback(fn); return; }
active.add(fn);
// Unused JSX initializers are constructed values, not mounted elements.
fallback(fn, 'dead');
const env = environment(r, fn.body.body ?? [], v.env);
fn.params.forEach((p, i) => bind(p, i === 0 ? props : unknown, r, env));
invalidate(fn.body.body ?? [], env);
if (fn.body.type !== 'BlockStatement') render(fn.body, r, env, certainty);
else statements(fn.body.body, r, env, certainty);
active.delete(fn);
}
function statements(body, r, env, certainty) {
for (const n of body) {
if (n.type === 'ReturnStatement') { render(n.argument, r, env, certainty); return; }
if (n.type === 'IfStatement') {
const t = value(n.test, r, env);
const unproven = t === unknown || t?.branches;
const branches = unproven ? [n.consequent, n.alternate] : [t ? n.consequent : n.alternate];
for (const branch of branches.filter(Boolean)) statements(branch.type === 'BlockStatement' ? branch.body : [branch], r, environment(r, branch.type === 'BlockStatement' ? branch.body : [branch], env), unproven ? 'maybe' : certainty);
// Early-return control flow requires a full CFG; retain uncertainty after it.
if (branches.some(b => { let found = false; walk(b, x => { if (x.type === 'ReturnStatement') found = true; }); return found; })) certainty = 'maybe';
} else if (!['VariableDeclaration', 'FunctionDeclaration', 'ExpressionStatement', 'EmptyStatement', 'TSTypeAliasDeclaration', 'TSInterfaceDeclaration'].includes(n.type)) fallback(n);
// Effects / callbacks can mount values outside the direct JSX return graph.
if (n.type === 'ExpressionStatement') uncertain(n, env);
}
}
function consume(v, certainty) {
if (v?.jsx) render(v.jsx, v.r, v.env, certainty);
else if (v?.branches) for (const b of v.branches) render(b, v.r, v.env, v.definite ? certainty : 'maybe');
else if (v?.fn) fallback(v.fn);
else if (v && typeof v === 'object') Object.values(v).forEach(item => consume(item, 'maybe'));
}
function render(raw, r, env, certainty = 'active') {
const n = unwrap(raw);
if (!n) return;
if (n.type === 'JSXExpressionContainer') { render(n.expression, r, env, certainty); return; }
if (n.type === 'JSXFragment') { n.children.forEach(c => render(c, r, env, certainty)); return; }
if (n.type === 'JSXElement') {
const o = n.openingElement; mark(o, certainty);
const jsxName = name => name.type === 'JSXIdentifier' ? name.name : `${jsxName(name.object)}.${name.property.name}`;
const name = jsxName(o.name);
const component = name.includes('.') ? value({type:'MemberExpression',object:{type:'Identifier',name:o.name.object.name},property:{type:'Identifier',name:o.name.property.name},computed:false}, r, env) : value({ type: 'Identifier', name }, r, env);
const id = component?.official ?? origin(r.file, name);
if (component?.official) {
if (!identities.has(o)) identities.set(o, []);
if (!identities.get(o).some(x => x.name === id.name && x.pkg === id.pkg)) identities.get(o).push(id);
}
const props = {};
let spread = false;
for (const a of o.attributes) {
if (a.type === 'JSXSpreadAttribute') { spread = true; uncertain(a.argument, env); continue; }
props[a.name.name] = a.value == null ? true : value(a.value.type === 'JSXExpressionContainer' ? a.value.expression : a.value, r, env);
}
if (name === 'DashboardLayout' || id?.name === 'DashboardLayout') {
const candidate = spread ? 'unknown' : !('sidebar' in props) || [undefined, null, false].includes(props.sidebar) ? 'empty' : props.sidebar === unknown || props.sidebar?.branches ? 'unknown' : 'present';
const sv = certainty === 'maybe' && candidate === 'present' ? 'unknown' : candidate;
const old = sidebarValues.get(o);
sidebarValues.set(o, old === 'present' || sv === 'present' ? 'present' : old === 'unknown' || sv === 'unknown' ? 'unknown' : 'empty');
}
if (component?.fn) {
if (n.children.some(c => c.type !== 'JSXText')) props.children = { jsx: { type: 'JSXFragment', children: n.children }, r, env };
invoke(component, spread ? unknown : props, certainty);
} else {
// Official and external components may forward render-valued props/children.
Object.values(props).forEach(v => consume(v, certainty));
n.children.forEach(c => render(c, r, env, certainty));
}
return;
}
const v = value(n, r, env);
if (v === unknown) uncertain(n, env);
else consume(v, certainty);
}
let mounted = false;
for (const r of modules.values()) walk(r.ast, n => {
if (n.type === 'CallExpression' && (n.callee.type === 'MemberExpression' && n.callee.property.name === 'render' || n.callee.name === 'hydrateRoot')) {
mounted = true; render(n.callee.name === 'hydrateRoot' ? n.arguments[1] : n.arguments[0], r, r.navEnv);
}
});
if (!mounted) for (const file of roots) invoke(exported(modules.get(file), 'default'), {}, 'active');
return { state, sidebarValues, identities };
}
+74
View File
@@ -0,0 +1,74 @@
// Optional action review, separate from the original static Guard verdict.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from '@babel/parser';
import { sourceFingerprint } from './guard-evidence.mjs';
const text = node => {
if (node?.type === 'StringLiteral') return node.value;
if (node?.type === 'JSXText') return node.value;
if (node?.type === 'JSXExpressionContainer') return text(node.expression);
return null;
};
const 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', 'extra'].includes(key)) continue;
if (Array.isArray(value)) value.forEach(item => walk(item, fn));
else if (value && typeof value === 'object') walk(value, fn);
}
};
const normalize = label => label.replace(/\s+/g, ' ').trim();
export function reviewActions(guard, requirements) {
const findings = [], actions = [];
const allowed = requirements?.actionPolicy?.allowedLabels;
if (requirements?.schemaVersion !== 1 || !Array.isArray(allowed) || !allowed.every(label => typeof label === 'string')) {
return { status: 'NOT VERIFIED', rawGuardStatus: guard.status, findings: [{ code: 'REQ-001', reason: 'Explicit schemaVersion:1 actionPolicy.allowedLabels is required' }], actions };
}
const accepted = new Set(allowed.map(normalize));
try {
if (sourceFingerprint(guard.target, guard.fingerprintFiles) !== guard.sourceFingerprint || sourceFingerprint(guard.contractSource, guard.contractFiles) !== guard.contractFingerprint) throw new Error('Guard source / contract changed');
for (const file of guard.checkedFiles.filter(name => /\.[cm]?[jt]sx?$/.test(name))) {
const ast = parse(fs.readFileSync(path.resolve(guard.target, file), 'utf8'), { sourceType: 'unambiguous', plugins: ['typescript', 'jsx'] });
const bindings = new Set(['button', 'a']);
for (const statement of ast.program.body) if (statement.type === 'ImportDeclaration' && statement.source.value === '@hm-design/ui') {
for (const specifier of statement.specifiers) if (specifier.imported?.name === 'Button') bindings.add(specifier.local.name);
}
walk(ast, node => {
if (node.type !== 'JSXElement') return;
const opening = node.openingElement;
if (opening.name.type !== 'JSXIdentifier' || !bindings.has(opening.name.name)) return;
const aria = opening.attributes.find(attr => attr.type === 'JSXAttribute' && attr.name.name === 'aria-label');
let label = aria ? text(aria.value) : '';
let dynamic = false;
if (!aria) for (const child of node.children) {
if (child.type === 'JSXElement') { dynamic = true; continue; }
const value = text(child);
if (value === null) dynamic = true;
else label += value;
}
if (opening.attributes.some(attr => attr.type === 'JSXSpreadAttribute')) dynamic = true;
label = typeof label === 'string' ? normalize(label) : '';
const action = { file, line: node.loc.start.line, label, dynamic };
actions.push(action);
if (dynamic || !label) findings.push({ ...action, code: 'REQ-002', reason: 'Dynamic or composite action name needs runtime review' });
else if (!accepted.has(label)) findings.push({ ...action, code: 'REQ-003', reason: 'Action absent from the explicit requirements allowlist' });
});
}
} catch (error) { findings.push({ code: 'REQ-001', reason: error.message }); }
// No blanket HTML prohibition; supplied policy is explicit, never inferred from prose.
const status = findings.some(item => item.code === 'REQ-003') ? 'FAIL' : findings.length || !actions.length || guard.scope !== 'FULL_SCREEN' ? 'NOT VERIFIED' : 'PASS';
return { schemaVersion: 1, status, rawGuardStatus: guard.status, scope: guard.scope, actions, findings, limitations: ['Static literal Button/button/link names only; wrappers, namespace calls, dynamic labels, APIs and all runtime states require review', 'Does not change original Guard issues, status, runtime evidence or design scores'] };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
const [guardFile, requirementsFile] = process.argv.slice(2);
const result = reviewActions(JSON.parse(fs.readFileSync(guardFile, 'utf8')), JSON.parse(fs.readFileSync(requirementsFile, 'utf8')));
console.log(JSON.stringify(result, null, 2));
if (result.status === 'FAIL') process.exitCode = 1;
} catch (error) { console.log(JSON.stringify({ status: 'NOT VERIFIED', error: error.message })); process.exitCode = 2; }
}
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { sourceFingerprint } from './guard-evidence.mjs';
import { reviewActions } from './guard-requirements.mjs';
function fixture(source, fn) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-action-review-'));
try {
fs.writeFileSync(path.join(dir, 'App.tsx'), source);
const guard = { status: 'NOT VERIFIED', scope: 'FULL_SCREEN', target: dir, contractSource: dir, fingerprintFiles: ['App.tsx'], contractFiles: ['App.tsx'], checkedFiles: ['App.tsx'], issues: [{ code: 'RENDER-002' }] };
guard.sourceFingerprint = guard.contractFingerprint = sourceFingerprint(dir, ['App.tsx']);
fn(guard, { schemaVersion: 1, actionPolicy: { allowedLabels: ['조회', '초기화'] } });
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
test('explicit authorized actions pass without changing raw Guard', () => fixture('import {Button as Action} from "@hm-design/ui"; const App=()=> <><Action>조회</Action><button aria-label="초기화"/></>;', (g, r) => {
const original = JSON.stringify(g); assert.equal(reviewActions(g, r).status, 'PASS'); assert.equal(JSON.stringify(g), original);
}));
test('unrequested CSV and download links fail independently', () => fixture('const App=()=> <><button>CSV 다운로드</button><a download href="local.csv">내보내기</a></>;', (g, r) => {
const result = reviewActions(g, r); assert.equal(result.status, 'FAIL'); assert.equal(result.findings.filter(f => f.code === 'REQ-003').length, 2);
}));
test('dynamic action names and spread props remain unverified', () => fixture('const App=()=> <><button>{caption}</button><button {...props}>조회</button></>;', (g, r) => assert.equal(reviewActions(g, r).status, 'NOT VERIFIED')));
test('stale source, absent policy and partial coverage cannot pass', () => fixture('const App=()=> <button>조회</button>;', (g, r) => {
assert.equal(reviewActions(g, {}).status, 'NOT VERIFIED'); assert.equal(reviewActions({ ...g, scope: 'PARTIAL' }, r).status, 'NOT VERIFIED');
fs.appendFileSync(path.join(g.target, 'App.tsx'), '\n// changed'); assert.equal(reviewActions(g, r).status, 'NOT VERIFIED');
}));
test('layout HTML is permitted; no observed actions is not proof of compliance', () => fixture('const App=()=> <section><p>CSV guide</p></section>;', (g, r) => assert.equal(reviewActions(g, r).status, 'NOT VERIFIED')));
+138
View File
@@ -0,0 +1,138 @@
import fs from 'node:fs';
import path from 'node:path';
import { staticStrings, localConstants } from './guard-theme-analysis.mjs';
export const verdict = values => values.includes('FAIL') ? 'FAIL' : values.includes('NOT VERIFIED') ? 'NOT VERIFIED' : 'PASS';
const lineOf = n => n?.loc?.start?.line ?? n?.source?.start?.line ?? 1;
const rawColor = /#[\da-f]{3,8}\b|\b(?:rgb|hsl)a?\(/i;
const geometry = /^(?:padding|margin|gap|row-gap|column-gap|border-(?:(?:top|bottom)-(?:left|right)-)?radius)(?:-|$)/;
function withoutVariables(value) {
let result = '';
for (let i = 0; i < value.length; i++) {
if (value.slice(i, i + 4) !== 'var(') { result += value[i]; continue; }
let depth = 1;
i += 3;
while (++i < value.length && depth) { if (value[i] === '(') depth++; else if (value[i] === ')') depth--; }
i--;
}
return result;
}
function knownStyleStrings(node, constants, seen = new Set()) {
const finite = staticStrings(node, constants);
if (finite) return finite;
if (node?.type === 'ConditionalExpression') return [...knownStyleStrings(node.consequent, constants, seen), ...knownStyleStrings(node.alternate, constants, seen)];
if (node?.type === 'TemplateLiteral') return node.quasis.map(q => q.value.cooked ?? q.value.raw);
if (node?.type === 'Identifier' && constants.has(node.name) && !seen.has(node.name)) return knownStyleStrings(constants.get(node.name), constants, new Set([...seen, node.name]));
return [];
}
// A class prefix is not a public Component identity. Only explicit consumer
// class bindings prove it. A simple positive ancestor absent from the complete
// entry can bound a rule; dynamic classes/spreads/DOM writes retain strict checks.
function classScope(records, entries, walk, origin, incomplete, implementationFiles) {
const classes = new Set(), componentClasses = new Map();
let uncertain = incomplete;
let implementationText = '';
try { implementationText = implementationFiles.map(f => fs.readFileSync(f, 'utf8')).join('\n'); } catch { uncertain = true; }
const memberName = n => n?.computed ? n.property?.value : n?.property?.name;
const collect = (values, component) => {
if (!values) { uncertain = true; return; }
for (const value of values) for (const name of value.split(/\s+/).filter(Boolean)) {
classes.add(name);
if (component) componentClasses.set(name, component);
}
};
for (const r of records.filter(r => r.ast)) {
const constants = localConstants(r.ast, walk);
for (const render of r.renders) {
const identity = origin(r.file, render.name);
for (const key of ['className', 'class']) if (render.attrs.has(key)) collect(staticStrings(render.attrs.get(key), constants), identity?.pkg === '@hm-design/ui' ? identity.name : null);
}
walk(r.ast, n => {
if (n.type === 'JSXSpreadAttribute' || n.type === 'JSXAttribute' && n.name.name === 'dangerouslySetInnerHTML') uncertain = true;
if (n.type === 'CallExpression') {
const method = memberName(n.callee);
if (['setAttribute', 'setAttributeNS', 'insertAdjacentHTML', 'write', 'writeln'].includes(method) || memberName(n.callee?.object) === 'classList') uncertain = true;
if (/createElement|^(?:_?jsx|_?jsxs|jsxDEV)$/.test(n.callee?.name ?? method ?? '') && n.arguments[1] && (n.arguments[1].type !== 'ObjectExpression' || n.arguments[1].properties.some(p => p.type === 'SpreadElement'))) uncertain = true;
}
if (n.type === 'AssignmentExpression' && ['className', 'innerHTML', 'outerHTML'].includes(memberName(n.left))) uncertain = true;
});
}
for (const file of entries.filter(f => f.endsWith('.html'))) {
try {
const markup = fs.readFileSync(file, 'utf8').replace(/<!--[\s\S]*?-->/g, '').replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '');
for (const [, value] of markup.matchAll(/\bclass\s*=\s*["']([^"']*)["']/g)) collect([value]);
if (/\bclass\s*=\s*[^"'\s]/.test(markup)) uncertain = true;
} catch { uncertain = true; }
}
return {
component(name) { return componentClasses.get(name) ?? (uncertain && name.startsWith('hm-') ? name.slice(3) : null); },
evidence(selector) {
// Restrict the proof to a plain leading positive class and an explicit
// descendant slot. Commas, pseudos, escapes and nesting stay unpruned.
const plain = selector.replace(/\[[^\]]*\]/g, '[]');
const ancestor = !/[,:\\&]/.test(plain) && selector.match(/^\.([A-Za-z_][\w-]*)\s+/)?.[1];
const explicitSlot = /\[data-slot\s*=\s*(?:"[\w-]+"|'[\w-]+'|[\w-]+)\]/.test(selector);
const implementationClassPossible = Boolean(ancestor && implementationText.includes(ancestor));
const absent = ancestor && explicitSlot && !uncertain && !classes.has(ancestor) && !implementationClassPossible;
return { status: absent ? 'NOT APPLICABLE' : 'POSSIBLE', requiredAncestorClass: ancestor || null, implementationClassPossible, classInputsComplete: !uncertain, reason: absent ? 'Required positive ancestor class is absent from this complete entry graph and HTML; and absent from public implementation text; CSS retained in inventory, not approved' : 'No proven scope exclusion; Component override policy remains enforced' };
},
};
}
// Inventory consumer declarations. General layout is not a Component replica.
export function styleAnalysis({ records, target, postcss, walk, selectedFile, knownSlot, origin, add, storybook, entries = [], scopeIncomplete = true, scopeRecords = records, implementationFiles = [] }) {
const checks = [];
const scope = classScope(scopeRecords, entries, walk, origin, scopeIncomplete, implementationFiles);
const record = (file, node, selector, component, property, value, important = false, applicability = { status: 'POSSIBLE' }) => {
const color = rawColor.test(value);
const spacing = geometry.test(property) && /(?:^|[\s(+])[-+]?(?:\d*\.)?\d+(?:px|rem|em)\b/.test(value) && !/^(?:0(?:px|rem|em)?\s*)+$/.test(value);
const override = Boolean(component);
const global = /(?:^|[,\s])(?:html|body|:root|\*)(?:$|[,\s.#:[>])/.test(selector);
const forbidden = override && applicability.status !== 'NOT APPLICABLE' && (rawColor.test(withoutVariables(value)) || spacing);
const intent = override || color || spacing || important;
const ruleId = forbidden ? 'STYLE-001' : 'STYLE-002';
if (forbidden && !storybook) add('FAIL', ruleId, file, node, 'Hardcoded color / spacing / radius overrides an HM Component; use its documented Token / Prop contract');
const relative = path.relative(target, file).replaceAll('\\', '/');
checks.push({ id: `${relative}:${lineOf(node)}:${selector}:${property}:${value}`, file: relative, line: lineOf(node), ruleId, component: component ?? null, selector, property, value, important, tokens: [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map(m => m[1]), scope: global ? 'GLOBAL' : 'COMPONENT_SCOPED', applicability, staticStatus: forbidden && !storybook ? 'FAIL' : 'PASS', runtimeStatus: 'NOT VERIFIED', designIntentStatus: intent ? 'NOT VERIFIED' : 'PASS', reason: intent ? 'Literal styling / Component override requires a documented design decision; computed equality is not approval' : 'No ambiguous override detected; cascade / actual application requires browser evidence' });
};
for (const r of records.filter(r => selectedFile(r.file))) {
if (r.css) {
try {
const ast = postcss.parse(fs.readFileSync(r.file, 'utf8'));
ast.walkDecls(n => {
const selector = n.parent?.type === 'rule' ? n.parent.selector : `@${n.parent?.name ?? 'declaration'}`;
const slots = [...selector.matchAll(/data-slot\s*=\s*["']?([\w-]+)/g)].map(m => m[1]);
const classes = [...selector.matchAll(/\.([A-Za-z_][\w-]*)/g)].map(m => m[1]);
const component = slots.find(knownSlot) ?? classes.filter(name => name.startsWith('hm-') && knownSlot(name.slice(3))).map(name => scope.component(name)).find(Boolean);
record(r.file, n, selector, component, n.prop, n.value, n.important, scope.evidence(selector));
});
} catch { /* Existing CSS-005 retains parse uncertainty. */ }
}
const constants = r.ast ? localConstants(r.ast, walk) : new Map();
if (r.ast) walk(r.ast, (n, parent) => {
if (n.type !== 'JSXAttribute' || !['style', 'className'].includes(n.name.name)) return;
const tag = parent.name?.type === 'JSXMemberExpression' ? `${parent.name.object.name}.${parent.name.property.name}` : parent.name?.name;
const identity = origin(r.file, tag);
const component = identity?.pkg === '@hm-design/ui' ? identity.name : null;
if (n.name.name === 'className') {
const values = knownStyleStrings(n.value?.expression ?? n.value, constants);
for (const v of values ?? []) for (const m of v.matchAll(/(?:^|[\s:])([\w-]+)-\[([^\]]+)\]/g)) {
const property = m[1] === 'gap' ? 'gap' : /^(?:p|m)[xytrbl]?$/.test(m[1]) ? `${m[1][0] === 'p' ? 'padding' : 'margin'}${({ x: '-inline', y: '-block', t: '-top', r: '-right', b: '-bottom', l: '-left' })[m[1][1]] ?? ''}` : m[1] === 'rounded' ? 'border-radius' : 'utility-' + m[1];
record(r.file, n, `<${tag ?? 'element'}>`, component, property, m[2]);
}
return;
}
const value = n.value?.expression;
if (value?.type !== 'ObjectExpression') return;
for (const p of value.properties) {
if (p.type !== 'ObjectProperty' || p.computed) continue;
const key = p.key.name ?? p.key.value;
const values = knownStyleStrings(p.value, constants);
const property = String(key).replace(/[A-Z]/g, c => `-${c.toLowerCase()}`);
for (const v of values ?? []) record(r.file, p, `<${tag ?? 'element'}>`, component, property, geometry.test(property) && /^\d+(?:\.\d+)?$/.test(v) ? `${v}px` : v);
}
});
}
return [...new Map(checks.map(c => [c.id, c])).values()];
}
+46
View File
@@ -0,0 +1,46 @@
import { verdict } from './guard-style-analysis.mjs';
// Exact source sites, not a single browser PASS for all CSS / dynamic branches.
export function styleVerification(guard, runtime, cssEvidence, linked) {
const widths = [390, 768, 1280, 1440];
const dynamic = (guard.tokenEvidence ?? []).flatMap(e => (e.dynamic ?? []).map(d => ({ ...d, definitions: e.definitions ?? [], incomplete: e.incomplete })));
const sites = [...(guard.styleEvidence?.checks ?? []).filter(c => c.designIntentStatus !== 'PASS'), ...dynamic];
const checks = [];
const supplied = runtime.cssEvidence?.checks;
for (const site of sites) {
const measurements = [];
for (const raw of Array.isArray(supplied) ? supplied.filter(c => c?.siteId === site.id) : []) {
const named = cssEvidence.checks.find(c => c.name === raw.name && c.width === raw.width && c.selector === raw.selector && c.property === raw.property && c.actual === raw.actual && c.expected === raw.expected);
const browser = raw.browser;
const meta = named && linked && raw.file === site.file && raw.ruleId === site.ruleId &&
typeof raw.state === 'string' && raw.state.trim() && typeof raw.component === 'string' && raw.component.trim() &&
browser && typeof browser.name === 'string' && browser.name.trim() && typeof browser.version === 'string' && browser.version.trim() &&
Number.isFinite(raw.height) && raw.height > 0 &&
(!site.selector || raw.selector === site.selector || raw.sourceSelector === site.selector && raw.matchesSourceSelector === true) &&
(!site.component || raw.component.toLowerCase().replaceAll('-', '') === site.component.toLowerCase().replaceAll('-', ''));
if (!meta) { measurements.push({ ...raw, status: 'NOT VERIFIED', reason: 'Missing / mismatched site, source, rule, DOM, state, viewport or browser metadata' }); continue; }
if (site.ruleId === 'TOKEN-002') {
const expression = raw.actualExpression;
const names = typeof expression === 'string' ? [...expression.matchAll(/(?:var\(\s*|\()(--hm-[\w-]+)\s*(?=[,)])/g)].map(m => m[1]) : [];
const validTokens = names.length > 0 && Array.isArray(raw.tokens) && names.every(n => raw.tokens.includes(n));
const missing = names.filter(n => !site.definitions.includes(n));
const malformed = typeof expression === 'string' && /var\(\s*--hm-[\w-]+\s+[^\s,)]/.test(expression);
const tokenStatus = malformed ? 'FAIL' : !validTokens || site.incomplete ? 'NOT VERIFIED' : missing.length ? 'FAIL' : 'PASS';
measurements.push({ ...raw, status: verdict([named.status, tokenStatus]), reason: missing.length ? `Observed undefined Token: ${missing.join(', ')}` : !validTokens ? 'Actual dynamic expression / token references were not supplied' : 'Observed declarations and computed value checked; unobserved branches remain unverified' });
} else {
const sameProperty = raw.property.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`) === site.property;
measurements.push({ ...raw, status: sameProperty ? named.status : 'NOT VERIFIED', reason: sameProperty ? 'Measured actual / expected equality at named DOM state' : 'Measurement property does not match the declaration' });
}
}
const covered = widths.every(w => measurements.some(m => m.width === w && m.status === 'PASS'));
const runtimeStatus = measurements.some(m => m.status === 'FAIL') ? 'FAIL' : covered && measurements.every(m => m.status === 'PASS') ? 'PASS' : 'NOT VERIFIED';
const staticStatus = site.staticStatus ?? site.status;
const designIntentStatus = site.designIntentStatus ?? 'PASS';
checks.push({ id: site.id, ruleId: site.ruleId, file: site.file, line: site.line, component: site.component ?? null, selector: site.selector ?? null, property: site.property ?? null, tokens: site.tokens ?? [], staticStatus, runtimeStatus, designIntentStatus, finalStatus: verdict([staticStatus, runtimeStatus, designIntentStatus]), measurements, reason: site.reason ?? 'Dynamic source cannot prove all branches; browser observations are separate evidence' });
}
const failures = (guard.issues ?? []).filter(i => !i.code.startsWith('RENDER-') && !['REUSE-003', 'TOKEN-002'].includes(i.code)).map(i => i.severity === 'FAIL' ? 'FAIL' : 'NOT VERIFIED');
const staticStatus = verdict([...failures, ...checks.map(c => c.staticStatus)]);
const runtimeStatus = sites.length ? verdict(checks.map(c => c.runtimeStatus)) : 'NOT VERIFIED';
const designIntentStatus = verdict([...checks.map(c => c.designIntentStatus), ...(guard.issues ?? []).filter(i => i.code === 'REUSE-003').map(() => 'NOT VERIFIED')]);
return { staticStatus, runtimeStatus, designIntentStatus, finalStatus: verdict([staticStatus, runtimeStatus, designIntentStatus]), checks, coverage: 'Named DOM / properties / four widths / supplied states only; unobserved themes, states and external inputs remain NOT VERIFIED' };
}
+131
View File
@@ -0,0 +1,131 @@
import fs from 'node:fs';
import path from 'node:path';
// Bounded, immutable local expressions only; never execute consumer code.
export function staticStrings(node, constants = new Map(), seen = new Set()) {
if (!node) return null;
if (node.type === 'StringLiteral' || node.type === 'NumericLiteral') return [String(node.value)];
if (node.type === 'Identifier' && constants.has(node.name) && !seen.has(node.name)) return staticStrings(constants.get(node.name), constants, new Set([...seen, node.name]));
if (['TSAsExpression', 'TSNonNullExpression', 'ParenthesizedExpression'].includes(node.type)) return staticStrings(node.expression, constants, seen);
if (node.type === 'ConditionalExpression') {
const a = staticStrings(node.consequent, constants, seen), b = staticStrings(node.alternate, constants, seen);
return a && b && a.length + b.length <= 32 ? [...new Set([...a, ...b])] : null;
}
if (node.type === 'BinaryExpression' && node.operator === '+') {
const a = staticStrings(node.left, constants, seen), b = staticStrings(node.right, constants, seen);
return a && b && a.length * b.length <= 32 ? a.flatMap(x => b.map(y => x + y)) : null;
}
if (node.type === 'TemplateLiteral') {
let values = [node.quasis[0].value.cooked ?? node.quasis[0].value.raw];
for (let i = 0; i < node.expressions.length; i++) {
const parts = staticStrings(node.expressions[i], constants, seen);
if (!parts || values.length * parts.length > 32) return null;
values = values.flatMap(a => parts.map(b => a + b + (node.quasis[i + 1].value.cooked ?? node.quasis[i + 1].value.raw)));
}
return values;
}
return null;
}
export function localConstants(ast, walk) {
const constants = new Map();
for (const stmt of ast.program.body) {
const decl = stmt.type === 'ExportNamedDeclaration' ? stmt.declaration : stmt;
if (decl?.type === 'VariableDeclaration' && decl.kind === 'const') for (const d of decl.declarations) if (d.id.type === 'Identifier') constants.set(d.id.name, d.init);
}
const shadowed = new Set();
walk(ast, (n, parent) => {
if (n.type === 'Identifier' && parent?.type === 'VariableDeclarator' && parent.id === n && constants.get(n.name) !== parent.init) shadowed.add(n.name);
const bindings = n.type === 'VariableDeclarator' && n.id.type !== 'Identifier' ? [n.id] : ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(n.type) ? n.params : n.type === 'CatchClause' ? [n.param] : [];
for (const binding of bindings) walk(binding, id => { if (id.type === 'Identifier') shadowed.add(id.name); });
});
for (const name of shadowed) constants.delete(name);
return constants;
}
// Definitions come from connected public CSS, never every file in the repository.
export function themeAnalysis({ records, workspace, postcss, walk, selectedFile, add, graphIncomplete = false }) {
const definitions = new Set();
const references = [];
const dynamic = [];
const declarationSources = new Map();
const visited = new Set();
let incomplete = graphIncomplete;
const collect = (value, file, node) => {
for (const match of value.matchAll(/var\(\s*(--[\w-]+)\s*(?=[,)])/g)) references.push({ name: match[1], file, node, fallback: value.slice(match.index + match[0].length).trimStart().startsWith(',') });
if (selectedFile(file) && /var\(\s*--hm-[\w-]+\s+[^\s,)]/.test(value)) add('FAIL', 'TOKEN-003', file, node, 'Malformed HM var() reference; expected a closing parenthesis or comma fallback');
// Tailwind v4 shorthand also consumes custom properties.
for (const match of value.matchAll(/(?:^|\s|:)\w[\w-]*-\(\s*(--hm-[\w-]+)\s*\)/g)) references.push({ name: match[1], file, node });
};
const collectComplete = (value, file, node) => {
collect(value, file, node);
if (!selectedFile(file) || !/var\(\s*--hm-[\w-]+/.test(value)) return;
let depth = 0;
for (const c of value) { if (c === '(') depth++; else if (c === ')') depth--; }
if (depth !== 0) add('FAIL', 'TOKEN-003', file, node, 'Malformed complete HM variable expression: unmatched parentheses');
};
const css = (file, consumer = false) => {
if (visited.has(file)) return;
visited.add(file);
try {
const ast = postcss.parse(fs.readFileSync(file, 'utf8'), { from: file });
ast.walkDecls(n => {
if (n.prop.startsWith('--')) { definitions.add(n.prop); declarationSources.set(n.prop, consumer ? 'CONSUMER' : 'PUBLIC'); }
if (consumer) collect(n.value, file, n);
});
if (!consumer) ast.walkAtRules('import', n => {
const spec = n.params.match(/^["']([^"']+)["']/)?.[1];
if (spec?.startsWith('.')) css(path.resolve(path.dirname(file), spec));
});
} catch { incomplete = true; }
};
for (const r of records) {
if (r.css) css(r.file, true);
for (const e of r.edges.filter(e => e.runtime)) {
const publicCSS = {
'@hm-design/ui/theme.css': 'packages/ui/src/theme.css',
'@hm-design/ui/dashboard-status.css': 'packages/ui/src/patterns/dashboard-status/dashboard-status.css',
}[e.spec];
if (publicCSS) css(path.join(workspace, publicCSS));
}
if (r.ast) {
const constants = localConstants(r.ast, walk);
const pending = (n, kind) => {
if (!selectedFile(r.file)) return;
const line = n.loc?.start?.line ?? 1;
const id = `${r.file}:${line}:${n.start ?? 0}:${kind}`;
if (dynamic.some(d => d.id === id)) return;
dynamic.push({ id, ruleId: 'TOKEN-002', file: r.file, line, kind, expression: fs.readFileSync(r.file, 'utf8').slice(n.start, n.end), status: 'NOT VERIFIED' });
add('NOT VERIFIED', 'TOKEN-002', r.file, n, `Unresolved ${kind} requires actual token / computed-style evidence for this site`);
};
walk(r.ast, (n, parent, field) => {
if (n.type === 'ObjectProperty' && !n.computed && n.key.type === 'StringLiteral' && n.key.value.startsWith('--')) { definitions.add(n.key.value); declarationSources.set(n.key.value, 'CONSUMER_INLINE'); }
if (n.type === 'StringLiteral' && field !== 'key' && !parent?.type.startsWith('Import')) collect(n.value, r.file, n);
if (n.type === 'TemplateLiteral') {
const values = staticStrings(n, constants);
if (values) values.forEach(v => collect(v, r.file, n));
else if (n.quasis.some(q => /var\(|--hm-/.test(q.value.raw))) pending(n, 'TOKEN_TEMPLATE');
}
if (n.type === 'JSXAttribute' && ['style', 'className'].includes(n.name.name) && n.value?.type === 'JSXExpressionContainer') {
const expr = n.value.expression;
if (n.name.name === 'className') {
const values = staticStrings(expr, constants);
if (values) values.forEach(v => collectComplete(v, r.file, expr));
else pending(expr, 'CLASS_NAME');
} else if (expr.type !== 'ObjectExpression') pending(expr, 'STYLE_PROP');
else for (const p of expr.properties) {
if (p.type !== 'ObjectProperty' || p.computed) { pending(p, 'STYLE_SPREAD'); continue; }
const values = staticStrings(p.value, constants);
if (values) values.forEach(v => collectComplete(v, r.file, p.value));
else pending(p.value, 'STYLE_VALUE');
}
}
});
}
}
for (const ref of references) if (selectedFile(ref.file) && !definitions.has(ref.name)) {
// Non-HM variables may belong to an external host; do not invent its contract.
add(incomplete || !ref.name.startsWith('--hm-') ? 'NOT VERIFIED' : 'FAIL', 'TOKEN-001', ref.file, ref.node, `${ref.name} is not declared in the connected consumer / public Theme CSS${incomplete ? '; CSS definitions incomplete' : ''}`);
}
return { definitions: [...definitions].filter(n => n.startsWith('--hm-')).sort(), references: references.filter(r => selectedFile(r.file)).map(({ name, file, node, fallback }) => ({ name, file, line: node?.loc?.start?.line ?? node?.source?.start?.line ?? 1, fallback: Boolean(fallback), origin: declarationSources.get(name) ?? 'UNRESOLVED', status: definitions.has(name) ? 'PASS' : incomplete || !name.startsWith('--hm-') ? 'NOT VERIFIED' : 'FAIL' })), dynamic, incomplete };
}
+6 -4
View File
@@ -1,4 +1,5 @@
// Portable test launcher: built-in modules only; never invokes npm or a global Node.
// Preview launcher: built-in modules only. Bundled Node when present, installed
// Node otherwise; no npm install/build is needed for the static preview.
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
@@ -11,12 +12,13 @@ const server = http.createServer((req, res) => {
try { pathname = decodeURIComponent(new URL(req.url, 'http://127.0.0.1').pathname); }
catch { res.writeHead(400); res.end(); return; }
// Dashboard is a production export and must be served from its own URL root.
const example = pathname === '/example' || pathname.startsWith('/example/');
const dashboard = pathname === '/dashboard' || pathname.startsWith('/dashboard/');
const dashboardPath = dashboard ? (pathname === '/dashboard' ? '/dashboard/' : pathname) : pathname;
// Source Reference/static example paths keep their repository-relative imports.
const reference = ['/docs/','/packages/','/src/','/public/'].some(prefix => pathname.startsWith(prefix));
const base = dashboard ? path.join(root, 'dist-dashboard') : (reference ? root : path.join(root, 'storybook-static'));
const relativePath = dashboard ? dashboardPath.slice('/dashboard'.length) || '/' : (pathname === '/favicon.ico' ? '/favicon.svg' : pathname);
const base = example ? path.join(root, 'examples/publisher-list/dist') : dashboard ? path.join(root, 'dist-dashboard') : (reference ? root : path.join(root, 'storybook-static'));
const relativePath = example ? pathname.slice('/example'.length) || '/' : dashboard ? dashboardPath.slice('/dashboard'.length) || '/' : (pathname === '/favicon.ico' ? '/favicon.svg' : pathname);
let file = path.resolve(base, '.' + relativePath);
const relative = path.relative(base, file);
if (relative.startsWith('..') || path.isAbsolute(relative) || pathname.includes('\\')) { res.writeHead(403); res.end(); return; }
@@ -58,7 +60,7 @@ server.on('error', error => {
});
server.on('listening', () => {
const url = `http://127.0.0.1:${server.address().port}/`;
console.log(`HM Design System Portable Test\nNode ${process.version} (${process.arch})\nStorybook: ${url}\nDashboard: ${url}dashboard/\nStatic status example: ${url}docs/patterns/dashboard-status-static.html\nPress Ctrl+C to stop this preview.\n`);
console.log(`HM Design System Portable Test\nNode ${process.version} (${process.arch})\nStorybook: ${url}\nNew React screen: ${url}example/\nDashboard: ${url}dashboard/\nStatic status example: ${url}docs/patterns/dashboard-status-static.html\nPress Ctrl+C to stop this preview.\n`);
openDefaultBrowser(url);
});
process.on('SIGINT', () => server.close(() => process.exit(0)));
+10 -7
View File
@@ -2,14 +2,17 @@
setlocal
chcp 65001 >nul
title HM Design System - Portable Test Preview
echo The server prints the actual Storybook, Dashboard and Example addresses below.
if not exist "%~dp0runtime\node\node.exe" (
echo ERROR: Bundled runtime is missing. Extract the complete ZIP first.
pause
exit /b 1
where node >nul 2>nul
if errorlevel 1 (
echo ERROR: Install Node.js 20.19+ or 22.12+, then retry. No npm install is needed for preview.
pause
exit /b 1
)
node "%~dp0preview-server.cjs"
) else (
"%~dp0runtime\node\node.exe" "%~dp0preview-server.cjs"
)
echo.
echo The actual Storybook and Dashboard addresses will be printed by the server below.
echo.
"%~dp0runtime\node\node.exe" "%~dp0preview-server.cjs"
if errorlevel 1 pause
endlocal
+24
View File
@@ -0,0 +1,24 @@
import fs from 'node:fs';
import path from 'node:path';
const root = path.resolve('examples/ai-quality');
const cases = [
['01', '사용자 목록', 'Easy', 'GNB_ONLY', 'SearchList', '<SearchList title="사용자 목록"/>', ['조회', '초기화'], ['FilterBar', 'DataTable']],
['02', '현장 업무', 'Medium', 'GNB_ONLY', 'SearchList', '<SearchList title="현장 업무" cards/>', ['조회', '초기화'], ['FilterBar']],
['03', '승인 결재', 'Hard', 'GNB_ONLY', 'SearchList', '<SearchList title="승인 결재" detail="dialog"/>', ['조회', '초기화', '결재 확인', '취소', '승인'], ['FilterBar', 'DataTable']],
['04', '채용 관리', 'Hard', 'GNB_ONLY', 'SearchList', '<SearchList title="채용 관리" detail="sheet"/>', ['조회', '초기화', '채용 상세', '확인'], ['FilterBar', 'DataTable']],
['05', '업무 요청', 'Medium', 'GNB_ONLY', 'RequestForm', '<RequestForm/>', ['제출'], []],
['06', '권한 설정', 'Medium', 'LNB_ONLY', 'Permissions', '<Permissions/>', ['저장'], []],
['07', '업무 Dashboard', 'Medium', 'DASHBOARD_ONLY', 'Dashboard', '<Dashboard/>', [], ['DashboardLayout']],
['08', '통계 Dashboard', 'Medium', 'DASHBOARD_ONLY', 'Dashboard', '<Dashboard statistics/>', [], ['DashboardLayout', 'DataTable']],
['09', '작업 변경', 'Hard', 'GNB_ONLY', 'OverlayWorkflow', '<OverlayWorkflow/>', ['작업 변경 열기', '취소', '적용'], []],
['10', '처리 상태', 'Easy', 'LNB_ONLY', 'StateWorkflow', '<StateWorkflow/>', ['로딩', '오류', '빈 결과', '다시 시도'], []],
];
for (const [id, title, difficulty, mode, component, jsx, actions, patterns] of cases) {
const folder = path.join(root, id);
fs.mkdirSync(folder, { recursive: true });
const frame = mode === 'GNB_ONLY' ? 'GNBFrame' : mode === 'LNB_ONLY' ? 'LNBFrame' : null;
fs.writeFileSync(path.join(folder, 'main.tsx'), `import { createRoot } from "react-dom/client"\nimport { ${component} } from "../shared/${component}"${frame ? '\nimport { ' + frame + ' } from "../shared/' + frame + '"' : ''}\nimport "../shared/index.css"\nimport "../shared/hm-theme.css"\ncreateRoot(document.getElementById("root")!).render(${frame ? `<${frame} title="${title}">${jsx}</${frame}>` : jsx})\n`);
fs.writeFileSync(path.join(folder, 'index.html'), `<!doctype html><html lang="ko"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><link rel="icon" href="data:,"/><title>${title}</title></head><body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>\n`);
fs.writeFileSync(path.join(folder, 'requirements.json'), JSON.stringify({ id, title, difficulty, mode, actions, patterns, generation: { model: 'GPT-6 (session identity)', reasoningLevel: 'UNAVAILABLE', randomSeed: null, seedReason: 'Session sampling seed unavailable', condition: 'Generated in current Codex session, shared compositions, no previous experiment solution source supplied', comparability: 'NON-COMPARABLE' } }, null, 2) + '\n');
}
console.log('Prepared 10 independent React bootstraps; shared composition source is explicit.');
+8
View File
@@ -0,0 +1,8 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import postcss from 'postcss';
import { themeAnalysis } from './guard-theme-analysis.mjs';
const workspace = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const result = themeAnalysis({ records: [{ edges: [{ runtime: true, spec: '@hm-design/ui/theme.css' }, { runtime: true, spec: '@hm-design/ui/dashboard-status.css' }] }], workspace, postcss, walk() {}, selectedFile() { return true; }, add() {} });
console.log(JSON.stringify({ source: '@hm-design/ui/theme.css + @hm-design/ui/dashboard-status.css', definitionCount: result.definitions.length, definitions: result.definitions, incomplete: result.incomplete, note: 'CSS declarations, not a promise that each variable is an overridable Runtime JSON key. See the public Runtime contract.' }, null, 2));
if (result.incomplete) process.exitCode = 1;
+109
View File
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup as render } from 'react-dom/server';
import { build } from 'vite';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
// Load the actual TSX through the project's Vite transforms; assertions cover
// consumer contracts rather than matching implementation text.
const cache = new Map();
const load = async source => {
if (!cache.has(source)) {
const name = path.basename(source, '.tsx');
await build({ configFile: false, logLevel: 'silent', build: { outDir: `_local/component-test/${name}`, emptyOutDir: false, lib: { entry: source, formats: ['es'], fileName: 'module' }, rolldownOptions: { external: id => !id.startsWith('.') && !path.isAbsolute(id) } } });
cache.set(source, import(pathToFileURL(path.resolve(`_local/component-test/${name}/module.js`)).href));
}
return cache.get(source);
};
const element = React.createElement;
test('FilterBar list composition is explicit and leaves default consumers intact', async () => {
const { FilterBar } = await load('packages/ui/src/patterns/filter-bar/filter-bar.tsx');
const original = render(element(FilterBar, { id: 'default', 'aria-label': 'Default' }, 'Content'));
assert.doesNotMatch(original, /data-layout/);
for (const layout of ['list-basic', 'list-extended']) {
const html = render(element(FilterBar, { layout, id: layout, style: { marginTop: 12 } }, 'Content'));
assert.match(html, new RegExp(`data-layout="${layout}"`));
assert.match(html, /margin-top:12px/);
assert.match(html, /Content/);
assert.doesNotMatch(html, / layout=/);
}
});
test('C2 Checkbox asChild preserves the consumer root and content, including checked indicator', async () => {
const { Checkbox } = await load('packages/ui/src/components/ui/checkbox.tsx');
const html = render(element(Checkbox, { asChild: true, checked: true }, element('button', { id: 'consumer-check' }, element('span', null, 'Consumer child'))));
assert.match(html, /id="consumer-check"/);
assert.match(html, /Consumer child/);
assert.match(html, /data-slot="checkbox-indicator"/);
assert.match(html, /role="checkbox"/);
});
test('C2 Progress asChild preserves the consumer root/content and progress semantics', async () => {
const { Progress } = await load('packages/ui/src/components/ui/progress.tsx');
const html = render(element(Progress, { asChild: true, value: 35 }, element('section', { id: 'consumer-progress' }, 'Consumer progress')));
assert.match(html, /<section[^>]*id="consumer-progress"/);
assert.match(html, /Consumer progress/);
assert.match(html, /aria-valuenow="35"/);
assert.match(html, /data-slot="progress-indicator"/);
});
test('C3 Toggle Group combines consumer styles with the requested spacing', async () => {
const { ToggleGroup } = await load('packages/ui/src/components/ui/toggle-group.tsx');
const html = render(element(ToggleGroup, { type: 'multiple', spacing: 3, style: { color: 'red' } }));
assert.match(html, /--hm-toggle-group-gap:12px/);
assert.match(html, /color:red/);
});
test('C4 every railway state forwards DOM identity, ARIA and style to the final root', async () => {
const { RouteLine } = await load('packages/center-graphic/src/components/CenterSpatialInformation.tsx');
for (const state of ['existing', 'planned', 'underConstruction']) {
const html = render(element(RouteLine, { routeClass: 'railway', state, id: 'route', 'aria-label': 'Railway', style: { opacity: 0.7 } }));
assert.match(html, /id="route"/);
assert.match(html, /aria-label="Railway"/);
assert.match(html, /opacity:0.7/);
assert.equal((html.match(/id="route"/g) ?? []).length, 1);
}
});
test('PT1 two pagination instances have distinct labels and matching references', async () => {
const { DataTablePagination, useDataTable } = await load('packages/ui/src/patterns/data-table/data-table.tsx');
function Tables() {
const table = useDataTable({ columns: [{ accessorKey: 'name' }], data: [{ name: 'one' }] });
return element(React.Fragment, null, element(DataTablePagination, { table }), element(DataTablePagination, { table }));
}
const html = render(element(Tables));
const ids = [...html.matchAll(/<span id="([^"]+)">페이지당/g)].map(match => match[1]);
assert.equal(ids.length, 2);
assert.equal(new Set(ids).size, 2);
for (const id of ids) assert.ok(html.includes(`aria-labelledby="${id}"`));
});
test('PT2 readOnly and disabled searches expose no mutating clear control', async () => {
const { FilterBarSearch } = await load('packages/ui/src/patterns/filter-bar/filter-bar.tsx');
for (const protection of [{ readOnly: true }, { disabled: true }]) {
const html = render(element(FilterBarSearch, { value: 'Keep', onValueChange() { throw Error('must not mutate'); }, ...protection }));
assert.doesNotMatch(html, /data-slot="filter-bar-search-clear"/);
assert.match(html, /value="Keep"/);
}
assert.match(render(element(FilterBarSearch, { value: 'Editable', onValueChange() {} })), /data-slot="filter-bar-search-clear"/);
});
test('C5 repeated popup triggers have unique generated IDs and retain consumer identity', async () => {
const { Combobox } = await load('packages/ui/src/components/ui/combobox.tsx');
const { DatePicker } = await load('packages/ui/src/components/ui/date-picker.tsx');
const html = render(element(React.Fragment, null,
element(Combobox, { options: [], 'aria-label': 'Department' }),
element(Combobox, { options: [], 'aria-label': 'Owner' }),
element(DatePicker, { 'aria-label': 'Start' }), element(DatePicker, { 'aria-label': 'End' }),
element(DatePicker, { id: 'consumer-date', 'aria-labelledby': 'date-label' })));
const ids = [...html.matchAll(/<button[^>]*id="([^"]+)"/g)].map(match => match[1]);
assert.equal(ids.length, 5);
assert.equal(new Set(ids).size, 5);
assert.ok(ids.includes('consumer-date'));
assert.match(html, /aria-labelledby="date-label"/);
});
test('C2 default roots and external Checkbox form remain backward compatible', async () => {
const { Checkbox } = await load('packages/ui/src/components/ui/checkbox.tsx');
const { Progress } = await load('packages/ui/src/components/ui/progress.tsx');
assert.match(render(element(Checkbox, { form: 'external', name: 'approved', checked: true })), /<button[^>]*form="external"/);
const progress = render(element(Progress, { value: 50, max: 200, 'aria-label': 'Upload' }));
assert.match(progress, /aria-valuemax="200"/);
assert.match(progress, /translateX\(-75%\)/);
});
+18
View File
@@ -0,0 +1,18 @@
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
const routes = [['/themes/', 'storybook-static/themes'], ['/storybook/', 'storybook-static'], ['/publisher/', 'examples/publisher-list/dist'], ['/dashboard/', 'dist-dashboard'], ['/recruit/', 'dist-recruit'], ['/', 'dist']];
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf' };
http.createServer((req, res) => {
try {
const pathname = decodeURIComponent(new URL(req.url, 'http://127.0.0.1').pathname);
const [prefix, folder] = routes.find(([p]) => pathname.startsWith(p));
const root = path.resolve(folder);
let file = path.resolve(root, `.${pathname.slice(prefix.length - 1)}`);
const relative = path.relative(root, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) { res.writeHead(403); res.end(); return; }
if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, 'index.html');
if (!fs.existsSync(file)) { res.writeHead(404); res.end(); return; }
res.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' }); fs.createReadStream(file).pipe(res);
} catch { res.writeHead(400); res.end(); }
}).listen(6322, '127.0.0.1', () => console.log('Quality reference preview: http://127.0.0.1:6322/'));
+34
View File
@@ -0,0 +1,34 @@
import fs from 'node:fs';
import path from 'node:path';
import { parse } from '@babel/parser';
import { combineEvidence } from './guard-evidence.mjs';
const directory = path.resolve(process.env.HM_QUALITY_OUTPUT ?? '_local/quality-20261010');
const read = name => JSON.parse(fs.readFileSync(path.join(directory, name), 'utf8'));
const slots = { FilterBar: 'filter-bar', DataTable: 'data-table', DashboardLayout: 'dashboard-layout' };
const screens = [];
for (let n = 1; n <= 10; n++) {
const id = String(n).padStart(2, '0');
const spec = JSON.parse(fs.readFileSync(`examples/ai-quality/${id}/requirements.json`, 'utf8'));
const guard = read(`guard-${id}.json`), runtime = read(`runtime-${id}.json`);
const combined = combineEvidence(guard, runtime);
screens.push({ id, title: spec.title, difficulty: spec.difficulty, mode: spec.mode, generation: spec.generation, sourceFingerprint: guard.sourceFingerprint, contractFingerprint: guard.contractFingerprint, rawGuard: guard.status, staticStatus: guard.staticStatus, combined, viewports: runtime.viewports, interactions: runtime.interactions, requiredPatterns: spec.patterns.map(name => ({ name, rendered: runtime.viewports.every(v => v.metrics?.patternSlots.includes(slots[name])) })) });
}
// Count public JSX sites in the shared consumer source once, not once per entry.
// This is source reuse coverage, not a percentage of mounted DOM or design quality.
const 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'].includes(key)) Array.isArray(value) ? value.forEach(v => walk(v, fn)) : walk(value, fn); };
const sourceReuse = [];
for (const file of fs.readdirSync('examples/ai-quality/shared').filter(f => f.endsWith('.tsx'))) {
const ast = parse(fs.readFileSync(`examples/ai-quality/shared/${file}`, 'utf8'), { sourceType: 'module', plugins: ['typescript', 'jsx'] });
const publicNames = new Set();
walk(ast, n => { if (n.type === 'ImportDeclaration' && n.source.value === '@hm-design/ui') n.specifiers.forEach(s => publicNames.add(s.local.name)); });
let hmSites = 0, nativeWidgetSites = 0;
walk(ast, n => { if (n.type === 'JSXOpeningElement' && n.name.type === 'JSXIdentifier') { if (publicNames.has(n.name.name)) hmSites++; if (['button', 'input', 'textarea', 'select', 'label', 'table', 'dialog'].includes(n.name.name)) nativeWidgetSites++; } });
sourceReuse.push({ file, hmSites, nativeWidgetSites });
}
const hmSites = sourceReuse.reduce((s, f) => s + f.hmSites, 0), nativeWidgetSites = sourceReuse.reduce((s, f) => s + f.nativeWidgetSites, 0);
const patterns = screens.flatMap(s => s.requiredPatterns);
const interactions = screens.flatMap(s => s.interactions);
const summary = { capturedAt: new Date().toISOString(), screens, sourceReuse: { definition: 'Public HM JSX sites / (public HM JSX sites + native widget JSX sites), shared TSX counted once; excludes layout/content HTML and local composition wrappers, not mounted DOM coverage', hmSites, nativeWidgetSites, rate: hmSites / (hmSites + nativeWidgetSites), files: sourceReuse }, requiredPatternReuse: { definition: 'Required Pattern occurrences observed at every required viewport; Navigation also reported separately', total: patterns.length, rendered: patterns.filter(p => p.rendered).length }, totals: { screens: screens.length, rawGuardPass: screens.filter(s => s.rawGuard === 'PASS').length, rawGuardFail: screens.filter(s => s.rawGuard === 'FAIL').length, rawGuardNotVerified: screens.filter(s => s.rawGuard === 'NOT VERIFIED').length, staticPass: screens.filter(s => s.staticStatus === 'PASS').length, combinedPass: screens.filter(s => s.combined.status === 'PASS').length, viewportPass: screens.flatMap(s => s.viewports).filter(v => v.status === 'PASS').length, interactionPass: interactions.filter(i => i.status === 'PASS').length, interactionTotal: interactions.length }, limitations: ['NON-COMPARABLE with previous experiment', 'Cross-model generation / sampling seed / exact reasoning level unavailable', 'Average design score NOT VERIFIED; no numerical rubric executed', 'Screen reader speech, physical touch, backend and full WCAG audit NOT VERIFIED'] };
fs.writeFileSync(path.join(directory, 'final-evidence.json'), JSON.stringify(summary, null, 2));
console.log(JSON.stringify({ totals: summary.totals, sourceReuse: summary.sourceReuse, requiredPatternReuse: summary.requiredPatternReuse }, null, 2));
if (screens.some(s => s.combined.status !== 'PASS') || patterns.some(p => !p.rendered)) process.exitCode = 1;
+29
View File
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { build } from 'vite'
import { pathToFileURL } from 'node:url'
import path from 'node:path'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
await build({ configFile: false, logLevel: 'silent', build: { outDir: '_local/theme-verification-test', emptyOutDir: false, lib: { entry: 'src/theme/verification-state.tsx', formats: ['es'], fileName: 'module' }, rolldownOptions: { external: id => !id.startsWith('.') && !path.isAbsolute(id) } } })
const { waitForThemeState, VerificationStyles } = await import(pathToFileURL(path.resolve('_local/theme-verification-test/module.js')).href)
test('Theme verification waits for the actual state, including asynchronous updates', async () => {
let ready = false, probes = 0
queueMicrotask(() => { ready = true })
await waitForThemeState(() => { probes++; return ready }, 'asynchronous Theme')
assert.ok(ready && probes >= 2)
})
test('Theme verification fails with a diagnostic when the state never becomes ready', async () => {
await assert.rejects(waitForThemeState(() => false, 'missing checked color'), /Theme state did not settle: missing checked color/)
})
test('Settled-target styles are limited to verification scope and define no Theme values', () => {
const css = renderToStaticMarkup(React.createElement(VerificationStyles)).replace(/<\/?style>/g, '')
assert.ok(css.split('{')[0].split(',').every(selector => selector.trim().startsWith('[data-hm-theme-verification]')))
assert.match(css, /transition-duration: 0s/)
assert.match(css, /transition-delay: 0s/)
assert.doesNotMatch(css, /background|color:|--hm-|:root/)
})
+88
View File
@@ -0,0 +1,88 @@
import fs from 'node:fs';
import path from 'node:path';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
// Optional local QA tooling: no runtime package or model session is added.
const require = createRequire(process.env.HM_PLAYWRIGHT_DIR ? path.join(process.env.HM_PLAYWRIGHT_DIR, 'package.json') : import.meta.url);
const { chromium } = require('playwright');
const base = process.env.HM_A11Y_URL ?? 'http://127.0.0.1:6024';
const output = path.resolve(process.env.HM_A11Y_OUTPUT ?? '_local/accessibility-runtime');
const profilesOnly = process.argv.includes('--profiles-only');
fs.mkdirSync(output, { recursive: true });
const result = { capturedAt: new Date().toISOString(), url: base, checks: [], errors: [], limitations: ['Screen reader speech and physical touch are NOT VERIFIED', 'Only named stories, states and widths are covered; this is not WCAG certification'] };
const browser = await chromium.launch({ headless: true, ...(process.env.HM_CHROME_PATH ? { executablePath: process.env.HM_CHROME_PATH } : {}) });
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
page.on('pageerror', e => result.errors.push(e.message));
const save = () => fs.writeFileSync(path.join(output, 'runtime.json'), JSON.stringify(result, null, 2));
const check = async (name, action) => { try { result.checks.push({ name, status: 'PASS', evidence: await action() }); } catch (e) { result.checks.push({ name, status: 'FAIL', error: e.message }); } save(); };
try {
const index = await (await page.request.get(`${base}/index.json`)).json();
const stories = Object.values(index.entries).filter(e => e.type === 'story' && e.id.endsWith('--all'));
const story = async name => {
const entry = stories.find(e => e.importPath.endsWith(`/${name}.stories.tsx`));
assert.ok(entry, `Missing ${name} All story`);
await page.goto(`${base}/iframe.html?id=${entry.id}&viewMode=story`);
await page.locator('#storybook-root > *').first().waitFor();
await page.evaluate(() => document.fonts.ready);
};
for (const width of profilesOnly ? [] : [390, 768, 1280, 1440]) {
await check(`Combobox relationship/keyboard/geometry ${width}`, async () => {
await page.setViewportSize({ width, height: 900 }); await story('combobox');
const trigger = page.locator('#cb-empty');
await trigger.focus(); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
assert.equal(await trigger.evaluate(e => e === document.activeElement && e.matches(':focus-visible')), true);
await page.keyboard.press(width === 768 ? 'Space' : 'Enter');
const popup = page.locator('[data-combobox-popup]'); await popup.waitFor();
const cdp = await page.context().newCDPSession(page);
const documentNode = await cdp.send('DOM.getDocument');
const popupNode = await cdp.send('DOM.querySelector', { nodeId: documentNode.root.nodeId, selector: '[data-combobox-popup]' });
const ax = await cdp.send('Accessibility.getPartialAXTree', { nodeId: popupNode.nodeId, fetchRelatives: false });
const popupName = ax.nodes.find(node => node.role?.value === 'dialog')?.name?.value;
assert.ok(popupName?.trim(), 'Dialog needs an actual browser accessible name');
await cdp.detach();
assert.equal(await trigger.getAttribute('aria-haspopup'), await popup.getAttribute('role'));
assert.equal(await trigger.getAttribute('aria-controls'), await popup.getAttribute('id'));
assert.equal(await trigger.getAttribute('aria-expanded'), 'true');
assert.equal(await page.locator('[data-slot="command-input"]').evaluate(e => document.getElementById(e.getAttribute('aria-controls'))?.getAttribute('role')), 'listbox');
const geometry = await popup.evaluate(e => { const r = e.getBoundingClientRect(); return { left: r.left, right: r.right, width: innerWidth }; });
assert.ok(geometry.left >= -1 && geometry.right <= width + 1, JSON.stringify(geometry));
const ids = await page.locator('[id]').evaluateAll(es => es.map(e => e.id).filter((id, i, all) => all.indexOf(id) !== i));
assert.deepEqual(ids, []);
const tree = await popup.ariaSnapshot(); assert.match(tree, /dialog/); assert.match(tree, /listbox/);
await page.locator('[data-slot="command-input"]').fill('Astro');
await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await popup.waitFor({ state: 'hidden' });
await page.waitForFunction(() => document.activeElement?.id === 'cb-empty');
assert.match(await trigger.textContent(), /Astro/);
await page.keyboard.press('Enter'); await popup.waitFor(); await page.keyboard.press('Escape'); await popup.waitFor({ state: 'hidden' });
await page.waitForFunction(() => document.activeElement?.id === 'cb-empty');
assert.equal(await trigger.getAttribute('aria-expanded'), 'false');
const disabled = page.locator('[data-slot="combobox-trigger"]:disabled'); assert.ok(await disabled.count());
const overview = page.locator('#storybook-root');
assert.equal(await overview.evaluate(e => document.documentElement.scrollWidth <= innerWidth + 1), true);
await page.screenshot({ path: path.join(output, `combobox-${width}.png`), fullPage: true });
return { geometry, duplicateIds: ids, tree, popupName, keyboard: ['Tab', 'Shift+Tab', 'Enter/Space', 'ArrowDown', 'Enter select', 'Escape'], disabled: await disabled.count() };
});
}
for (const name of ['gnb', 'lnb']) {
await check(`${name} visible profile names / explicit name / keyboard return`, async () => {
await page.setViewportSize({ width: 1280, height: 900 }); await story(name);
const profile = page.locator(`[data-slot="${name === 'gnb' ? 'gnb-user' : 'lnb-profile'}"]`).first();
const texts = await profile.locator(name === 'gnb' ? '[data-slot="gnb-user-name"], [data-slot="gnb-user-description"]' : '[data-slot="lnb-profile-text"] > *').allTextContents();
const label = await profile.getAttribute('aria-label'); for (const text of texts) assert.ok(label.includes(text), `${label} omits ${text}`);
await profile.focus(); await page.keyboard.press('Enter'); await page.getByRole('menu').waitFor();
await page.keyboard.press('ArrowDown'); await page.keyboard.press('Escape'); await page.getByRole('menu').waitFor({ state: 'hidden' });
assert.equal(await profile.evaluate(e => e === document.activeElement), true);
const axeResults = process.env.HM_AXE_PATH ? await (async () => {
await page.addScriptTag({ path: process.env.HM_AXE_PATH });
const r = await page.evaluate(async selector => { const r = await axe.run(document.querySelector(selector), { runOnly: ['label-content-name-mismatch'] }); return { violations: r.violations.map(v => v.id), incomplete: r.incomplete.map(v => v.id) }; }, `[data-slot="${name === 'gnb' ? 'gnb-user' : 'lnb-profile'}"]`);
assert.deepEqual(r.violations, []); return r;
})() : { status: 'NOT VERIFIED', reason: 'HM_AXE_PATH not supplied' };
return { texts, label, tree: await profile.ariaSnapshot(), axe: axeResults };
});
}
} catch (e) { result.errors.push(e.stack); }
finally { save(); await browser.close(); }
result.status = result.errors.length || result.checks.some(c => c.status === 'FAIL') ? 'FAIL' : result.checks.length === (profilesOnly ? 2 : 6) ? 'PASS' : 'NOT VERIFIED';
save(); console.log(JSON.stringify({ status: result.status, checks: result.checks.map(({name,status,error}) => ({name,status,error})), errors: result.errors }, null, 2));
if (result.status !== 'PASS') process.exitCode = 1;
+84
View File
@@ -0,0 +1,84 @@
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
const require = createRequire(process.env.HM_PLAYWRIGHT_DIR ? path.join(process.env.HM_PLAYWRIGHT_DIR, 'package.json') : import.meta.url);
const { chromium } = require('playwright');
const browser = await chromium.launch({ headless: true, ...(process.env.HM_CHROME_PATH ? { executablePath: process.env.HM_CHROME_PATH } : {}) });
const out = '_local/quality-20261010';
const results = [];
const selected = process.argv.slice(2);
const base = 'http://127.0.0.1:6322';
const task = async (name, fn) => { if (selected.length && !selected.includes(name)) return; try { const evidence = await fn(); results.push({ name, status: 'PASS', evidence }); } catch (e) { results.push({ name, status: 'FAIL', error: e.message }); } console.log(JSON.stringify(results.at(-1))); };
const assert = (ok, message) => { if (!ok) throw Error(message); };
try {
for (const [name, route] of [['APP', '/'], ['PUBLISHER', '/publisher/'], ['DASHBOARD', '/dashboard/'], ['RECRUIT', '/recruit/']]) {
await task(`${name}_RESPONSIVE_RUNTIME`, async () => {
const evidence = [];
for (const width of [390, 768, 1280, 1440]) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto(base + route, { waitUntil: 'networkidle' });
await page.locator('#root [data-slot]:visible').first().waitFor();
const result = await page.evaluate(() => {
const frame = document.querySelector('[data-slot="dashboard-layout"]');
const scroll = document.querySelector('[data-slot="dashboard-scroll"]');
return { overflow: document.documentElement.scrollWidth > innerWidth + 1, hmElements: document.querySelectorAll('[data-slot]').length, frameWidth: frame?.getBoundingClientRect().width, headerHeight: document.querySelector('[data-slot="dashboard-header"]')?.getBoundingClientRect().height, internalOverflow: scroll ? scroll.scrollWidth > scroll.clientWidth : null };
});
await page.screenshot({ path: `${out}/existing-${name}-${width}.png`, fullPage: true });
await page.close();
assert(!result.overflow && !errors.length, `${width}: overflow / browser errors ${JSON.stringify(errors)}`);
if (result.frameWidth) assert(result.frameWidth >= 960 && result.headerHeight === 48, 'Dashboard geometry');
evidence.push({ width, ...result, errors });
}
return evidence;
});
}
await task('FILTERBAR_REFERENCE_USAGE', async () => {
const page = await browser.newPage();
await page.goto(`${base}/storybook/iframe.html?id=patterns-data-filter-bar--all&viewMode=story`, { waitUntil: 'networkidle' });
const status = page.getByRole('status');
await page.getByRole('searchbox', { name: '검색어' }).fill('김');
assert((await status.innerText()).includes('입력한 조건 1개 · 조회 적용 조건 0개'), 'draft / applied distinction');
await page.getByRole('button', { name: '조회', exact: true }).click(); assert((await status.innerText()).includes('조회 적용 조건 1개'), 'reference query');
await page.getByRole('button', { name: '초기화', exact: true }).click(); assert((await status.innerText()).includes('입력한 조건 0개 · 조회 적용 조건 0개'), 'reference reset');
const evidence = [];
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 900 });
const metrics = await page.locator('[data-slot="filter-bar"]').evaluate(e => ({ layout: e.dataset.layout, gap: getComputedStyle(e).gap, justify: getComputedStyle(e).justifyContent, wrap: getComputedStyle(e).flexWrap }));
assert(metrics.layout === 'list-extended' && metrics.gap === '8px' && metrics.wrap === 'wrap' && metrics.justify === 'flex-start', 'FilterBar composition');
await page.screenshot({ path: `${out}/filter-reference-${width}.png`, fullPage: true }); evidence.push({ width, ...metrics });
}
await page.close(); return evidence;
});
await task('THEME_DESIGNER', async () => {
const page = await browser.newPage();
await page.goto(`${base}/storybook/iframe.html?id=theme-designer-playground--live-verification&viewMode=story`, { waitUntil: 'networkidle' });
await page.waitForFunction(() => /^(PASS|FAIL)/.test(document.querySelector('[data-testid="designer-result"]')?.textContent ?? ''), { timeout: 20000 });
const text = await page.locator('[data-testid="designer-result"]').innerText(); assert(text.startsWith('PASS'), text);
await page.close(); return { text };
});
await task('THEME_JSON_LOADER', async () => {
const page = await browser.newPage();
await page.goto(`${base}/storybook/iframe.html?id=theme-designer-playground--editor&viewMode=story`, { waitUntil: 'networkidle' });
await page.getByRole('button', { name: 'Load Service Example JSON', exact: true }).click();
await page.waitForFunction(() => document.querySelector('[aria-label="Primary / Brand HEX"]')?.value !== '#123328');
const changed = await page.getByRole('textbox', { name: 'Primary / Brand HEX', exact: true }).inputValue();
await page.getByRole('button', { name: 'Load Default JSON', exact: true }).click();
await page.waitForFunction(() => document.querySelector('[aria-label="Primary / Brand HEX"]')?.value === '#123328');
await page.close(); return { servicePrimary: changed, defaultPrimary: '#123328' };
});
await task('RUNTIME_THEME_KEYBOARD', async () => {
const page = await browser.newPage();
await page.goto(`${base}/storybook/iframe.html?id=theme-runtime-contract--verification&viewMode=story`, { waitUntil: 'networkidle' });
await page.waitForFunction(() => /^(PASS|FAIL)/.test(document.querySelector('[data-testid="result"]')?.textContent ?? ''), { timeout: 20000 });
const auto = await page.locator('[data-testid="result"]').innerText(); assert(auto.startsWith('PASS'), auto);
const run = page.getByRole('button', { name: 'Run verification', exact: true });
await run.focus(); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); await page.keyboard.press('Enter');
await page.waitForFunction(() => /^(PASS|FAIL)/.test(document.querySelector('[data-testid="result"]')?.textContent ?? '') && document.querySelector('[data-testid="result"]')?.dataset.running !== 'true', { timeout: 20000 });
const keyboard = await page.locator('[data-testid="result"]').innerText(); assert(keyboard.startsWith('PASS') && !keyboard.includes('KEYBOARD_FOCUS: NOT VERIFIED'), keyboard);
await page.close(); return { auto, keyboard };
});
} finally { await browser.close(); }
fs.writeFileSync(`${out}/${selected.length ? 'reference-' + selected.join('-') : 'reference-runtime'}.json`, JSON.stringify(results, null, 2));
if (results.some(r => r.status === 'FAIL')) process.exitCode = 1;
+44
View File
@@ -0,0 +1,44 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const output = path.resolve('_local/quality-20261010');
fs.mkdirSync(output, { recursive: true });
const storybookBin = JSON.parse(fs.readFileSync('node_modules/storybook/package.json')).bin;
const jobs = [
['THEME_DEFAULTS', 'scripts/theme-defaults.mjs', ['--check']],
['TYPECHECK', 'node_modules/typescript/bin/tsc', ['-b']],
['UI_TYPECHECK', 'node_modules/typescript/bin/tsc', ['-p', 'packages/ui/tsconfig.json']],
['CENTER_TYPECHECK', 'node_modules/typescript/bin/tsc', ['-p', 'packages/center-graphic/tsconfig.json']],
['GENERATED_TYPECHECK', 'node_modules/typescript/bin/tsc', ['-p', 'examples/ai-quality/tsconfig.json']],
['PRODUCTION_BUILD', 'node_modules/vite/bin/vite.js', ['build']],
['DASHBOARD_BUILD', 'node_modules/vite/bin/vite.js', ['build', '--config', 'vite.dashboard-export.config.ts']],
['DASHBOARD_FINALIZE', 'scripts/finalize-dashboard-export.mjs', []],
['RECRUIT_BUILD', 'node_modules/vite/bin/vite.js', ['build', '--config', 'vite.recruit-export.config.ts']],
['PUBLISHER_BUILD', 'node_modules/vite/bin/vite.js', ['build', '--config', 'examples/publisher-list/vite.config.ts']],
['STORYBOOK_BUILD', path.join('node_modules/storybook', typeof storybookBin === 'string' ? storybookBin : storybookBin.storybook), ['build', '-o', 'storybook-static', '--disable-telemetry']],
['GUARD_TEST', null, ['--test', 'scripts/ai-generation-guard.test.mjs']],
['EVIDENCE_TEST', null, ['--test', 'scripts/guard-evidence.test.mjs']],
['COMPONENT_TEST', null, ['--test', 'scripts/publisher-components.test.mjs']],
['THEME_TEST', null, ['--test', 'scripts/theme-verification.test.mjs']],
['DOC_LINKS', 'scripts/check-doc-links.mjs', []],
['TOKEN_INVENTORY', 'scripts/public-theme-tokens.mjs', []],
['GUARD_FULL_SCREEN', 'scripts/ai-generation-guard.mjs', ['--target', '.', '--scope', 'FULL_SCREEN', '--mode', 'GNB_ONLY', '--result', 'index.html', '--json']],
['GUARD_PARTIAL', 'scripts/ai-generation-guard.mjs', ['--target', '.', '--scope', 'PARTIAL', '--mode', 'GNB_ONLY', '--files', 'src/index.css,tailwind.css', '--result', 'index.html', '--json']],
['GUARD_GENERATED_PARTIAL', 'scripts/ai-generation-guard.mjs', ['--target', 'examples/ai-quality/01', '--scope', 'PARTIAL', '--mode', 'GNB_ONLY', '--files', '../shared/SearchList.tsx', '--result', 'index.html', '--json']],
['GUARD_STORYBOOK', 'scripts/ai-generation-guard.mjs', ['--target', '.', '--scope', 'FULL_SCREEN', '--profile', 'STORYBOOK', '--json']],
['GUARD_STORYBOOK_PARTIAL', 'scripts/ai-generation-guard.mjs', ['--target', '.', '--scope', 'PARTIAL', '--profile', 'STORYBOOK', '--files', 'src/patterns/filter-bar.stories.tsx', '--json']],
];
const selected = process.argv.slice(2);
const results = [];
for (const [name, script, args] of jobs.filter(j => !selected.length || selected.includes(j[0]))) {
const begin = Date.now();
const r = spawnSync(process.execPath, [...(script ? [script] : []), ...args], { encoding: 'utf8', maxBuffer: 25 * 1024 * 1024 });
fs.writeFileSync(path.join(output, `${name}.stdout.log`), r.stdout ?? '');
fs.writeFileSync(path.join(output, `${name}.stderr.log`), r.stderr ?? '');
let report;
if (name.startsWith('GUARD_') && name !== 'GUARD_TEST') { try { report = JSON.parse(r.stdout); } catch {} }
const item = { name, command: [process.execPath, ...(script ? [script] : []), ...args], exit: r.status, result: report?.status ?? (r.status === 0 ? 'PASS' : 'FAIL'), ...(report ? { staticStatus: report.staticStatus, issueCounts: Object.fromEntries([...new Set(report.issues.map(i => i.code))].map(code => [code, report.issues.filter(i => i.code === code).length])) } : {}), durationMs: Date.now() - begin, error: r.error?.message };
results.push(item); console.log(JSON.stringify(item));
}
fs.writeFileSync(path.join(output, selected.length ? `regression-${selected.join('-')}.json` : 'regression.json'), JSON.stringify(results, null, 2));
if (results.some(r => r.exit !== 0)) process.exitCode = 1;
+140
View File
@@ -0,0 +1,140 @@
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { spawnSync } from 'node:child_process';
import { combineEvidence } from './guard-evidence.mjs';
const output = path.resolve(process.env.HM_QUALITY_OUTPUT ?? '_local/quality-20261010');
const base = process.env.HM_QUALITY_URL ?? 'http://127.0.0.1:6321';
const playwright = process.env.HM_PLAYWRIGHT_DIR;
const require = createRequire(playwright ? path.join(playwright, 'package.json') : import.meta.url);
const { chromium } = require('playwright');
const browser = await chromium.launch({ headless: true, ...(process.env.HM_CHROME_PATH ? { executablePath: process.env.HM_CHROME_PATH } : {}) });
fs.mkdirSync(output, { recursive: true });
const summary = [];
const wanted = process.argv.slice(2);
try {
for (let n = 1; n <= 10; n++) {
const id = String(n).padStart(2, '0');
if (wanted.length && !wanted.includes(id)) continue;
const target = path.resolve('examples/ai-quality', id);
const spec = JSON.parse(fs.readFileSync(path.join(target, 'requirements.json')));
const result = spawnSync(process.execPath, ['scripts/ai-generation-guard.mjs', '--target', target, '--scope', 'FULL_SCREEN', '--mode', spec.mode, '--result', 'index.html', '--json'], { encoding: 'utf8' });
if (result.error) throw result.error;
const guard = JSON.parse(result.stdout);
fs.writeFileSync(path.join(output, `guard-${id}.json`), result.stdout);
const runtime = { sourceFingerprint: guard.sourceFingerprint, contractFingerprint: guard.contractFingerprint, capturedAt: new Date().toISOString(), url: `${base}/${id}/index.html`, viewports: [], interactions: [] };
for (const width of [390, 768, 1280, 1440]) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
page.on('console', e => { if (e.type() === 'error') errors.push(e.text()); });
try {
await page.goto(runtime.url, { waitUntil: 'networkidle' });
await page.locator('#root [data-slot]:visible').first().waitFor();
await page.evaluate(() => document.fonts.ready);
const metrics = await page.evaluate(() => {
const visible = e => !!e.getClientRects().length && getComputedStyle(e).visibility !== 'hidden' && getComputedStyle(e).display !== 'none';
const ids = [...document.querySelectorAll('[id]')].map(e => e.id);
const controls = [...document.querySelectorAll('input:not([type="hidden"]),textarea,select,button,[role="checkbox"],[role="switch"]')].filter(visible).filter(e => !e.closest('[aria-hidden="true"]'));
const name = e => e.getAttribute('aria-label') || (e.getAttribute('aria-labelledby') ?? '').split(/\s+/).map(id => document.getElementById(id)?.textContent ?? '').join('').trim() || [...(e.labels ?? [])].map(l => l.textContent).join('').trim() || e.textContent?.trim();
const dashboard = document.querySelector('[data-slot="dashboard-scroll"]');
const frame = document.querySelector('[data-slot="dashboard-layout"]');
const header = document.querySelector('[data-slot="dashboard-header"]');
const flex = [...document.querySelectorAll('.flex')].find(visible);
const grid = [...document.querySelectorAll('.grid')].find(visible);
const tabs = [...document.querySelectorAll('[role="tab"][aria-selected="true"]')];
return {
pageOverflow: document.documentElement.scrollWidth > innerWidth + 1,
duplicateIds: ids.filter((id, i) => ids.indexOf(id) !== i),
unnamedControls: controls.filter(e => !name(e)).map(e => ({ tag: e.tagName, slot: e.dataset.slot })),
brokenActiveTabs: tabs.filter(e => !document.getElementById(e.getAttribute('aria-controls'))).map(e => e.id),
theme: getComputedStyle(document.documentElement).getPropertyValue('--hm-sem-fg-primary').trim(),
fontReady: document.fonts.check('16px "Noto Sans KR"'),
fontFamily: getComputedStyle(document.body).fontFamily,
utilityFlex: flex ? getComputedStyle(flex).display : null, utilityGrid: grid ? getComputedStyle(grid).display : null,
dashboard: dashboard ? { headerHeight: header.getBoundingClientRect().height, frameWidth: frame.getBoundingClientRect().width, internalOverflow: dashboard.scrollWidth > dashboard.clientWidth, overflowX: getComputedStyle(dashboard).overflowX } : null,
patternSlots: [...new Set([...document.querySelectorAll('[data-slot]')].filter(visible).map(e => e.dataset.slot))],
filterLayout: document.querySelector('[data-slot="filter-bar"]')?.dataset.layout ?? null,
actionNames: [...document.querySelectorAll('main button')].filter(visible).filter(e => e.dataset.slot === 'button').map(name),
};
});
const issues = [];
if (metrics.pageOverflow) issues.push('page overflow');
if (metrics.duplicateIds.length || metrics.unnamedControls.length || metrics.brokenActiveTabs.length) issues.push('accessible identity');
if (!metrics.theme || !metrics.fontReady) issues.push('theme / font not applied');
if (metrics.utilityFlex && metrics.utilityFlex !== 'flex' || metrics.utilityGrid && metrics.utilityGrid !== 'grid') issues.push('utilities not applied');
if (metrics.dashboard && (metrics.dashboard.headerHeight !== 48 || metrics.dashboard.frameWidth < 960 || width < 960 && !metrics.dashboard.internalOverflow)) issues.push('Dashboard geometry');
if (metrics.actionNames.some(name => !spec.actions.includes(name))) issues.push('unrequested main action');
if (errors.length) issues.push('runtime error');
await page.screenshot({ path: path.join(output, `${id}-${width}.png`), fullPage: true });
runtime.viewports.push({ width, status: issues.length ? 'FAIL' : 'PASS', issues, errors, metrics });
} catch (e) { runtime.viewports.push({ width, status: 'FAIL', error: e.message, errors }); }
finally { await page.close(); }
}
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
await page.goto(runtime.url, { waitUntil: 'networkidle' });
const check = async (name, action) => { try { await action(); runtime.interactions.push({ name, status: 'PASS' }); } catch (e) { runtime.interactions.push({ name, status: 'FAIL', error: e.message }); } };
const assert = (condition, message) => { if (!condition) throw Error(message); };
if (n <= 4) {
await check('draft / apply / empty / reset', async () => {
const search = page.getByRole('searchbox', { name: '이름 검색' });
const result = page.getByRole('status').filter({ hasText: '조회 결과' });
await search.fill('김'); assert((await result.innerText()).includes('3건'), 'draft applied too early');
await page.getByRole('button', { name: '조회', exact: true }).click(); assert((await result.innerText()).includes('1건'), 'query not applied');
await search.fill('없는이름'); await page.getByRole('button', { name: '조회', exact: true }).click(); assert((await result.innerText()).includes('0건'), 'empty result missing');
await page.getByRole('button', { name: '초기화', exact: true }).click(); assert((await result.innerText()).includes('3건') && await search.inputValue() === '', 'reset incomplete');
});
await check('clear restores keyboard focus', async () => {
const search = page.getByRole('searchbox', { name: '이름 검색' }); await search.fill('김');
await page.getByRole('button', { name: '검색어 지우기' }).focus(); await page.keyboard.press('Enter');
assert(await search.evaluate(e => e === document.activeElement) && await search.inputValue() === '', 'focus lost after clear');
});
if (n === 3 || n === 4) await check('overlay name / Escape / focus return / confirmation', async () => {
const trigger = page.getByRole('button', { name: n === 3 ? '결재 확인' : '채용 상세', exact: true });
await trigger.click(); await page.getByRole('dialog', { name: n === 3 ? '결재 확인' : '채용 상세' }).waitFor();
await page.keyboard.press('Escape'); await page.getByRole('dialog').waitFor({ state: 'hidden' });
assert(await trigger.evaluate(e => e === document.activeElement), 'overlay focus return');
await trigger.click(); await page.getByRole('button', { name: n === 3 ? '승인' : '확인', exact: true }).click();
assert((await page.getByRole('status').filter({ hasText: '조회 결과' }).innerText()).includes('처리 완료'), 'confirmation not applied');
});
} else if (n === 5) await check('invalid first focus / label focus / successful submit', async () => {
await page.getByRole('button', { name: '제출', exact: true }).click();
const name = page.getByRole('textbox', { name: '요청자 이름' }); assert(await name.evaluate(e => e === document.activeElement), 'first invalid field not focused');
await name.fill('김한맥'); await page.getByText('처리에 동의합니다', { exact: true }).click();
await page.getByRole('button', { name: '제출', exact: true }).click(); assert((await page.getByRole('status').innerText()).includes('제출되었습니다'), 'submission failed');
});
else if (n === 6) await check('readOnly / checkbox / switch / save', async () => {
const account = page.getByRole('textbox', { name: '계정' }); assert(await account.getAttribute('readonly') !== null, 'readOnly missing');
await page.getByRole('checkbox', { name: '편집 권한' }).click(); await page.getByRole('switch', { name: '알림 수신' }).click();
await page.getByRole('button', { name: '저장' }).click(); assert((await page.getByRole('status').innerText()).includes('편집 허용 · 알림 꺼짐'), 'settings mismatch');
});
else if (n === 7 || n === 8) await check('period keyboard / active panel / metric update', async () => {
await page.getByRole('tab', { name: '이번 주' }).focus(); await page.keyboard.press('ArrowRight');
await page.getByRole('tab', { name: '이번 달' }).click(); assert(await page.getByRole('tabpanel').getByText('48건', { exact: true }).count() === 1, 'monthly metrics missing');
if (n === 8) assert(await page.getByRole('table').getByText('48', { exact: true }).count() === 1, 'monthly aggregation missing');
});
else if (n === 9) await check('Drawer Escape / focus return / apply', async () => {
const trigger = page.getByRole('button', { name: '작업 변경 열기' }); await trigger.click(); await page.getByRole('dialog', { name: '작업 변경', exact: true }).waitFor();
await page.keyboard.press('Escape'); await page.getByRole('dialog').waitFor({ state: 'hidden' }); assert(await trigger.evaluate(e => e === document.activeElement), 'Drawer focus return');
await trigger.click(); await page.getByRole('button', { name: '적용', exact: true }).click(); assert((await page.getByRole('status').innerText()).includes('적용 완료'), 'apply failed');
});
else await check('loading / error / retry / empty', async () => {
await page.getByRole('status', { name: '처리 중' }).waitFor(); await page.getByRole('button', { name: '오류', exact: true }).click(); await page.getByRole('alert').waitFor();
await page.getByRole('button', { name: '다시 시도' }).click(); assert((await page.getByRole('status').innerText()).includes('등록된 항목이 없습니다'), 'retry / empty missing');
});
if (spec.mode !== 'DASHBOARD_ONLY') await check('mobile menu Escape / focus return', async () => {
await page.setViewportSize({ width: 390, height: 900 });
const menu = page.getByRole('button', { name: `${spec.title} 메뉴`, exact: true }); await menu.click(); await page.getByRole('dialog').waitFor();
await page.keyboard.press('Escape'); await page.getByRole('dialog').waitFor({ state: 'hidden' }); assert(await menu.evaluate(e => e === document.activeElement), 'mobile focus return');
});
await page.close();
fs.writeFileSync(path.join(output, `runtime-${id}.json`), JSON.stringify(runtime, null, 2));
const combined = combineEvidence(guard, runtime);
fs.writeFileSync(path.join(output, `combined-${id}.json`), JSON.stringify(combined, null, 2));
summary.push({ id, title: spec.title, difficulty: spec.difficulty, mode: spec.mode, rawGuard: guard.status, staticStatus: guard.staticStatus, combinedStatus: combined.status, runtimeStatus: combined.runtimeStatus, staticIssues: guard.issues.filter(i => !i.code.startsWith('RENDER-')), viewports: runtime.viewports.map(({ width, status, issues, error }) => ({ width, status, issues, error })), interactions: runtime.interactions });
console.log(JSON.stringify(summary.at(-1)));
}
} finally { await browser.close(); }
fs.writeFileSync(path.join(output, 'runtime-summary.json'), JSON.stringify(summary, null, 2));
if (summary.some(s => s.combinedStatus === 'FAIL')) process.exitCode = 1;