import fs from 'node:fs'; import crypto from 'node:crypto'; import path from 'node:path'; import { parse as parseJavaScript } from '@babel/parser'; import { compare, selectorSpecificity } from '@csstools/selector-specificity'; import postcss from 'postcss'; import selectorParser from 'postcss-selector-parser'; export const STYLE_IMPORT_PATTERN = /^@import ['"](\.\/[^'"]+\.css)['"];$/gm; export function styleLeafPaths(root) { const stylesRoot = path.join(root, 'src/web/styles'); const index = fs.readFileSync(path.join(stylesRoot, 'index.css'), 'utf8'); return Array.from(index.matchAll(STYLE_IMPORT_PATTERN), ([, relativePath]) => ( path.resolve(stylesRoot, relativePath) )); } export function readStyleSource(root) { return styleLeafPaths(root).map((file) => fs.readFileSync(file, 'utf8')).join(''); } function sourceFiles(directory) { return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const target = path.join(directory, entry.name); if (entry.isDirectory()) return sourceFiles(target); return /\.[jt]sx?$/.test(entry.name) ? [target] : []; }); } function classTokens(value) { return value.split(/\s+/).filter((token) => /^-?[_a-zA-Z]+[_a-zA-Z0-9-]*$/.test(token)); } function mergeClassInfo(...values) { return { classes: [...new Set(values.flatMap((value) => value.classes))].sort(), prefixes: [...new Set(values.flatMap((value) => value.prefixes))].sort(), unknown: values.some((value) => value.unknown), }; } function staticStringValues(node, environment, seen = new Set()) { if (!node) return ['']; if (Array.isArray(node)) return null; if (node.type === 'StringLiteral') return [node.value]; if (node.type === 'NumericLiteral') return [String(node.value)]; if (node.type === 'NullLiteral' || node.type === 'BooleanLiteral') return ['']; if (['TSAsExpression', 'TSSatisfiesExpression', 'TypeCastExpression', 'ParenthesizedExpression'].includes(node.type)) { return staticStringValues(node.expression, environment, seen); } if (node.type === 'Identifier') { if (node.name === 'undefined') return ['']; const binding = environment.get(node.name); if (!binding || seen.has(binding)) return null; if (binding.values) return binding.values; return staticStringValues(binding.node, binding.environment, new Set([...seen, binding])); } if (node.type === 'MemberExpression' && !node.computed && node.object?.type === 'Identifier' && node.property?.type === 'Identifier') { return environment.get(`${node.object.name}.${node.property.name}`)?.values || null; } if (node.type === 'CallExpression' && node.arguments.length === 0 && node.callee?.type === 'MemberExpression' && !node.callee.computed && node.callee.property?.name === 'toLowerCase') { const values = staticStringValues(node.callee.object, environment, seen); return values?.map((value) => value.toLowerCase()) || null; } if (node.type === 'ConditionalExpression') { const consequent = staticStringValues(node.consequent, environment, seen); const alternate = staticStringValues(node.alternate, environment, seen); return consequent && alternate ? [...new Set([...consequent, ...alternate])] : null; } if (node.type === 'LogicalExpression' && node.operator === '&&') { const right = staticStringValues(node.right, environment, seen); return right ? [...new Set(['', ...right])] : null; } if (node.type === 'BinaryExpression' && node.operator === '+') { const left = staticStringValues(node.left, environment, seen); const right = staticStringValues(node.right, environment, seen); if (!left || !right || left.length * right.length > 64) return null; return left.flatMap((leftValue) => right.map((rightValue) => leftValue + rightValue)); } if (node.type === 'TemplateLiteral') { let values = [node.quasis[0].value.cooked || node.quasis[0].value.raw]; for (let index = 0; index < node.expressions.length; index += 1) { const expressions = staticStringValues(node.expressions[index], environment, seen); if (!expressions || values.length * expressions.length > 64) return null; const suffix = node.quasis[index + 1].value.cooked || node.quasis[index + 1].value.raw; values = values.flatMap((value) => expressions.map((expression) => value + expression + suffix)); } return values; } return null; } function classExpressionInfo(node, environment = new Map()) { if (!node) return { classes: [], prefixes: [], unknown: false }; if (node.type === 'JSXExpressionContainer') return classExpressionInfo(node.expression, environment); const staticValues = staticStringValues(node, environment); if (staticValues) { return { classes: [...new Set(staticValues.flatMap(classTokens))], prefixes: [], unknown: false, }; } if (node.type === 'TemplateLiteral') { const classes = node.quasis.flatMap((quasi) => classTokens(quasi.value.cooked || quasi.value.raw)); const prefixes = []; for (let index = 0; index < node.expressions.length; index += 1) { const before = node.quasis[index].value.cooked || node.quasis[index].value.raw; const prefix = /(?:^|\s)((?:app|client|harbor|has|is)-[a-z0-9-]*)$/.exec(before)?.[1]; const expression = classExpressionInfo(node.expressions[index], environment); if (prefix) prefixes.push(prefix); classes.push(...expression.classes); prefixes.push(...expression.prefixes); } return { classes, prefixes, unknown: true }; } return { classes: [], prefixes: [], unknown: true }; } function jsxName(node) { if (node.type === 'JSXIdentifier') return node.name; if (node.type === 'JSXMemberExpression') return `${jsxName(node.object)}.${jsxName(node.property)}`; return null; } function elementClassInfo(openingElement, environment, imperativeClasses = []) { let info = { classes: [], prefixes: [], unknown: false }; for (const attribute of openingElement.attributes) { if (attribute.type === 'JSXSpreadAttribute') { info.unknown = true; continue; } if (attribute.name?.name !== 'className') continue; info = mergeClassInfo(info, classExpressionInfo(attribute.value, environment)); } info.classes.push(...imperativeClasses); info.classes = [...new Set(info.classes)].sort(); return info; } function parseWitnessFile(file, source) { try { return parseJavaScript(source, { plugins: [/\.tsx?$/.test(file) ? 'typescript' : 'jsx', /\.[jt]sx$/.test(file) ? 'jsx' : null].filter(Boolean), sourceFilename: file, sourceType: 'module', }); } catch (error) { throw new TypeError(`Unable to parse JSX witness source: ${file}`, { cause: error }); } } function componentDefinitions(files) { const definitions = new Map(); const definitionNodes = new Set(); const register = (name, node, file) => { if (!name || !/^[A-Z]/.test(name)) return; if (definitions.has(name)) throw new TypeError(`Ambiguous JSX component witness owner: ${name}`); definitions.set(name, { file, node }); definitionNodes.add(node); }; const collect = (node, file) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { for (const child of node) collect(child, file); return; } if (node.type === 'FunctionDeclaration') register(node.id?.name, node, file); if (node.type === 'VariableDeclarator' && ['ArrowFunctionExpression', 'FunctionExpression'].includes(node.init?.type)) { register(node.id?.name, node.init, file); } for (const [key, child] of Object.entries(node)) { if (['loc', 'start', 'end', 'extra', 'comments', 'tokens', 'errors'].includes(key)) continue; collect(child, file); } }; for (const file of files) collect(file.parsed.program, file.file); return { definitions, definitionNodes }; } function imperativeClassesByTarget(file, source) { const mutations = [...source.matchAll(/\.classList\.(?:add|remove|toggle|replace)\(([^)]*)\)/g)]; if (!mutations.length) return new Map(); const classes = mutations.flatMap(([, args]) => [...args.matchAll(/['"]([^'"]+)['"]/g)].map((match) => match[1])).sort(); const diagnosticsFile = file.endsWith('/features/diagnostics/ConnectivityDiagnosticsPanel.tsx'); if (!diagnosticsFile || mutations.length !== 3 || JSON.stringify(classes) !== JSON.stringify(['is-moving', 'is-moving', 'is-visible', 'is-visible'])) { throw new TypeError(`Unsupported imperative class mutation in JSX witness source: ${file}`); } return new Map([['client-diagnostics-active-marker', ['is-moving', 'is-visible']]]); } function literalUnion(source, pattern, label) { const match = pattern.exec(source); if (!match) throw new TypeError(`Missing dynamic class domain: ${label}`); return [...match[1].matchAll(/'([^']+)'/g)].map((value) => value[1]); } function dynamicClassBindings(files) { const bySuffix = (suffix) => files.find(({ file }) => file.endsWith(suffix))?.source; const bindings = new Map(); const add = (suffix, name, values) => { if (!values.length) throw new TypeError(`Empty dynamic class domain: ${name}`); if (!bindings.has(suffix)) bindings.set(suffix, new Map()); bindings.get(suffix).set(name, { values: [...new Set(values)].sort() }); }; const diagnosticsSuffix = '/features/diagnostics/ConnectivityDiagnosticsPanel.tsx'; const diagnostics = bySuffix(diagnosticsSuffix); if (diagnostics) { const diagnosticStatuses = [...diagnostics.matchAll(/\['(is-[a-z-]+)'/g)].map((match) => match[1]); add(diagnosticsSuffix, 'className', diagnosticStatuses); } const devicesSuffix = '/features/devices/DevicesPanel.tsx'; const devices = bySuffix(devicesSuffix); const deviceContract = bySuffix('/features/devices/deviceSnapshot.ts'); if (devices || deviceContract) { if (!devices || !deviceContract) throw new TypeError('Incomplete dynamic class domain: devices'); add(devicesSuffix, 'device.status', literalUnion(deviceContract, /type DeviceStatus = ([^;]+);/, 'DeviceStatus')); add(devicesSuffix, 'displayPolicy', literalUnion(deviceContract, /export type DevicePolicy = ([^;]+);/, 'DevicePolicy')); } const routingSuffix = '/features/routing/RoutingFeature.tsx'; const routing = bySuffix(routingSuffix); if (routing) { const routingStatusBody = /function localRuleStatus[\s\S]+?\n}\n\nfunction routingSaveState/.exec(routing)?.[0]; if (!routingStatusBody) throw new TypeError('Missing dynamic class domain: localRuleStatus'); add(routingSuffix, 'status', [...routingStatusBody.matchAll(/return \['([a-z-]+)'/g)].map((match) => match[1])); } const connectionSuffix = '/features/connection/ConnectionPanel.tsx'; const connection = bySuffix(connectionSuffix); if (connection) { const clockUnits = [...connection.matchAll(/\['(hours|minutes|seconds)', duration\./g)].map((match) => match[1]); const directNames = [...connection.matchAll(/ match[1]); add(connectionSuffix, 'name', [ ...directNames, ...clockUnits.flatMap((unit) => [`${unit}-value`, `${unit}-label`]), ]); } const pageSuffix = '/components/ClientOverviewPage.tsx'; const page = bySuffix(pageSuffix); if (page) { const product = /const product = isGateway \? '([^']+)' : '([^']+)';/.exec(page); if (!product) throw new TypeError('Missing dynamic class domain: product'); add(pageSuffix, 'product', product.slice(1)); } return new Map(files.map(({ file }) => { const entry = [...bindings].find(([suffix]) => file.endsWith(suffix)); return [file, entry?.[1] || new Map()]; })); } function bindComponentProps(definition, element, callerEnvironment, callerFile, dynamicBindings) { const supplied = new Map(); for (const attribute of element.openingElement.attributes) { if (attribute.type === 'JSXSpreadAttribute') { throw new TypeError(`Unsupported spread props in JSX witness call: ${jsxName(element.openingElement.name)}`); } const name = attribute.name?.name; if (!name) continue; const node = attribute.value?.type === 'JSXExpressionContainer' ? attribute.value.expression : attribute.value || { type: 'BooleanLiteral', value: true }; supplied.set(name, node.type === 'Identifier' && callerEnvironment.get(node.name) ? callerEnvironment.get(node.name) : { callable: ['ArrowFunctionExpression', 'FunctionExpression'].includes(node.type) && producesJsx(node), node, environment: callerEnvironment, file: callerFile, }); } supplied.set('children', { node: element.children, environment: callerEnvironment, file: callerFile }); const environment = new Map(dynamicBindings.get(definition.file)); const parameter = definition.node.params?.[0]; if (parameter?.type === 'ObjectPattern') { for (const property of parameter.properties) { if (property.type === 'RestElement') continue; const sourceName = property.key?.name; const target = property.value?.type === 'AssignmentPattern' ? property.value.left : property.value; const localName = target?.name; const fallback = property.value?.type === 'AssignmentPattern' ? { node: property.value.right, environment: callerEnvironment, file: definition.file } : null; if (sourceName && localName && (supplied.get(sourceName) || fallback)) { environment.set(localName, supplied.get(sourceName) || fallback); } } } return environment; } function producesJsx(node) { if (!node || typeof node !== 'object') return false; if (Array.isArray(node)) return node.some(producesJsx); if (node.type === 'JSXElement' || node.type === 'JSXFragment') return true; if (node.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 'createPortal') return true; return Object.entries(node).some(([key, child]) => ( !['loc', 'start', 'end', 'extra', 'comments', 'tokens', 'errors'].includes(key) && producesJsx(child) )); } function withLocalJsxBindings(node, environment, definitionNodes, file) { const scoped = new Map(environment); const collect = (value) => { if (!value || typeof value !== 'object') return; if (Array.isArray(value)) { for (const child of value) collect(child); return; } if (definitionNodes.has(value)) return; if (value.type === 'VariableDeclarator' && value.id?.type === 'Identifier' && producesJsx(value.init)) { scoped.set(value.id.name, { callable: ['ArrowFunctionExpression', 'FunctionExpression'].includes(value.init?.type), declaration: value, environment: scoped, file, node: value.init, }); return; } for (const [key, child] of Object.entries(value)) { if (['loc', 'start', 'end', 'extra', 'comments', 'tokens', 'errors'].includes(key)) continue; collect(child); } }; collect(node); return scoped; } function bindCallParameters(callable, argumentsList, callerEnvironment, callerFile) { const environment = new Map(callable.environment); for (let index = 0; index < callable.node.params.length; index += 1) { const parameter = callable.node.params[index]; if (parameter?.type === 'Identifier' && argumentsList[index]) { environment.set(parameter.name, { node: argumentsList[index], environment: callerEnvironment, file: callerFile }); } } return environment; } export function createStyleWitnesses(sources, { entry = 'App' } = {}) { const files = sources.map(({ file, source }) => ({ file, source, parsed: parseWitnessFile(file, source) })); const { definitions, definitionNodes } = componentDefinitions(files); const entryDefinition = definitions.get(entry); if (!entryDefinition) throw new TypeError(`Missing JSX witness entry component: ${entry}`); const imperativeByFile = new Map(files.map((file) => [ file.file, imperativeClassesByTarget(file.file, file.source), ])); const dynamicByFile = dynamicClassBindings(files); const witnesses = []; const expand = (definition, ancestors, ancestorUnknown, environment, stack) => { if (stack.includes(definition)) throw new TypeError(`Recursive JSX witness component: ${definition.node.id?.name || definition.file}`); const nextStack = [...stack, definition]; const entryEnvironment = withLocalJsxBindings(definition.node.body, environment, definitionNodes, definition.file); const visit = ( node, currentAncestors = ancestors, currentUnknown = ancestorUnknown, currentEnvironment = entryEnvironment, currentFile = definition.file, ) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { for (const child of node) visit(child, currentAncestors, currentUnknown, currentEnvironment, currentFile); return; } if (definitionNodes.has(node)) return; if (node.type === 'VariableDeclarator' && node.id?.type === 'Identifier') { const binding = currentEnvironment.get(node.id.name); if (binding?.declaration === node) return; } if (node.type === 'CallExpression' && node.callee?.type === 'Identifier') { const callable = currentEnvironment.get(node.callee.name); if (callable?.callable) { const callEnvironment = withLocalJsxBindings( callable.node.body, bindCallParameters(callable, node.arguments, currentEnvironment, currentFile), definitionNodes, callable.file || currentFile, ); visit(callable.node.body, currentAncestors, currentUnknown, callEnvironment, callable.file || currentFile); return; } } if (node.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 'createPortal') { const target = node.arguments[1]; const documentBody = target?.type === 'MemberExpression' && !target.computed && target.object?.name === 'document' && target.property?.name === 'body'; const appOrBody = target?.type === 'LogicalExpression' && target.operator === '||' && target.left?.type === 'CallExpression' && target.left.callee?.type === 'MemberExpression' && target.left.callee.object?.name === 'document' && target.left.callee.property?.name === 'querySelector' && target.left.arguments?.[0]?.value === '.app.client-app' && target.right?.type === 'MemberExpression' && target.right.object?.name === 'document' && target.right.property?.name === 'body'; if (!documentBody && !appOrBody) { throw new TypeError(`Unsupported JSX portal target in ${definition.file}`); } visit(node.arguments[0], [], false, currentEnvironment, currentFile); if (appOrBody) { visit(node.arguments[0], [{ classes: ['app', 'client-app', 'is-gateway-app'], prefixes: [], unknown: false }], false, currentEnvironment, currentFile); } return; } if (node.type === 'JSXExpressionContainer' && node.expression?.type === 'Identifier') { const binding = currentEnvironment.get(node.expression.name); if (binding) { visit(binding.node, currentAncestors, currentUnknown, binding.environment, binding.file || currentFile); return; } } if (node.type === 'JSXElement') { const name = jsxName(node.openingElement.name); const intrinsic = Boolean(name && /^[a-z]/.test(name)); if (intrinsic) { const initialInfo = elementClassInfo(node.openingElement, currentEnvironment); const additions = [...imperativeByFile.get(currentFile).entries()] .filter(([target]) => initialInfo.classes.includes(target)) .flatMap(([, classes]) => classes); const info = elementClassInfo(node.openingElement, currentEnvironment, additions); witnesses.push({ ancestorClasses: [...new Set(currentAncestors.flatMap((ancestor) => ancestor.classes))].sort(), ancestorPrefixes: [...new Set(currentAncestors.flatMap((ancestor) => ancestor.prefixes))].sort(), ancestorUnknown: currentUnknown || currentAncestors.some((ancestor) => ancestor.unknown), classes: [...new Set(info.classes)].sort(), prefixes: [...new Set(info.prefixes)].sort(), tag: name, unknown: info.unknown, }); visit(node.children, [...currentAncestors, info], currentUnknown, currentEnvironment, currentFile); return; } if (TRANSPARENT_JSX_COMPONENTS.has(name)) { visit(node.children, currentAncestors, currentUnknown, currentEnvironment, currentFile); return; } const target = definitions.get(name); if (!target) throw new TypeError(`Unresolved JSX witness component: ${name} in ${definition.file}`); const targetEnvironment = bindComponentProps(target, node, currentEnvironment, currentFile, dynamicByFile); expand(target, currentAncestors, currentUnknown, targetEnvironment, nextStack); return; } if (node.type === 'JSXFragment') { visit(node.children, currentAncestors, currentUnknown, currentEnvironment, currentFile); return; } for (const [key, child] of Object.entries(node)) { if (['loc', 'start', 'end', 'extra', 'comments', 'tokens', 'errors'].includes(key)) continue; if (child && typeof child === 'object') visit(child, currentAncestors, currentUnknown, currentEnvironment, currentFile); } }; visit(definition.node.body, ancestors, ancestorUnknown, entryEnvironment); }; expand(entryDefinition, [], false, new Map(dynamicByFile.get(entryDefinition.file)), []); return sortLedger(witnesses); } export function readStyleWitnesses(root) { const files = sourceFiles(path.join(root, 'src/web')).map((file) => ({ file, source: fs.readFileSync(file, 'utf8'), })); return createStyleWitnesses(files); } const OBSERVED_PROPERTIES = new Set(` --client-accent --client-accent-soft --client-bg --client-border --client-control --client-delete-strike-y --client-device-chart-height --client-device-copy-color --client-rule-drag-y --client-muted --client-panel --client-power-top --client-text --client-work-height --harbor-connect --harbor-gateway --harbor-word -webkit-appearance -webkit-backdrop-filter -webkit-text-fill-color align-content align-items align-self animation animation-duration animation-name animation-timing-function appearance backdrop-filter background background-color border border-bottom border-bottom-color border-collapse border-radius border-top bottom box-shadow box-sizing caret-color clip-path color color-scheme column-gap content cursor display fill filter flex flex-basis flex-direction flex-wrap font font-family font-size font-style font-variant-numeric font-weight gap grid-area grid-column grid-row grid-template-columns grid-template-rows height inset isolation justify-content justify-items justify-self left letter-spacing line-height margin margin-bottom margin-inline margin-left margin-right margin-top max-height max-width min-height min-width mix-blend-mode opacity order outline outline-offset overflow overflow-wrap overflow-x overflow-y overscroll-behavior padding padding-block padding-bottom padding-inline padding-left padding-right padding-top place-content place-items pointer-events position right row-gap scrollbar-width stroke stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-width table-layout text-align text-decoration text-overflow text-shadow text-transform text-underline-offset top touch-action transform transform-box transform-origin transition transition-delay user-select vector-effect vertical-align visibility white-space width will-change z-index `.trim().split(/\s+/)); const PROPERTY_FAMILIES = new Map([ [['animation', 'animation-duration', 'animation-name', 'animation-timing-function'], 'animation'], [['background', 'background-color'], 'background'], [['border', 'border-bottom', 'border-bottom-color', 'border-top'], 'border-edge'], [['column-gap', 'gap', 'row-gap'], 'gap'], [['flex', 'flex-basis'], 'flex-size'], [['font', 'font-family', 'font-size', 'font-style', 'font-variant-numeric', 'font-weight', 'line-height'], 'font'], [['grid-area', 'grid-column', 'grid-row'], 'grid-placement'], [['inset', 'bottom', 'left', 'right', 'top'], 'inset'], [['margin', 'margin-bottom', 'margin-inline', 'margin-left', 'margin-right', 'margin-top'], 'margin'], [['overflow', 'overflow-x', 'overflow-y'], 'overflow'], [['padding', 'padding-block', 'padding-bottom', 'padding-inline', 'padding-left', 'padding-right', 'padding-top'], 'padding'], [['place-content', 'align-content', 'justify-content'], 'place-content'], [['place-items', 'align-items', 'justify-items'], 'place-items'], [['transition', 'transition-delay'], 'transition'], ].flatMap(([properties, family]) => properties.map((property) => [property, family]))); const SUPPORTED_SELECTOR_NODES = new Set(['attribute', 'class', 'combinator', 'id', 'pseudo', 'selector', 'tag', 'universal']); const SUPPORTED_COMBINATORS = new Set([' ', '+', '>']); const SUPPORTED_PSEUDOS = new Set([ ':-webkit-autofill', '::-webkit-inner-spin-button', '::-webkit-outer-spin-button', '::-webkit-scrollbar', '::after', '::before', '::marker', '::placeholder', '::view-transition-group', '::view-transition-new', '::view-transition-old', ':active', ':disabled', ':first-child', ':focus', ':focus-visible', ':focus-within', ':has', ':hover', ':last-child', ':not', ':nth-child', ':root', ]); const TRANSPARENT_JSX_COMPONENTS = new Set(['React.Fragment']); const hash = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex'); function propertyFamily(property) { if (property.startsWith('--')) return property; if (!OBSERVED_PROPERTIES.has(property)) { throw new TypeError(`Unsupported CSS property in cascade proof: ${property}`); } return PROPERTY_FAMILIES.get(property) || property; } function atRuleContext(node) { const context = []; for (let parent = node.parent; parent && parent.type !== 'root'; parent = parent.parent) { if (parent.type !== 'atrule') continue; if (parent.name !== 'media' && parent.name !== 'keyframes' && parent.name !== '-webkit-keyframes') { throw new TypeError(`Unsupported CSS at-rule in cascade proof: @${parent.name}`); } context.unshift(`@${parent.name} ${parent.params}`); } return context; } function keyframeAncestor(node) { for (let parent = node.parent; parent && parent.type !== 'root'; parent = parent.parent) { if (parent.type === 'atrule' && (parent.name === 'keyframes' || parent.name === '-webkit-keyframes')) { return parent; } } return null; } function splitVarArguments(value) { let depth = 0; let quote = null; let escaped = false; for (let index = 0; index < value.length; index += 1) { const char = value[index]; const next = value[index + 1]; if (quote) { if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === quote) quote = null; continue; } if (char === '"' || char === "'") { quote = char; continue; } if (char === '/' && next === '*') { const end = value.indexOf('*/', index + 2); if (end < 0) throw new TypeError('Unterminated CSS value comment'); index = end + 1; continue; } if (char === '(') depth += 1; else if (char === ')') depth -= 1; else if (char === ',' && depth === 0) return [value.slice(0, index), value.slice(index + 1)]; } return [value, null]; } export function variableReferences(value) { const references = []; for (let index = 0; index < value.length;) { const match = /\bvar\s*\(/gy; match.lastIndex = index; const found = match.exec(value); if (!found) { index += 1; continue; } const contentStart = match.lastIndex; let depth = 1; let quote = null; let escaped = false; let cursor = contentStart; for (; cursor < value.length && depth > 0; cursor += 1) { const char = value[cursor]; const next = value[cursor + 1]; if (quote) { if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === quote) quote = null; continue; } if (char === '"' || char === "'") { quote = char; continue; } if (char === '/' && next === '*') { const end = value.indexOf('*/', cursor + 2); if (end < 0) throw new TypeError('Unterminated CSS value comment'); cursor = end + 1; continue; } if (char === '(') depth += 1; else if (char === ')') depth -= 1; } if (depth !== 0) throw new TypeError(`Unterminated var() reference: ${value}`); const rawArguments = value.slice(contentStart, cursor - 1); const [rawName, rawFallback] = splitVarArguments(rawArguments); const name = rawName.trim(); if (!/^--[a-zA-Z0-9_-]+$/.test(name)) throw new TypeError(`Invalid var() name: ${name}`); const fallback = rawFallback === null ? null : rawFallback.trim(); references.push({ name, fallback }); if (fallback) references.push(...variableReferences(fallback)); index = cursor; } return references; } function simpleConstraints(nodes) { const constraints = { attributes: {}, classes: [], ids: [], negatives: [], tags: [] }; for (const node of nodes) { if (node.type === 'id') constraints.ids.push(node.value); else if (node.type === 'class') constraints.classes.push(node.value); else if (node.type === 'tag' && node.value !== '*') constraints.tags.push(node.value.toLowerCase()); else if (node.type === 'attribute' && node.operator === '=' && node.value !== undefined) { constraints.attributes[node.attribute] = String(node.value); } else if (node.type === 'pseudo' && node.value === ':not') { for (const selector of node.nodes || []) { if (selector.nodes.some((part) => part.type === 'combinator')) continue; if (selector.nodes.length !== 1) continue; const negative = simpleConstraints(selector.nodes); constraints.negatives.push(...negative.ids.map((value) => `#${value}`)); constraints.negatives.push(...negative.classes.map((value) => `.${value}`)); constraints.negatives.push(...negative.tags); } } } for (const key of ['classes', 'ids', 'negatives', 'tags']) constraints[key].sort(); return constraints; } export function analyzeSelectorList(selector) { let root; try { root = selectorParser().astSync(selector); } catch (error) { throw new TypeError(`Unsupported CSS selector grammar: ${selector}`, { cause: error }); } if (!root.nodes.length) throw new TypeError(`Empty CSS selector list: ${selector}`); root.walk((node) => { if (!SUPPORTED_SELECTOR_NODES.has(node.type)) { throw new TypeError(`Unsupported CSS selector node ${node.type}: ${selector}`); } if (node.type === 'combinator' && !SUPPORTED_COMBINATORS.has(node.value)) { throw new TypeError(`Unsupported CSS combinator ${node.value}: ${selector}`); } if (node.type === 'pseudo' && !SUPPORTED_PSEUDOS.has(node.value)) { throw new TypeError(`Unsupported CSS pseudo ${node.value}: ${selector}`); } if (node.type === 'attribute' && node.operator !== '=') { throw new TypeError(`Unsupported CSS attribute selector: ${selector}`); } }); return root.nodes.map((selectorNode) => { const compounds = [[]]; for (const node of selectorNode.nodes) { if (node.type === 'combinator') compounds.push([]); else compounds.at(-1).push(node); } const specificity = selectorSpecificity(selectorNode); if (![specificity.a, specificity.b, specificity.c].every(Number.isSafeInteger)) { throw new TypeError(`Unsupported selector specificity: ${selectorNode}`); } return { ancestorClasses: compounds.length > 1 ? simpleConstraints(compounds.slice(0, -1).flat()).classes : [], selector: selectorNode.toString(), specificity, target: simpleConstraints(compounds.at(-1)), witnessEligible: !selectorNode.nodes.some((node) => node.type === 'combinator' && node.value === '+'), }; }); } function witnessHasClass(witness, className, ancestor = false) { const classes = ancestor ? witness.ancestorClasses : witness.classes; const prefixes = ancestor ? witness.ancestorPrefixes : witness.prefixes; const unknown = ancestor ? witness.ancestorUnknown : witness.unknown; return unknown || classes.includes(className) || prefixes.some((prefix) => className.startsWith(prefix)); } function witnessMatches(witness, requirement) { if (requirement.target.tags.length && !requirement.target.tags.includes(witness.tag)) return false; if (!requirement.target.classes.every((className) => witnessHasClass(witness, className))) return false; return requirement.ancestorClasses.every((className) => witnessHasClass(witness, className, true)); } export function selectorsWithoutWitness(source, witnesses) { const unmatched = []; const root = postcss.parse(source, { from: undefined }); root.walkRules((rule) => { if (keyframeAncestor(rule)) return; for (const requirement of analyzeSelectorList(rule.selector)) { if ([':root', 'html', 'body', '#root'].includes(requirement.selector.trim())) continue; const hasStructuralTarget = requirement.target.classes.length || requirement.target.tags.length || requirement.ancestorClasses.length; if (requirement.witnessEligible && hasStructuralTarget && !witnesses.some((witness) => witnessMatches(witness, requirement))) { unmatched.push(requirement.selector); } } }); return [...new Set(unmatched)].sort(); } function constraintsConflict(left, right, witnesses) { if (left.target.ids.length && right.target.ids.length && !left.target.ids.some((id) => right.target.ids.includes(id))) return true; if (left.target.tags.length && right.target.tags.length && !left.target.tags.some((tag) => right.target.tags.includes(tag))) return true; for (const [name, value] of Object.entries(left.target.attributes)) { if (right.target.attributes[name] !== undefined && right.target.attributes[name] !== value) return true; } const leftPositive = [...left.target.ids.map((value) => `#${value}`), ...left.target.classes.map((value) => `.${value}`), ...left.target.tags]; const rightPositive = [...right.target.ids.map((value) => `#${value}`), ...right.target.classes.map((value) => `.${value}`), ...right.target.tags]; if (leftPositive.some((value) => right.target.negatives.includes(value)) || rightPositive.some((value) => left.target.negatives.includes(value))) return true; if (!witnesses || !left.witnessEligible || !right.witnessEligible) return false; const hasStaticRequirement = left.target.classes.length || right.target.classes.length || left.target.tags.length || right.target.tags.length || left.ancestorClasses.length || right.ancestorClasses.length; return Boolean(hasStaticRequirement && !witnesses.some((witness) => ( witnessMatches(witness, left) && witnessMatches(witness, right) ))); } function mediaRequirements(context) { const requirements = {}; for (const item of context) { if (!item.startsWith('@media ')) continue; const params = item.slice('@media '.length); for (const clause of params.split(/\s+and\s+/)) { const match = /^\(([a-z-]+):\s*([a-z0-9.]+(?:px)?)\)$/.exec(clause.trim()); if (!match) throw new TypeError(`Unsupported media grammar: ${params}`); const [, feature, value] = match; if (feature === 'min-width' || feature === 'max-width') requirements[feature] = Number.parseFloat(value); else if (requirements[feature] !== undefined && requirements[feature] !== value) requirements[feature] = null; else requirements[feature] = value; } } return requirements; } function mediaCompatible(left, right) { const a = mediaRequirements(left); const b = mediaRequirements(right); for (const feature of new Set([...Object.keys(a), ...Object.keys(b)])) { if (a[feature] === null || b[feature] === null) return false; if (!['min-width', 'max-width'].includes(feature) && a[feature] !== undefined && b[feature] !== undefined && a[feature] !== b[feature]) return false; } const min = Math.max(a['min-width'] ?? -Infinity, b['min-width'] ?? -Infinity); const max = Math.min(a['max-width'] ?? Infinity, b['max-width'] ?? Infinity); return min <= max; } function semanticDeclaration(entry) { return { context: entry.context, declarationIndex: entry.declarationIndex, important: entry.important, property: entry.property, ruleOccurrence: entry.ruleOccurrence, selector: entry.selector, value: entry.value, }; } function declarationAtom(entry) { return { context: entry.context, important: entry.important, property: entry.property, selector: entry.selector, value: entry.value, }; } function sortLedger(entries) { return [...entries].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))); } export function createStyleLedger(source, { witnesses = null } = {}) { const root = postcss.parse(source, { from: undefined }); root.walkAtRules((atRule) => { if (!['media', 'keyframes', '-webkit-keyframes'].includes(atRule.name)) { throw new TypeError(`Unsupported CSS at-rule in cascade proof: @${atRule.name}`); } if (atRule.name === 'media') mediaRequirements([`@media ${atRule.params}`]); }); const declarations = []; const selectors = []; const cascadeDeclarations = []; const ruleDeclarationSequences = []; const occurrences = new Map(); let ruleOrdinal = 0; root.walkRules((rule) => { const context = atRuleContext(rule); const inKeyframes = Boolean(keyframeAncestor(rule)); const occurrenceKey = JSON.stringify([context, rule.selector]); const ruleOccurrence = occurrences.get(occurrenceKey) || 0; occurrences.set(occurrenceKey, ruleOccurrence + 1); const analyzed = inKeyframes ? [] : analyzeSelectorList(rule.selector); if (!inKeyframes) { selectors.push({ context, occurrence: ruleOccurrence, ordinal: ruleOrdinal, selector: rule.selector, selectors: analyzed.map((entry) => entry.selector), }); } let declarationIndex = 0; const ruleDeclarations = []; for (const node of rule.nodes || []) { if (node.type !== 'decl') continue; const family = propertyFamily(node.prop); const entry = { context, declarationIndex, family, important: Boolean(node.important), ordinal: declarations.length, property: node.prop, ruleOccurrence, ruleOrdinal, selector: rule.selector, value: node.value, }; declarations.push(entry); ruleDeclarations.push({ important: Boolean(node.important), property: node.prop, value: node.value }); if (!inKeyframes) { for (const analyzedSelector of analyzed) { cascadeDeclarations.push({ ...entry, ...analyzedSelector }); } } declarationIndex += 1; } ruleDeclarationSequences.push({ context, declarations: ruleDeclarations, selector: rule.selector }); ruleOrdinal += 1; }); const keyframes = []; const keyframeOccurrences = new Map(); root.walkAtRules((atRule) => { if (atRule.name !== 'keyframes' && atRule.name !== '-webkit-keyframes') return; const occurrence = keyframeOccurrences.get(atRule.params) || 0; keyframeOccurrences.set(atRule.params, occurrence + 1); keyframes.push({ context: atRuleContext(atRule), name: atRule.params, occurrence, vendor: atRule.name, frames: (atRule.nodes || []).filter((node) => node.type === 'rule').map((frame) => ({ selector: frame.selector, declarations: (frame.nodes || []).filter((node) => node.type === 'decl').map((node) => ({ important: Boolean(node.important), property: node.prop, value: node.value, })), })), }); }); const customProperties = declarations.filter((entry) => entry.property.startsWith('--')); const variableReferenceLedger = declarations.flatMap((entry) => variableReferences(entry.value).map((reference, referenceIndex) => ({ ...semanticDeclaration(entry), referenceIndex, ...reference, }))); const grouped = new Map(); for (const entry of cascadeDeclarations) { const key = JSON.stringify([entry.family, entry.important, entry.specificity]); if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(entry); } const cascadeEdges = []; for (const entries of grouped.values()) { for (let rightIndex = 1; rightIndex < entries.length; rightIndex += 1) { const right = entries[rightIndex]; for (let leftIndex = 0; leftIndex < rightIndex; leftIndex += 1) { const left = entries[leftIndex]; if (compare(left.specificity, right.specificity) !== 0) continue; if (!mediaCompatible(left.context, right.context)) continue; if (constraintsConflict(left, right, witnesses)) continue; cascadeEdges.push({ context: [left.context, right.context], family: left.family, important: left.important, loser: declarationAtom(left), specificity: left.specificity, winner: declarationAtom(right), }); } } } const normalizedRuleDeclarationSequences = []; const appClientDeclarations = []; for (const rule of ruleDeclarationSequences) { if (rule.context.length === 0 && rule.selector === '.app.client-app') appClientDeclarations.push(...rule.declarations); else normalizedRuleDeclarationSequences.push(rule); } if (appClientDeclarations.length) { normalizedRuleDeclarationSequences.push({ context: [], declarations: appClientDeclarations, selector: '.app.client-app', }); } let appClientSelectorSeen = false; const selectorLists = sortLedger(selectors.flatMap(({ context, selector, selectors: expanded }) => { if (context.length === 0 && selector === '.app.client-app') { if (appClientSelectorSeen) return []; appClientSelectorSeen = true; } return [{ context, selector, selectors: expanded }]; })); const duplicateSelectors = []; const rulesBySelector = new Map(); for (const rule of normalizedRuleDeclarationSequences) { const key = JSON.stringify([rule.context, rule.selector]); if (!rulesBySelector.has(key)) rulesBySelector.set(key, []); rulesBySelector.get(key).push(rule.declarations); } for (const [key, sequences] of rulesBySelector) { if (sequences.length > 1) duplicateSelectors.push({ key: JSON.parse(key), sequences }); } const duplicateKeyframes = []; const keyframesByName = new Map(); for (const keyframe of keyframes) { const key = JSON.stringify([keyframe.context, keyframe.vendor, keyframe.name]); if (!keyframesByName.has(key)) keyframesByName.set(key, []); keyframesByName.get(key).push(keyframe.frames); } for (const [key, sequences] of keyframesByName) { if (sequences.length > 1) duplicateKeyframes.push({ key: JSON.parse(key), sequences }); } const ledgers = { cascadeEdges: sortLedger(cascadeEdges), customProperties: sortLedger(customProperties.map(declarationAtom)), declarations: sortLedger(declarations.map(declarationAtom)), duplicateKeyframes: sortLedger(duplicateKeyframes), duplicateSelectors: sortLedger(duplicateSelectors), keyframes: sortLedger(keyframes.map(({ occurrence: _occurrence, ...keyframe }) => keyframe)), ruleDeclarationSequences: sortLedger(normalizedRuleDeclarationSequences), selectors: selectorLists, variableReferences: sortLedger(variableReferenceLedger.map((entry) => ({ context: entry.context, fallback: entry.fallback, important: entry.important, name: entry.name, property: entry.property, selector: entry.selector, value: entry.value, }))), }; if (witnesses) ledgers.witnesses = sortLedger(witnesses); return { counts: { cascadeEdges: cascadeEdges.length, customProperties: customProperties.length, declarations: declarations.length, important: declarations.filter((entry) => entry.important).length, keyframes: keyframes.length, media: root.nodes.reduce((count, node) => count + (node.type === 'atrule' && node.name === 'media' ? 1 : 0), 0), rules: ruleOrdinal, variableReferences: variableReferenceLedger.length, }, hashes: Object.fromEntries(Object.entries(ledgers).map(([name, ledger]) => [name, hash(ledger)])), ledgers, }; }