139 lines
8.6 KiB
JavaScript
139 lines
8.6 KiB
JavaScript
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) {
|
|
// OrgChart deliberately loads its public CSS inside Shadow DOM via ?url.
|
|
// Inventory that export's declarations, without treating arbitrary raw/url
|
|
// imports as applied styles or collecting tokens from unrelated packages.
|
|
const orgChartCSS = e.spec === '@hm-design/org-chart/style.css' || e.spec === '@hm-design/org-chart/style.css?url';
|
|
if (!e.runtime && !orgChartCSS) continue;
|
|
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',
|
|
'@hm-design/org-chart/style.css': 'packages/org-chart/src/styles/org-chart.css',
|
|
'@hm-design/org-chart/style.css?url': 'packages/org-chart/src/styles/org-chart.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 };
|
|
}
|