139 lines
9.7 KiB
JavaScript
139 lines
9.7 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 [];
|
|
}
|
|
|
|
// 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()];
|
|
}
|