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 }; }