/** * `tailwind/off-scale-spacing-token` — checks resolved Tailwind spacing * tokens against the same project spacing scale used for CSS declarations. */ import type { FactIndex } from "../facts/index.js"; import { makeFinding } from "./finding.js"; import type { Finding, FindingReplaceClassTokenFix } from "./types.js"; import { indexResolvedTailwindTokens, resolvedForClass, TAILWIND_SPACING_UTILITY_TO_PROPERTY, } from "./tailwind-rule-utils.js"; import { matchesScale, normalizeLengthForScale, parseLengthValue, readUsageNode, } from "./utils.js"; export const RULE_ID = "tailwind/off-scale-spacing-token"; export const RULE_VERSION = "1"; export function ruleTailwindOffScaleSpacingToken(ix: FactIndex): Finding[] { const resolvedByKey = indexResolvedTailwindTokens(ix); const findings: Finding[] = []; for (const klass of ix.byKind("tailwind_class")) { if (klass.value.kind !== "token") continue; const property = TAILWIND_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 resolved = resolvedForClass(resolvedByKey, klass); if (!resolved || resolved.resolved.kind !== "length") continue; const parsed = parseLengthValue(resolved.resolved.value); if (!parsed) continue; const normalized = normalizeLengthForScale(parsed, scale); if (!normalized) continue; const signedValue = klass.negative ? -normalized.value : normalized.value; if (matchesScale(signedValue, allowed)) continue; const node = readUsageNode(ix, klass.nodeId); const evidenceIds = node ? [node.id, klass.id, resolved.id, policy.id, scale.id] : [klass.id, resolved.id, policy.id, scale.id]; findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: `\`${klass.raw}\` resolves to ${formatResolvedValue(signedValue, scale.unit)}, which is not on the project's spacing scale [${allowed.join(", ")}].`, 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, resolvedValue: signedValue, scale: scale.name, }, fix: buildFix(klass.raw, klass.utility), attributes: { rawClass: klass.raw, utility: klass.utility, token: klass.value.token, property, scale: scale.name, allowed, resolvedValue: resolved.resolved.value, resolvedPx: scale.unit === "px" ? signedValue : undefined, resolvedUnit: scale.unit, source: resolved.resolved.source, }, }) ); } return findings; } function formatResolvedValue(value: number, unit: string): string { return unit === "px" ? `${value}px` : `${value}${unit}`; } function buildFix(raw: string, utility: string): FindingReplaceClassTokenFix { return { kind: "replaceClassToken", title: `Review ${raw}`, from: raw, to: null, utility, deterministic: false, }; }