import type { FactIndex } from "../facts/index.js"; import { makeFinding } from "./finding.js"; import type { Finding } from "./types.js"; export const RULE_ID = "theme/no-theme-coupled-literal"; export const RULE_VERSION = "1"; const COLOR_LITERAL_GLOBAL = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|oklab|oklch|lab|lch)\(\s*[\d.%\s,\/]+\s*\)/g; const WHITE_RGBA = /^rgba?\(\s*255\s*,\s*255\s*,\s*255/i; const BLACK_RGBA = /^rgba?\(\s*0\s*,\s*0\s*,\s*0/i; const COLOR_MIX_OPEN = "color-mix("; const BOX_SHADOW_PROP = /^(?:-webkit-|-moz-)?box-shadow$/i; const NEUTRAL_SHADOW = /^(?:none|inherit|initial|unset|revert|revert-layer)\s*$/i; export function ruleThemeNoThemeCoupledLiteral(ix: FactIndex): Finding[] { const policy = ix.policy.ruleConfig(RULE_ID); if (!policy?.enabled) return []; const findings: Finding[] = []; for (const decl of ix.byKind("style_declaration")) { if (isTokenFile(decl.file)) continue; const hit = findThemeCoupledLiteral(decl.property, decl.value); if (!hit) continue; findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity ?? "warn", message: hit.message, location: decl.location, evidence: ix.evidence([decl.id, policy.id]), fingerprintIdentity: { file: decl.file, selector: decl.selector, declarationPath: decl.declarationPath, property: decl.property, rawValue: hit.rawValue, reason: hit.reason, }, attributes: { property: decl.property, rawValue: decl.value, color: hit.rawValue, suggestion: hit.suggestion, source: "css", }, }), ); } return findings; } function findThemeCoupledLiteral( property: string, value: string, ): { rawValue: string; reason: string; message: string; suggestion: string } | null { COLOR_LITERAL_GLOBAL.lastIndex = 0; let match: RegExpExecArray | null; while ((match = COLOR_LITERAL_GLOBAL.exec(value)) !== null) { const raw = match[0]; if (insideLightDark(value, match.index)) continue; if (insideColorMix(value, match.index)) { return { rawValue: raw, reason: "color-mix", message: `color-mix() argument ${raw} is theme-coupled. Use a semantic token or wrap the declaration in light-dark(...).`, suggestion: `Replace ${raw} with a semantic token or light-dark(...)`, }; } if (BOX_SHADOW_PROP.test(property) && !NEUTRAL_SHADOW.test(value.trim())) { return { rawValue: raw, reason: "box-shadow", message: `Custom box-shadow with literal color ${raw} is theme-coupled. Use a shadow/elevation token or light-dark(...).`, suggestion: "Use a shadow/elevation token or light-dark(...)", }; } if (WHITE_RGBA.test(raw) || BLACK_RGBA.test(raw)) { return { rawValue: raw, reason: "white-black-rgba", message: `Raw ${raw} assumes one theme. Wrap it in light-dark(...) or use a semantic token.`, suggestion: WHITE_RGBA.test(raw) ? `light-dark(/* TODO light-theme equivalent */, ${raw})` : `light-dark(${raw}, /* TODO dark-theme equivalent */)`, }; } } return null; } function insideLightDark(value: string, index: number): boolean { return enclosingFunctionName(value, index) === "light-dark"; } function insideColorMix(value: string, index: number): boolean { return enclosingFunctionName(value, index) === "color-mix"; } function enclosingFunctionName(value: string, index: number): string | null { let depth = 0; for (let i = index - 1; i >= 0; i--) { const ch = value[i]; if (ch === ")") { depth++; continue; } if (ch !== "(") continue; if (depth > 0) { depth--; continue; } const before = value.slice(0, i).match(/([a-z-]+)\s*$/i); return before?.[1]?.toLowerCase() ?? null; } return null; } function isTokenFile(filePath: string): boolean { const normalized = filePath.replace(/\\/g, "/"); const base = normalized.slice(normalized.lastIndexOf("/") + 1); if (base === "_tokens.scss" || base === "tokens.scss") return true; return base.startsWith("_") && /(^|\/)tokens\//.test(normalized); }