/** * `tailwind/arbitrary-spacing` — flags arbitrary Tailwind spacing utilities * that cannot be proven to sit on the configured spacing scale. */ import type { FactIndex, TailwindClassFact } from "../facts/index.js"; import { makeFinding } from "./finding.js"; import type { Finding, FindingReplaceClassTokenFix } from "./types.js"; import { matchesScale, normalizeLengthForScale, parseLengthValue, readUsageNode, } from "./utils.js"; export const RULE_ID = "tailwind/arbitrary-spacing"; export const RULE_VERSION = "1"; const SPACING_UTILITY_TO_PROPERTY: ReadonlyMap = new Map([ ["m", "margin"], ["mt", "margin"], ["mr", "margin"], ["mb", "margin"], ["ml", "margin"], ["ms", "margin"], ["me", "margin"], ["mx", "margin"], ["my", "margin"], ["p", "padding"], ["pt", "padding"], ["pr", "padding"], ["pb", "padding"], ["pl", "padding"], ["ps", "padding"], ["pe", "padding"], ["px", "padding"], ["py", "padding"], ["gap", "gap"], ["gap-x", "gap"], ["gap-y", "gap"], ["space-x", "gap"], ["space-y", "gap"], ] as const); export function ruleTailwindArbitrarySpacing(ix: FactIndex): Finding[] { const findings: Finding[] = []; for (const klass of ix.byKind("tailwind_class")) { if (klass.value.kind !== "arbitrary") continue; const property = SPACING_UTILITY_TO_PROPERTY.get(klass.utility); if (!property) continue; const policy = ix.policy.propertyScale(property); if (!policy) continue; const scale = ix.policy.scale(policy.scale); if (!scale) continue; const allowed = ix.policy.scaleValues(policy.scale).map((v) => v.value); if (allowed.length === 0) continue; const parsed = parseLengthValue(klass.value.raw); const normalized = parsed ? normalizeLengthForScale(parsed, scale) : null; if (normalized && matchesScale(normalized.value, allowed)) continue; const node = readUsageNode(ix, klass.nodeId); const evidenceIds = node ? [node.id, klass.id, policy.id, scale.id] : [klass.id, policy.id, scale.id]; const fix = buildFix(klass); findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: `\`${klass.raw}\` is an arbitrary spacing value not on the project's spacing scale.`, 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, value: klass.value.raw, }, fix, attributes: { rawClass: klass.raw, rawValue: klass.value.raw, utility: klass.utility, property, scale: scale.name, allowed, normalizedValue: normalized?.value, normalizedUnit: normalized ? scale.unit : undefined, assumedRootFontSizePx: normalized?.assumedRootFontSizePx, assumedEmBasePx: normalized?.assumedEmBasePx, source: "tailwind", }, }), ); } return findings; } function buildFix(klass: TailwindClassFact): FindingReplaceClassTokenFix { return { kind: "replaceClassToken", title: `Review ${klass.raw}`, from: klass.raw, to: null, utility: klass.utility, deterministic: false, }; }