/** * `tailwind/unknown-class` — opt-in strict-mode diagnostics for classes that * either fail parser utility recognition or fail token resolution. */ import type { FactIndex } from "../facts/index.js"; import { makeFinding } from "./finding.js"; import type { Finding } from "./types.js"; import { indexResolvedTailwindTokens, resolvedForClass, TAILWIND_COLOR_UTILITIES, } from "./tailwind-rule-utils.js"; import { readUsageNode } from "./utils.js"; export const RULE_ID = "tailwind/unknown-class"; export const RULE_VERSION = "1"; /** Color keywords valid in any palette — never a "did not resolve" miss. */ const UNIVERSAL_COLOR_KEYWORDS = new Set([ "white", "black", "transparent", "current", "currentcolor", "inherit", "none", ]); export function ruleTailwindUnknownClass(ix: FactIndex): Finding[] { const policy = ix.policy.tailwindUnknownClassEnabled(); if (!policy) return []; const resolvedByKey = indexResolvedTailwindTokens(ix); const findings: Finding[] = []; for (const klass of ix.byKind("tailwind_class")) { if (klass.utility === "unknown") { const node = readUsageNode(ix, klass.nodeId); const evidenceIds = node ? [node.id, klass.id, policy.id] : [klass.id, policy.id]; findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: `\`${klass.raw}\` could not be parsed as a known Tailwind utility.`, location: klass.location, evidence: ix.evidence(evidenceIds), fingerprintIdentity: { source: "tailwind_class", file: klass.file, nodeId: klass.nodeId, originPath: klass.originPath, raw: klass.raw, reason: "parse-failure", }, attributes: { rawClass: klass.raw, utility: klass.utility, reason: "parse-failure", }, }) ); continue; } if (klass.value.kind !== "token") continue; // The resolution-miss diagnostic is about PALETTE tokens only. Don't flag // non-color utilities (font-medium, items-center, border-2 width), bare // numeric tokens (sizes/widths, not colors), or universal color keywords // (white/black/transparent) as "did not resolve to a palette token". if (!TAILWIND_COLOR_UTILITIES.has(klass.utility)) continue; // `shadow` is dual-purpose: `shadow-{size}` (sm/lg/xl/...) is box-shadow, // not a color. The resolution-miss check can't distinguish a size keyword // from a misspelled color, so it would flag every `shadow-lg`. Skip it — // shadow *color* misuse is rare and not worth the false positives. if (klass.utility === "shadow") continue; const token = klass.value.token; if (/^\d/.test(token)) continue; if (UNIVERSAL_COLOR_KEYWORDS.has(token.toLowerCase())) continue; const resolved = resolvedForClass(resolvedByKey, klass); if (!resolved || resolved.resolved.kind !== "unknown") continue; const node = readUsageNode(ix, klass.nodeId); const evidenceIds = node ? [node.id, klass.id, resolved.id, policy.id] : [klass.id, resolved.id, policy.id]; findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: `\`${klass.raw}\` did not resolve to any known palette token.`, location: klass.location, evidence: ix.evidence(evidenceIds), fingerprintIdentity: { source: "tailwind_class", file: klass.file, nodeId: klass.nodeId, originPath: klass.originPath, raw: klass.raw, utility: klass.utility, token: klass.value.token, reason: "resolution-miss", }, attributes: { rawClass: klass.raw, utility: klass.utility, token: klass.value.token, reason: "resolution-miss", }, }) ); } return findings; }