fix: prevent false CSS class matches in AI guard

This commit is contained in:
shkim4 committed 2026-10-11 15:07:50 +09:00
1 parent 26d78763cc
commit d6d6eb53af
3 files changed
+434 -10

No files matched your search

+2 -2
View File
@@ -90,7 +90,7 @@ 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();
// Parse public barrels and manifest exports for import contracts.
// Implementation text is only a conservative blocker for CSS scope exclusions.
// Implementation class bindings only conservatively block 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'));
@@ -465,7 +465,7 @@ async function inspect(options) {
&& (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)) });
styleChecks = styleAnalysis({ records: [...graph.values()].filter(r => applicableFiles.has(r.file)), target, postcss, walk, parseJS, 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 };
+161
View File
@@ -7,6 +7,9 @@ import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import test from 'node:test';
import { parse } from '@babel/parser';
import postcss from 'postcss';
import { styleAnalysis } from './guard-style-analysis.mjs';
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 => {
@@ -861,3 +864,161 @@ test('86. Component implementation classes and unrelated graph mutations stay pr
assert.equal(r.styleEvidence.checks.find(c => c.selector.startsWith('.legacy')).applicability.classInputsComplete, false);
});
});
// Exercise implementation binding analysis without editing the real packages.
function implementationFixture(source, callback, extra = {}) {
fixture({
'src/App.tsx': 'export default () => <Fixture/>;',
'src/styles.css': '.legacy [data-slot="button"] { padding: 17px; }',
'packages/ui/src/index.ts': 'export { Fixture } from "./fixture";',
'packages/ui/src/fixture.tsx': source,
...extra,
}, root => {
const parseJS = (text, file) => parse(text, { sourceType: 'unambiguous', plugins: [...(/\.[cm]?tsx?$/.test(file) ? ['typescript'] : []), 'jsx'] });
const walk = (node, fn, parent = null) => {
if (!node || typeof node !== 'object') return;
if (node.type) fn(node, parent);
for (const [key, value] of Object.entries(node)) {
if (['loc', 'start', 'end', 'comments', 'tokens', 'extra'].includes(key)) continue;
if (Array.isArray(value)) value.forEach(x => walk(x, fn, node));
else if (value && typeof value === 'object') walk(value, fn, node);
}
};
const file = path.join(root, 'src/App.tsx'), ast = parseJS(fs.readFileSync(file, 'utf8'), file), renders = [];
walk(ast, n => {
if (n.type === 'JSXOpeningElement') renders.push({ name: n.name.name, attrs: new Map(n.attributes.filter(a => a.type === 'JSXAttribute').map(a => [a.name.name, a.value?.type === 'JSXExpressionContainer' ? a.value.expression : a.value])) });
});
const implementationFiles = dir => fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => e.isDirectory() ? implementationFiles(path.join(dir, e.name)) : [path.join(dir, e.name)]);
const issues = [];
const checks = styleAnalysis({ records: [{ file: path.join(root, 'src/styles.css'), css: true }], scopeRecords: [{ file, ast, renders }], target: root, postcss, walk, parseJS, selectedFile: () => true, knownSlot: s => s === 'button', origin: (_, name) => name === 'Fixture' ? { pkg: '@hm-design/ui', name } : null, add: (severity, code) => issues.push({ severity, code }), storybook: false, scopeIncomplete: false, entries: [path.join(root, 'index.html')], implementationFiles: implementationFiles(path.join(root, 'packages/ui/src')) });
callback({ issues, checks });
});
}
test('87. implementation comments non-class strings and partial tokens never prove an ancestor', () => {
for (const source of [
'// legacy remains documented\nexport const Fixture = () => <div className="work"/>;',
'const title = "legacy"; export const Fixture = () => <div title={title} className="work"/>;',
'export const Fixture = () => <div className="legacyish pre-legacy legacy/post"/>;',
'import { cva } from "class-variance-authority"; const unused = cva("legacy"); export const Fixture = () => <div className="work"/>;',
'import { cva } from "class-variance-authority"; const variants = cva("work", {variants:{mode:{legacy:"work"}}, defaultVariants:{mode:"legacy"}}); export const Fixture = () => <div className={variants()}/>;',
]) implementationFixture(source, ({ issues, checks }) => {
assert.ok(!issues.some(i => i.code === 'STYLE-001'));
assert.equal(checks[0].applicability.implementationClassPossible, false);
assert.equal(checks[0].applicability.status, 'NOT APPLICABLE');
assert.equal(checks[0].staticStatus, 'PASS');
assert.equal(checks[0].runtimeStatus, 'NOT VERIFIED');
assert.equal(checks[0].designIntentStatus, 'NOT VERIFIED');
});
});
test('88. implementation exact JSX factory joiner and variant class tokens remain protected', () => {
for (const source of [
'export const Fixture = () => <div className="work legacy"/>;',
'export const Fixture = () => <div class="legacy"/>;',
'import React from "react"; export const Fixture = () => React.createElement("div", { className: "legacy" });',
'import { cn } from "cn"; export const Fixture = () => <div className={cn("work", enabled && "legacy")}/>;',
'import cx from "clsx"; export const Fixture = () => <div className={cx(["work", {legacy:enabled}])}/>;',
'import { cva as variants, type VariantProps } from "class-variance-authority"; import { cn } from "cn"; const styles = variants("work", {variants:{mode:{on:"legacy",off:"work"}}, compoundVariants:[{mode:"off",className:"legacy"}],defaultVariants:{mode:"off"}}); export const Fixture = ({className,mode}: VariantProps<typeof styles>) => <div className={cn(styles({mode,className}))}/>;',
'import { cva } from "class-variance-authority"; const styles = cva("work", {compoundVariants:[{class:"legacy"}]}); export const Fixture = () => <div className={styles()}/>;',
'export const Fixture = ({className="legacy"}) => <div className={className}/>;',
]) implementationFixture(source, ({ issues, checks }) => {
assert.ok(issues.some(i => i.code === 'STYLE-001' && i.severity === 'FAIL'));
assert.equal(checks[0].applicability.implementationClassPossible, true);
assert.equal(checks[0].staticStatus, 'FAIL');
});
});
test('89. imported implementation constants variants aliases and barrels retain class evidence', () => {
for (const [source, helper] of [
['import { name as applied } from "./barrel"; export const Fixture = () => <div className={applied}/>;', 'export const name = "legacy";'],
['import { styles } from "./barrel"; export const Fixture = () => <div className={styles()}/>;', 'import { cva } from "class-variance-authority"; export const styles = cva("legacy");'],
['import { name } from "./nested"; export const Fixture = () => <div className={name}/>;', 'export const name = "legacy";'],
]) implementationFixture(source, ({ issues, checks }) => {
assert.ok(issues.some(i => i.code === 'STYLE-001'));
assert.equal(checks[0].applicability.implementationClassPossible, true);
}, { 'packages/ui/src/barrel.ts': 'export * from "./helper";', 'packages/ui/src/nested/index.ts': 'export { name } from "../helper.js";', 'packages/ui/src/helper.ts': helper });
});
test('90. unresolved connected implementation inputs cannot prove scope exclusion', () => {
for (const source of [
'export const Fixture = ({value}) => <div className={value}/>;',
'export const Fixture = () => <div className={computeClass()}/>;',
'function cn(){return location.hash;} export const Fixture = () => <div className={cn()}/>;',
'import { cn } from "cn"; export const Fixture = ({cn}) => <div className={cn()}/>;',
'import { cn } from "cn"; export function Fixture(){ function cn(){return location.hash;} return <div className={cn()}/>; }',
'import { cn } from "cn"; export function Fixture(){ try { throw makeClass(); } catch(cn){ return <div className={cn()}/>; } }',
'export const Fixture = ({value}) => <div {...value}/>;',
'export const Fixture = ({props}) => <div className={props.className}/>;',
'export const Fixture = ({legacy:className}) => <div className={className}/>;',
'export const Fixture = (props=getProps()) => <div className={props.className}/>;',
'const Other=({className}) => <div className={className}/>; export const Fixture = ({legacy:className}) => <div className={className}/>;',
'const Other=(props) => <div className={props.className}/>; export const Fixture = ({props}) => <div className={props.className}/>;',
'export const Fixture = () => <div className={`prefix-${location.hash}`}/>;',
'const opaque = getProps(); export const Fixture = () => <div {...opaque}/>;',
'document.body.classList.add(location.hash); export const Fixture = () => <div/>;',
'import "./mutation"; export const Fixture = () => <div/>;',
'import("./mutation"); export const Fixture = () => <div/>;',
'import { value } from "./missing"; export const Fixture = () => <div className={value}/>;',
'export const Fixture = () => <div',
]) implementationFixture(source, ({ issues, checks }) => {
assert.ok(issues.some(i => i.code === 'STYLE-001'));
assert.equal(checks[0].applicability.status, 'POSSIBLE');
assert.equal(checks[0].applicability.classInputsComplete, false);
assert.ok(checks[0].applicability.unresolvedImplementationFiles.length);
}, { 'packages/ui/src/mutation.ts': 'document.body.className = location.hash;' });
});
test('91. missing ancestors retain CSS inventory and unverified intent in the real Guard', () => {
for (const ancestor of ['legacy', 'hm_unused_screen_audit', 'hm_guard_absent_9e4b']) fixture({
'src/App.tsx': 'import { Button } from "@hm-design/ui"; const title="legacy"; export default () => <div title={title} className="legacyish"><Button/></div>;',
'src/styles.css': '@import "./tokens.css"; /* legacy */ .'+ancestor+' [data-slot="button"] { padding:17px; }',
}, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.ok(!r.issues.some(i => i.code === 'STYLE-001'));
const c = r.styleEvidence.checks.find(c => c.component === 'button');
assert.equal(c.applicability.status, 'NOT APPLICABLE');
assert.equal(r.styleEvidence.staticStatus, 'PASS');
assert.equal(r.styleEvidence.finalStatus, 'NOT VERIFIED');
assert.equal(r.status, 'NOT VERIFIED');
assert.ok(r.issues.some(i => i.code === 'REUSE-003' && i.severity === 'NOT VERIFIED'));
});
});
test('92. unique dynamic ancestor controls cannot pass because of library substring coincidences', () => {
const ancestor = 'hm_guard_dynamic_9e4b';
for (const app of [
'const name=location.hash?"'+ancestor+'":"work"; export default () => <div className={name}><Button/></div>;',
'export default ({className}) => <div className={className}><Button/></div>;',
'export default ({props}) => <div {...props}><Button/></div>;',
'document.body["classList"].add(location.hash); export default () => <Button/>;',
'import "./mutation"; export default () => <Button/>;',
'function cn(){return location.hash;} export default () => <div className={cn()}><Button/></div>;',
'import { cn } from "cn"; export default ({cn}) => <div className={cn()}><Button/></div>;',
'import { cn } from "cn"; export default function App(){function cn(){return location.hash;} return <div className={cn()}><Button/></div>}',
]) fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; '+app, 'src/mutation.ts': 'document.body.className = location.hash;', 'src/styles.css': '@import "./tokens.css"; .'+ancestor+' [data-slot="button"] { padding:17px; }' }, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.ok(r.issues.some(i => i.code === 'STYLE-001' && i.severity === 'FAIL'));
assert.equal(r.styleEvidence.checks.find(c => c.component === 'button').applicability.implementationClassPossible, false);
});
});
test('93. actual internal flex and public variant classes remain exact tokens', () => {
for (const ancestor of ['flex', 'inline-flex', 'group', 'h-8']) fixture({ 'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button/>;', 'src/styles.css': '@import "./tokens.css"; .'+ancestor+' [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.component === 'button').applicability.implementationClassPossible, true);
});
});
test('94. consumer class joiners match exact tokens and preserve unknown-class review', () => {
for (const present of [false, true]) fixture({
'src/App.tsx': 'import { Button } from "@hm-design/ui"; import { clsx as cx } from "clsx"; export default () => <div className={cx("work", enabled && "'+(present ? 'legacy' : 'legacyish')+'")}><Button/></div>;',
'src/styles.css': '@import "./tokens.css"; .legacy [data-slot="button"] { padding:17px; }',
}, root => {
const r = run(root, ['--scope', 'FULL_SCREEN']).report;
assert.equal(r.issues.some(i => i.code === 'STYLE-001'), present);
assert.equal(r.styleEvidence.checks.find(c => c.component === 'button').applicability.status, present ? 'POSSIBLE' : 'NOT APPLICABLE');
assert.ok(r.issues.some(i => i.code === 'TOKEN-002' && i.severity === 'NOT VERIFIED'));
});
});
+271 -8
View File
@@ -26,14 +26,260 @@ function knownStyleStrings(node, constants, seen = new Set()) {
return [];
}
const propertyName = n => n?.computed ? n.property?.value : n?.property?.name;
const nodeName = n => n?.name ?? n?.value;
const classKeys = new Set(['className', 'class']);
const joiners = new Set(['cn', 'clsx', 'classNames', 'twMerge']);
const helperPackages = new Set(['cn', 'clsx', 'classnames', 'tailwind-merge', 'class-variance-authority']);
const helperName = (context, callee) => {
if (context.shadowed?.has(callee.name)) return null;
const imported = context.imports?.get(callee.name);
return imported && helperPackages.has(imported.source) ? imported.name === 'default' ? imported.source === 'classnames' ? 'classNames' : imported.source : imported.name : null;
};
const mergeCandidates = results => ({ values: results.flatMap(r => r.values), unknown: results.some(r => r.unknown) });
const unknownClasses = () => ({ values: [], unknown: true });
const functionNodes = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', 'ObjectMethod', 'ClassMethod', 'ClassPrivateMethod']);
function bindingNames(node) {
if (!node) return [];
if (node.type === 'Identifier') return [node.name];
if (node.type === 'RestElement') return bindingNames(node.argument);
if (node.type === 'AssignmentPattern') return bindingNames(node.left);
if (node.type === 'ArrayPattern') return node.elements.flatMap(bindingNames);
if (node.type === 'ObjectPattern') return node.properties.flatMap(p => bindingNames(p.type === 'RestElement' ? p.argument : p.value));
return [];
}
// Only expressions feeding a class binding are evidence. Never search comments,
// arbitrary strings, CSS selectors or variant names for a substring match.
function classCandidates(node, context, resolveBinding, internal = false, seen = new Set()) {
if (!node) return unknownClasses();
const finite = staticStrings(node, context.constants);
if (finite) return { values: finite, unknown: false };
const recurse = n => classCandidates(n, context, resolveBinding, internal, seen);
if (['JSXExpressionContainer', 'TSAsExpression', 'TSNonNullExpression', 'ParenthesizedExpression'].includes(node.type)) return recurse(node.expression);
if (['BooleanLiteral', 'NullLiteral'].includes(node.type)) return { values: [], unknown: false };
if (node.type === 'ConditionalExpression') return mergeCandidates([recurse(node.consequent), recurse(node.alternate)]);
if (node.type === 'LogicalExpression') return node.operator === '&&' ? recurse(node.right) : mergeCandidates([recurse(node.left), recurse(node.right)]);
if (node.type === 'ArrayExpression') return mergeCandidates(node.elements.filter(Boolean).map(recurse));
if (node.type === 'ObjectExpression') return mergeCandidates(node.properties.map(p => {
if (p.type !== 'ObjectProperty') return unknownClasses();
if (p.value.type === 'BooleanLiteral' && !p.value.value || p.value.type === 'NullLiteral') return { values: [], unknown: false };
return p.computed ? recurse(p.key) : { values: [String(nodeName(p.key))], unknown: false };
}));
if (node.type === 'Identifier') {
const key = `${context.file}:${node.name}`;
if (seen.has(key)) return unknownClasses();
const binding = resolveBinding(context, node.name);
if (binding?.node) return classCandidates(binding.node, binding.context, resolveBinding, internal, new Set([...seen, key]));
// Public className forwarding is already checked at consumer call sites.
if (internal && context.forwardedClasses.has(node.name) && !context.declared.has(node.name)) return { values: [], unknown: false };
return unknownClasses();
}
if (node.type === 'MemberExpression' && internal && classKeys.has(propertyName(node)) && context.forwardedProps.has(node.object?.name) && !context.declared.has(node.object.name)) return { values: [], unknown: false };
if (node.type === 'CallExpression') {
const name = node.callee.name ?? propertyName(node.callee);
const helper = helperName(context, node.callee);
if (joiners.has(helper)) return mergeCandidates(node.arguments.map(recurse));
if (helper === 'cva') {
const config = node.arguments[1];
const results = node.arguments[0] ? [recurse(node.arguments[0])] : [];
if (config && config.type !== 'ObjectExpression') results.push(unknownClasses());
for (const p of config?.properties ?? []) {
if (p.type !== 'ObjectProperty' || p.computed) { results.push(unknownClasses()); continue; }
if (nodeName(p.key) === 'variants') {
if (p.value.type !== 'ObjectExpression') { results.push(unknownClasses()); continue; }
for (const variant of p.value.properties) {
if (variant.type !== 'ObjectProperty' || variant.value.type !== 'ObjectExpression') { results.push(unknownClasses()); continue; }
results.push(...variant.value.properties.map(option => option.type === 'ObjectProperty' ? recurse(option.value) : unknownClasses()));
}
}
if (nodeName(p.key) === 'compoundVariants') {
if (p.value.type !== 'ArrayExpression') { results.push(unknownClasses()); continue; }
for (const item of p.value.elements) {
if (item?.type !== 'ObjectExpression') { results.push(unknownClasses()); continue; }
for (const field of item.properties) {
if (field.type !== 'ObjectProperty' || field.computed) results.push(unknownClasses());
else if (classKeys.has(nodeName(field.key))) results.push(recurse(field.value));
}
}
}
}
return mergeCandidates(results);
}
const binding = node.callee.type === 'Identifier' ? resolveBinding(context, node.callee.name) : null;
if (binding?.node?.type === 'CallExpression' && helperName(binding.context, binding.node.callee) === 'cva') {
const results = [classCandidates(binding.node, binding.context, resolveBinding, internal, seen)];
for (const arg of node.arguments) {
if (arg.type !== 'ObjectExpression') { results.push(unknownClasses()); continue; }
for (const p of arg.properties) {
if (p.type !== 'ObjectProperty' || p.computed) results.push(unknownClasses());
else if (classKeys.has(nodeName(p.key))) results.push(recurse(p.value));
}
}
return mergeCandidates(results);
}
if (name === 'trim') return recurse(node.callee.object);
if (name === 'join' && node.arguments[0]?.value === ' ') return recurse(node.callee.object);
// An unrelated function named cn/clsx is opaque, not a proven joiner.
if (joiners.has(name)) return mergeCandidates([...node.arguments.map(recurse), unknownClasses()]);
}
return unknownClasses();
}
function implementationClasses(files, parseJS, walk, usedComponents) {
const classes = new Set(), modules = new Map(), fileSet = new Set(files.map(f => path.resolve(f)));
const resolveFile = (file, spec) => {
if (!spec?.startsWith('.')) return null;
const base = path.resolve(path.dirname(file), spec);
return [base, ...['.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx'].map(ext => base + ext), ...(/\.[cm]?js$/.test(base) ? [base.replace(/\.[cm]?js$/, '.ts'), base.replace(/\.[cm]?js$/, '.tsx')] : [])].map(f => path.normalize(f)).find(f => fileSet.has(f));
};
for (const file of fileSet) {
if (!/\.[cm]?[jt]sx?$/.test(file)) continue;
const context = { file, constants: new Map(), imports: new Map(), exports: new Map(), stars: [], dependencies: new Set(), forwardedClasses: new Set(), forwardedProps: new Set(), declared: new Set(), shadowed: new Set(), parents: new WeakMap(), scopes: new WeakMap(), unknown: false };
modules.set(file, context);
try { context.ast = parseJS(fs.readFileSync(file, 'utf8'), file); } catch { context.unknown = true; continue; }
// Parameter type references (VariantProps<typeof buttonVariants>) are not
// bindings and must not shadow the immutable class factory they describe.
for (const stmt of context.ast.program.body) {
const declaration = stmt.type === 'ExportNamedDeclaration' ? stmt.declaration : stmt;
if (declaration?.type === 'VariableDeclaration' && declaration.kind === 'const') for (const d of declaration.declarations) if (d.id.type === 'Identifier') context.constants.set(d.id.name, d.init);
}
for (const stmt of context.ast.program.body) {
if (stmt.type === 'ImportDeclaration' && stmt.importKind !== 'type') {
const dependency = resolveFile(file, stmt.source.value);
if (stmt.source.value.startsWith('.') && !dependency) context.unknown = true;
if (dependency) context.dependencies.add(dependency);
for (const s of stmt.specifiers.filter(s => s.importKind !== 'type')) context.imports.set(s.local.name, { file: dependency, source: stmt.source.value, name: s.type === 'ImportSpecifier' ? nodeName(s.imported) : s.type === 'ImportDefaultSpecifier' ? 'default' : '*' });
}
if (stmt.type === 'ExportAllDeclaration' && stmt.exportKind !== 'type') {
const dependency = resolveFile(file, stmt.source.value);
context.stars.push(dependency);
if (dependency) context.dependencies.add(dependency);
else context.unknown = true;
}
if (stmt.type === 'ExportNamedDeclaration' && stmt.exportKind !== 'type') {
if (stmt.source) {
const dependency = resolveFile(file, stmt.source.value);
if (dependency) context.dependencies.add(dependency);
else if (stmt.source.value.startsWith('.')) context.unknown = true;
}
for (const s of stmt.specifiers) context.exports.set(nodeName(s.exported), { file: stmt.source ? resolveFile(file, stmt.source.value) : file, name: nodeName(s.local) });
if (stmt.declaration?.id) context.exports.set(stmt.declaration.id.name, { file, name: stmt.declaration.id.name });
for (const d of stmt.declaration?.declarations ?? []) if (d.id.type === 'Identifier') context.exports.set(d.id.name, { file, name: d.id.name });
}
if (stmt.type === 'ExportDefaultDeclaration') context.exports.set('default', { file, name: stmt.declaration.id?.name ?? stmt.declaration.name ?? 'default' });
}
walk(context.ast, (n, parent) => {
if (parent) context.parents.set(n, parent);
if (n.type === 'VariableDeclarator' || functionNodes.has(n.type) && n.id) for (const name of bindingNames(n.id)) {
context.declared.add(name);
if (context.imports.has(name)) context.shadowed.add(name);
if (context.constants.get(name) !== n.init) context.constants.delete(name);
}
const params = functionNodes.has(n.type) ? n.params : n.type === 'CatchClause' ? [n.param] : [];
for (const p of params) for (const name of bindingNames(p)) {
if (context.imports.has(name)) context.shadowed.add(name);
context.constants.delete(name);
}
});
}
const bindingContext = (context, node) => {
let owner = node;
while (owner && !functionNodes.has(owner.type)) owner = context.parents.get(owner);
if (!owner) return context;
if (context.scopes.has(owner)) return context.scopes.get(owner);
const scope = { ...context, forwardedClasses: new Set(), forwardedProps: new Set() };
for (const p of owner.params) {
if (p.type === 'Identifier') scope.forwardedProps.add(p.name);
if (p.type === 'ObjectPattern') for (const field of p.properties) {
if (field.type === 'RestElement' && field.argument.type === 'Identifier') scope.forwardedProps.add(field.argument.name);
if (field.type !== 'ObjectProperty' || field.computed || !classKeys.has(nodeName(field.key))) continue;
const binding = field.value.type === 'AssignmentPattern' ? field.value.left : field.value;
if (binding.type === 'Identifier' && binding.name === nodeName(field.key)) scope.forwardedClasses.add(binding.name);
}
}
context.scopes.set(owner, scope);
return scope;
};
const exported = (file, name, seen = new Set()) => {
const key = `${file}:${name}`, context = modules.get(file);
if (!context || seen.has(key)) return null;
const next = new Set([...seen, key]), binding = context.exports.get(name);
if (binding) return binding.file === file ? { context, name: binding.name } : exported(binding.file, binding.name, next);
for (const star of context.stars) { const result = exported(star, name, next); if (result) return result; }
return null;
};
const resolveBinding = (context, name) => {
if (context.constants.has(name)) return { context, node: context.constants.get(name) };
const imported = context.imports.get(name), binding = imported?.file && exported(imported.file, imported.name);
return binding ? resolveBindingLocal(binding.context, binding.name, new Set()) : null;
};
function resolveBindingLocal(context, name, seen) {
const key = `${context.file}:${name}`;
if (seen.has(key)) return null;
seen.add(key);
if (context.constants.has(name)) return { context, node: context.constants.get(name) };
const imported = context.imports.get(name), binding = imported?.file && exported(imported.file, imported.name);
return binding ? resolveBindingLocal(binding.context, binding.name, seen) : null;
}
for (const context of modules.values()) if (context.ast) walk(context.ast, n => {
const scope = bindingContext(context, n);
let value;
if (n.type === 'JSXAttribute' && classKeys.has(n.name.name)) value = n.value;
if (n.type === 'CallExpression' && /^(?:createElement|_?jsx|_?jsxs|jsxDEV)$/.test(n.callee.name ?? propertyName(n.callee) ?? '')) for (const p of n.arguments[1]?.properties ?? []) if (p.type === 'ObjectProperty' && !p.computed && classKeys.has(nodeName(p.key))) collect(p.value);
if (n.type === 'AssignmentPattern' && classKeys.has(n.left.name)) value = n.right;
if (n.type === 'JSXAttribute' && n.name.name === 'dangerouslySetInnerHTML') context.unknown = true;
if (n.type === 'JSXSpreadAttribute' && !(scope.forwardedProps.has(n.argument.name) && !context.declared.has(n.argument.name))) context.unknown = true;
if (n.type === 'CallExpression') {
const method = propertyName(n.callee);
if (['setAttribute', 'setAttributeNS', 'insertAdjacentHTML', 'write', 'writeln'].includes(method) || propertyName(n.callee?.object) === 'classList') context.unknown = 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' && !(scope.forwardedProps.has(p.argument.name) && !context.declared.has(p.argument.name))))) context.unknown = true;
}
if (n.type === 'ImportExpression' || n.type === 'CallExpression' && (n.callee.type === 'Import' || n.callee.name === 'require')) {
const spec = (n.source ?? n.arguments[0])?.value;
const dependency = typeof spec === 'string' ? resolveFile(context.file, spec) : null;
if (dependency) context.dependencies.add(dependency);
else context.unknown = true;
}
if (n.type === 'AssignmentExpression' && ['className', 'innerHTML', 'outerHTML'].includes(propertyName(n.left))) context.unknown = true;
if (value) collect(value);
function collect(expr) {
const result = classCandidates(expr, bindingContext(context, expr), resolveBinding, true);
for (const v of result.values) for (const token of v.split(/\s+/).filter(Boolean)) classes.add(token);
context.unknown ||= result.unknown;
}
});
// Preserve the conservative public implementation class catalogue. Unknown
// generated classes only block exclusions for connected implementation modules.
const connected = new Set();
const connect = file => {
if (!file || connected.has(file)) return;
connected.add(file);
for (const dependency of modules.get(file)?.dependencies ?? []) connect(dependency);
};
for (const identity of usedComponents) {
const packageDir = identity.pkg === '@hm-design/ui' ? 'ui' : identity.pkg === '@hm-design/center-graphic' ? 'center-graphic' : null;
if (!packageDir) continue;
const entry = [...modules.keys()].find(f => f.replaceAll('\\', '/').endsWith(`/packages/${packageDir}/src/index.ts`));
const binding = exported(entry, identity.name);
if (binding) connect(binding.context.file);
else if (entry) {
// A missing implementation export/parse cannot prove class absence.
modules.get(entry).unknown = true;
connect(entry);
}
}
const unresolvedFiles = [...connected].filter(file => !modules.has(file) || modules.get(file).unknown);
return { classes, incomplete: unresolvedFiles.length > 0, unresolvedFiles };
}
// 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) {
function classScope(records, entries, walk, origin, incomplete, implementationFiles, parseJS) {
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 usedComponents = [];
const memberName = n => n?.computed ? n.property?.value : n?.property?.name;
const collect = (values, component) => {
if (!values) { uncertain = true; return; }
@@ -44,9 +290,24 @@ function classScope(records, entries, walk, origin, incomplete, implementationFi
};
for (const r of records.filter(r => r.ast)) {
const constants = localConstants(r.ast, walk);
const imports = new Map();
for (const stmt of r.ast.program.body) if (stmt.type === 'ImportDeclaration' && stmt.importKind !== 'type') for (const s of stmt.specifiers.filter(s => s.importKind !== 'type')) imports.set(s.local.name, { source: stmt.source.value, name: s.type === 'ImportSpecifier' ? nodeName(s.imported) : s.type === 'ImportDefaultSpecifier' ? 'default' : '*' });
const shadowed = new Set();
walk(r.ast, n => {
const bindings = n.type === 'VariableDeclarator' ? [n.id] : functionNodes.has(n.type) ? [...n.params, n.id] : n.type === 'CatchClause' ? [n.param] : [];
for (const binding of bindings) for (const name of bindingNames(binding)) {
if (imports.has(name)) shadowed.add(name);
if (n.type !== 'VariableDeclarator') constants.delete(name);
}
});
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);
if (identity?.pkg) usedComponents.push(identity);
for (const key of ['className', 'class']) if (render.attrs.has(key)) {
const result = classCandidates(render.attrs.get(key), { file: r.file, constants, imports, shadowed }, (context, name) => context.constants.has(name) ? { context, node: context.constants.get(name) } : null);
collect(result.values, identity?.pkg === '@hm-design/ui' ? identity.name : null);
uncertain ||= result.unknown;
}
}
walk(r.ast, n => {
if (n.type === 'JSXSpreadAttribute' || n.type === 'JSXAttribute' && n.name.name === 'dangerouslySetInnerHTML') uncertain = true;
@@ -58,6 +319,8 @@ function classScope(records, entries, walk, origin, incomplete, implementationFi
if (n.type === 'AssignmentExpression' && ['className', 'innerHTML', 'outerHTML'].includes(memberName(n.left))) uncertain = true;
});
}
const implementation = implementationClasses(implementationFiles, parseJS, walk, usedComponents);
uncertain ||= implementation.incomplete;
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, '');
@@ -73,17 +336,17 @@ function classScope(records, entries, walk, origin, incomplete, implementationFi
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 implementationClassPossible = Boolean(ancestor && implementation.classes.has(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' };
return { status: absent ? 'NOT APPLICABLE' : 'POSSIBLE', requiredAncestorClass: ancestor || null, implementationClassPossible, classInputsComplete: !uncertain, unresolvedImplementationFiles: implementation.unresolvedFiles.map(f => f.replaceAll('\\', '/').split('/packages/').at(-1)), reason: absent ? 'Required positive ancestor class is absent from this complete entry graph, HTML and public implementation class bindings; 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 = [] }) {
export function styleAnalysis({ records, target, postcss, walk, parseJS, selectedFile, knownSlot, origin, add, storybook, entries = [], scopeIncomplete = true, scopeRecords = records, implementationFiles = [] }) {
const checks = [];
const scope = classScope(scopeRecords, entries, walk, origin, scopeIncomplete, implementationFiles);
const scope = classScope(scopeRecords, entries, walk, origin, scopeIncomplete, implementationFiles, parseJS);
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);