Files
HMDesign/scripts/guard-style-analysis.mjs
T

402 lines
27 KiB
JavaScript

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 [];
}
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, parseJS) {
const classes = new Set(), componentClasses = new Map();
let uncertain = incomplete;
const usedComponents = [];
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);
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);
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;
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;
});
}
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, '');
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 && 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, 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, parseJS, selectedFile, knownSlot, origin, add, storybook, entries = [], scopeIncomplete = true, scopeRecords = records, implementationFiles = [] }) {
const checks = [];
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);
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()];
}