#!/usr/bin/env node // Advisory audit for Control UI class selectors that have no production source reference. import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; import postcss, { type Rule } from "postcss"; import selectorParser, { type ClassName, type Selector } from "postcss-selector-parser"; import ts from "typescript"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(SCRIPT_DIR, ".."); const UI_ROOT = path.join(REPO_ROOT, "ui"); const UI_SOURCE_ROOT = path.join(UI_ROOT, "src"); const CLASS_TOKEN_PATTERN = /[-_A-Za-z][-_A-Za-z0-9]*/gu; const CLASS_STEM_PATTERN = /(?:^|[\s"'`=])([-_A-Za-z][-_A-Za-z0-9]*)$/u; type ExternalClassFamily = { matches: (className: string) => boolean; producer: string; }; // These classes are emitted by dependencies rather than written literally in ui/src. const EXTERNAL_CLASS_FAMILIES: ExternalClassFamily[] = [ // highlight.js emits language token spans during markdown rendering. { matches: (className) => className === "hljs" || className.startsWith("hljs-"), producer: "highlight.js via ui/src/components/markdown-code-blocks.ts", }, // CodeMirror owns cm-* editor DOM; its Lezer highlighter owns tok-* spans. { matches: (className) => className.startsWith("cm-") || className.startsWith("tok-"), producer: "CodeMirror and @lezer/highlight via ui/src/pages/chat/components/file-editor-view.ts", }, // Web Awesome owns wa-* classes inside its component implementation. { matches: (className) => className.startsWith("wa-"), producer: "Web Awesome custom-element internals", }, // ProseMirror owns the editor-root and state classes it adds to its DOM. { matches: (className) => className.startsWith("ProseMirror"), producer: "ProseMirror editor DOM", }, // markdown-it-task-lists emits these two classes from parsed markdown. { matches: (className) => className === "task-list-item" || className === "task-list-item-checkbox", producer: "markdown-it-task-lists via ui/src/components/markdown-parser.ts (the contains-task-list class is removed there)", }, ]; type SourceReferences = { literalClasses: Set; stems: Set; }; type SourceReferenceFile = SourceReferences & { file: string; }; type DynamicClassBuilder = { defaultValue: string | null; dynamic: boolean; name: string; parameterIndex: number; suffix: string; }; type DynamicClassBuilderCall = { arguments: Array; name: string; }; type DeadClassFinding = { classes: string[]; endLine: number; file: string; selector: string; startLine: number; testOnlyFiles: string[]; }; function groupBy(values: Iterable, keyFor: (value: T) => K): Map { const groups = new Map(); for (const value of values) { const key = keyFor(value); const group = groups.get(key); if (group) { group.push(value); } else { groups.set(key, [value]); } } return groups; } function walkFiles(rootDir: string, accepts: (fileName: string) => boolean): string[] { const files: string[] = []; const pending = [rootDir]; while (pending.length > 0) { const current = pending.pop(); if (!current) { continue; } for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const entryPath = path.join(current, entry.name); if (entry.isDirectory()) { pending.push(entryPath); } else if (entry.isFile() && accepts(entry.name)) { files.push(entryPath); } } } return files.toSorted((left, right) => left.localeCompare(right)); } function relativeToRepo(filePath: string): string { return path.relative(REPO_ROOT, filePath).replaceAll(path.sep, "/"); } function addLiteralClassTokens(value: string, output: Set): void { for (const match of value.matchAll(CLASS_TOKEN_PATTERN)) { output.add(match[0]); } } function addTrailingClassStem(value: string, output: Set): void { const stem = value.match(CLASS_STEM_PATTERN)?.[1]; // Dynamic class families in this UI use kebab/BEM separators. Requiring one // avoids treating ordinary string assembly ("item" + index) as CSS evidence. if (stem?.includes("-")) { output.add(stem); } } function inlineConditionalStringValues(node: ts.Expression): string[] { if ( !ts.isConditionalExpression(node) || !ts.isStringLiteral(node.whenTrue) || !ts.isStringLiteral(node.whenFalse) ) { return []; } return [node.whenTrue.text, node.whenFalse.text]; } function classMapPropertyName(node: ts.ObjectLiteralElementLike): string | null { if (!ts.isPropertyAssignment(node) && !ts.isShorthandPropertyAssignment(node)) { return null; } const name = node.name; if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { return name.text; } return null; } /** Collect literal class tokens and dynamic class stems from TypeScript source. */ export function collectControlUiClassReferences( source: string, fileName = "source.ts", ): SourceReferences { const literalClasses = new Set(); const stems = new Set(); const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true); function visit(node: ts.Node): void { if (ts.isStringLiteralLike(node)) { addLiteralClassTokens(node.text, literalClasses); } else if (ts.isTemplateExpression(node)) { addLiteralClassTokens(node.head.text, literalClasses); addTrailingClassStem(node.head.text, stems); const firstSpan = node.templateSpans[0]; if (node.head.text === "" && firstSpan) { for (const branch of inlineConditionalStringValues(firstSpan.expression)) { addTrailingClassStem(`${branch}${firstSpan.literal.text}`, stems); } } for (const span of node.templateSpans) { addLiteralClassTokens(span.literal.text, literalClasses); if (ts.isTemplateMiddle(span.literal)) { addTrailingClassStem(span.literal.text, stems); } } } if ( ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken && (ts.isStringLiteral(node.left) || ts.isNoSubstitutionTemplateLiteral(node.left)) ) { addTrailingClassStem(node.left.text, stems); } if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "classMap" && node.arguments[0] && ts.isObjectLiteralExpression(node.arguments[0]) ) { for (const property of node.arguments[0].properties) { const name = classMapPropertyName(property); if (name) { literalClasses.add(name); } } } ts.forEachChild(node, visit); } visit(sourceFile); return { literalClasses, stems }; } function declarationName(node: ts.FunctionLikeDeclaration): string | null { if (node.name && (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name))) { return node.name.text; } if ( (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name) ) { return node.parent.name.text; } return null; } function isFunctionLikeDeclaration(node: ts.Node): node is ts.FunctionLikeDeclaration { return ( ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) || ts.isConstructorDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) ); } function callName(node: ts.CallExpression): string | null { if (ts.isIdentifier(node.expression)) { return node.expression.text; } if (ts.isPropertyAccessExpression(node.expression)) { return node.expression.name.text; } return null; } function staticStringValue(node: ts.Expression | undefined): string | null { return node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : null; } function classBuilderSuffix( following: ts.TemplateLiteralLikeNode, ): { dynamic: boolean; suffix: string } | null { if (following.text === "" || /^[\s"'`]/u.test(following.text)) { return { dynamic: false, suffix: "" }; } const suffix = following.text.match(/^([-_A-Za-z0-9]+)/u)?.[1]; if (!suffix) { return null; } const remainder = following.text.slice(suffix.length); if (remainder !== "" && !/^[\s"'`]/u.test(remainder)) { return null; } return { dynamic: ts.isTemplateMiddle(following) && remainder === "", suffix, }; } function collectDynamicClassBuilderData( source: string, fileName: string, ): { builders: DynamicClassBuilder[]; calls: DynamicClassBuilderCall[]; } { const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true); const builders: DynamicClassBuilder[] = []; const calls: DynamicClassBuilderCall[] = []; function visit(node: ts.Node): void { if (ts.isCallExpression(node)) { const name = callName(node); if (name) { calls.push({ arguments: node.arguments.map((argument) => staticStringValue(argument)), name, }); } } if (isFunctionLikeDeclaration(node) && node.body) { const name = declarationName(node); if (name) { const builderName: string = name; const parameters = new Map(); node.parameters.forEach((parameter, index) => { if (ts.isIdentifier(parameter.name)) { parameters.set(parameter.name.text, { defaultValue: staticStringValue(parameter.initializer), index, }); } }); function visitBody(bodyNode: ts.Node): void { if (ts.isTemplateExpression(bodyNode)) { let precedingText = bodyNode.head.text; for (const span of bodyNode.templateSpans) { if (ts.isIdentifier(span.expression) && /(?:^|[\s"'`=])$/u.test(precedingText)) { const parameter = parameters.get(span.expression.text); const pattern = classBuilderSuffix(span.literal); if (parameter && pattern) { builders.push({ ...pattern, defaultValue: parameter.defaultValue, name: builderName, parameterIndex: parameter.index, }); } } precedingText = span.literal.text; } } ts.forEachChild(bodyNode, visitBody); } visitBody(node.body); } } ts.forEachChild(node, visit); } visit(sourceFile); return { builders, calls }; } function addDynamicClassBuilderReferences( references: SourceReferences, builders: DynamicClassBuilder[], calls: DynamicClassBuilderCall[], ): void { const callsByName = groupBy(calls, (call) => call.name); for (const builder of builders) { const values = new Set(); if (builder.defaultValue !== null) { values.add(builder.defaultValue); } for (const call of callsByName.get(builder.name) ?? []) { const value = call.arguments[builder.parameterIndex]; if (value !== null && value !== undefined) { values.add(value); } } for (const value of values) { const reference = `${value}${builder.suffix}`; (builder.dynamic ? references.stems : references.literalClasses).add(reference); } } } function mergeReferences(references: SourceReferences[]): SourceReferences { return { literalClasses: new Set(references.flatMap((entry) => [...entry.literalClasses])), stems: new Set(references.flatMap((entry) => [...entry.stems])), }; } function collectSourceReferenceFiles(): { production: SourceReferences; tests: SourceReferenceFile[]; } { const sourceFiles = walkFiles(UI_SOURCE_ROOT, (fileName) => fileName.endsWith(".ts")); const productionFiles: SourceReferenceFile[] = []; const testFiles: SourceReferenceFile[] = []; const builders: DynamicClassBuilder[] = []; const calls: DynamicClassBuilderCall[] = []; for (const filePath of sourceFiles) { const source = fs.readFileSync(filePath, "utf8"); const entry = { file: relativeToRepo(filePath), ...collectControlUiClassReferences(source, filePath), }; const isTestSupport = filePath.endsWith(".test.ts") || filePath.endsWith(".test-support.ts") || filePath.split(path.sep).includes("test-helpers"); if (isTestSupport) { testFiles.push(entry); } else { productionFiles.push(entry); const builderData = collectDynamicClassBuilderData(source, filePath); builders.push(...builderData.builders); calls.push(...builderData.calls); } } const indexHtml = fs.readFileSync(path.join(UI_ROOT, "index.html"), "utf8"); const htmlClasses = new Set(); addLiteralClassTokens(indexHtml, htmlClasses); productionFiles.push({ file: "ui/index.html", literalClasses: htmlClasses, stems: new Set(), }); const production = mergeReferences(productionFiles); addDynamicClassBuilderReferences(production, builders, calls); return { production, tests: testFiles, }; } function isReferenced(className: string, references: SourceReferences): boolean { return ( references.literalClasses.has(className) || [...references.stems].some((stem) => className.startsWith(stem)) ); } function externalProducer(className: string): string | null { return EXTERNAL_CLASS_FAMILIES.find((family) => family.matches(className))?.producer ?? null; } function selectorClasses(selector: Selector): ClassName[] { const classes: ClassName[] = []; selector.walkClasses((classNode) => { classes.push(classNode); }); return classes; } function selectorTargetsPart(selector: Selector): boolean { let targetsPart = false; selector.walkPseudos((pseudo) => { if (pseudo.value === "::part") { targetsPart = true; } }); return targetsPart; } function ruleLines(rule: Rule): { startLine: number; endLine: number } { return { startLine: rule.source?.start?.line ?? 1, endLine: rule.source?.end?.line ?? rule.source?.start?.line ?? 1, }; } function testOnlyFilesFor(classes: string[], tests: SourceReferenceFile[]): string[] { return tests .filter((test) => classes.some((className) => isReferenced(className, test))) .map((test) => test.file); } function auditStylesheet( filePath: string, references: SourceReferences, tests: SourceReferenceFile[], ): DeadClassFinding[] { const css = fs.readFileSync(filePath, "utf8"); const root = postcss.parse(css, { from: filePath }); const findings: DeadClassFinding[] = []; root.walkRules((rule) => { const parsed = selectorParser().astSync(rule.selector); for (const selector of parsed.nodes) { const classes = selectorClasses(selector); // Element selectors (including html/body/:root) never become class // candidates. ::part selectors target component-owned shadow DOM. if (classes.length === 0 || selectorTargetsPart(selector)) { continue; } const classNames = [...new Set(classes.map((classNode) => classNode.value))]; const keptAlive = classNames.some( (className) => externalProducer(className) || isReferenced(className, references), ); if (keptAlive) { continue; } findings.push({ ...ruleLines(rule), classes: classNames, file: relativeToRepo(filePath), selector: selector.toString().trim(), testOnlyFiles: testOnlyFilesFor(classNames, tests), }); } }); return findings; } function collectDeadClassFindings(): DeadClassFinding[] { const references = collectSourceReferenceFiles(); const stylesheets = walkFiles(UI_SOURCE_ROOT, (fileName) => fileName.endsWith(".css")); return stylesheets.flatMap((filePath) => auditStylesheet(filePath, references.production, references.tests), ); } function formatReport(findings: DeadClassFinding[]): string { if (findings.length === 0) { return "Control UI dead-CSS audit: 0 dead class selectors."; } const byFile = groupBy(findings, (finding) => finding.file); const lines = ["Control UI dead-CSS audit (advisory):"]; for (const [file, fileFindings] of byFile) { lines.push(`\n${file}`); for (const finding of fileFindings) { const lineRange = finding.startLine === finding.endLine ? `line ${finding.startLine}` : `lines ${finding.startLine}-${finding.endLine}`; lines.push(` ${finding.classes.map((name) => `.${name}`).join(", ")} (${lineRange})`); lines.push(` selector: ${finding.selector}`); if (finding.testOnlyFiles.length > 0) { lines.push(` test-only reference: ${finding.testOnlyFiles.join(", ")}`); } } } const classCount = findings.reduce((count, finding) => count + finding.classes.length, 0); lines.push( `\nSummary: ${classCount} dead class selector${classCount === 1 ? "" : "s"} in ${findings.length} selector${findings.length === 1 ? "" : "s"}.`, ); return lines.join("\n"); } function main(argv = process.argv.slice(2)): void { if (argv.length > 0) { throw new Error(`Unknown option: ${argv[0]}`); } process.stdout.write(`${formatReport(collectDeadClassFindings())}\n`); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { main(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; } }