/** * `styles/no-raw-spacing` — fires when a length-bearing CSS declaration or * JSX inline style on a scaled property uses a raw spacing literal instead of * a design token. * * The rule needs both a `style.property.scale` policy (which property is * scaled, and which scale name backs it) and a matching set of `scale_value` * facts (the allowed numbers). If either is missing the rule is silent for * that property. * * Two violation shapes, mirroring `styles/no-raw-color`: * * - **off the scale** — `padding: 13px` on a [0,4,8,12,16] scale. Flagged; the * suggested fix snaps to the nearest scale value (as the matching token when * one exists, else the raw grid value). Because snapping *changes* the * rendered value, the fix is a suggestion (non-deterministic) — never applied * silently. * - **on the scale but a raw literal that equals a token** — `padding: 16px` * where `--space-md` is `16px`. Flagged; the fix swaps in `var(--space-md)`. * This is value-preserving, so it is a deterministic, auto-applicable fix. * (Only fires when a matching spacing token exists — a pure-grid value with no * named token, like `0`, stays silent.) * * `padding: var(--space-3)` → silent (token reference). `padding: 1.5rem` on a * px scale → normalized through the configured root font size before matching. */ import type { FactId, FactIndex, ScaleFact, StyleDeclarationFact, UsageComponentFact, UsageInlineStyleFact, } from "../facts/index.js"; import { makeFinding } from "./finding.js"; import { buildSpacingTokenLookup, resolveSpacingValue, type CheckedSpacingValue, type SpacingTokenLookup, } from "./spacing-resolution.js"; import type { Finding } from "./types.js"; import { indexComponentByNodeId, readUsageNode } from "./utils.js"; export const RULE_ID = "styles/no-raw-spacing"; export const RULE_VERSION = "1"; export function ruleStylesNoRawSpacing(ix: FactIndex): Finding[] { const findings: Finding[] = []; // Token lookups are scale-relative (token values normalize to the scale's // unit), so cache one per scale name to avoid rebuilding per declaration. const lookupCache = new Map(); const lookupFor = (scale: ScaleFact): SpacingTokenLookup => { let lookup = lookupCache.get(scale.name); if (!lookup) { lookup = buildSpacingTokenLookup(ix.tokens.byCategory("spacing"), scale); lookupCache.set(scale.name, lookup); } return lookup; }; for (const decl of ix.byKind("style_declaration")) { const finding = checkDeclaration(ix, decl, lookupFor); if (finding) findings.push(finding); } const componentByNode = indexComponentByNodeId(ix); for (const inline of ix.byKind("usage_inline_style")) { const finding = checkInlineStyle(ix, inline, componentByNode, lookupFor); if (finding) findings.push(finding); } return findings; } /** * Inline style property names arrive camelCase (`marginTop`); the policy keys * spacing scales by CSS property (`margin-top`). Normalize so longhand inline * props resolve their scale the same way the CSS file path does. */ function cssPropertyName(property: string): string { return property.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); } function checkDeclaration( ix: FactIndex, decl: StyleDeclarationFact, lookupFor: (scale: ScaleFact) => SpacingTokenLookup ): Finding | null { const policy = ix.policy.propertyScale(decl.property); if (!policy) return null; const scale = ix.policy.scale(policy.scale); if (!scale) return null; const allowed = ix.policy.scaleValues(policy.scale).map((v) => v.value); if (allowed.length === 0) return null; const checked = resolveSpacingValue({ raw: decl.value, allowed, scale, tokens: lookupFor(scale), }); if (!checked) return null; return makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: spacingMessage(decl.property, decl.value, allowed, scale.unit, checked), location: decl.location, evidence: ix.evidence([decl.id, policy.id, scale.id]), fingerprintIdentity: { source: "style_declaration", file: decl.file, selector: decl.selector, declarationPath: decl.declarationPath, property: decl.property, value: decl.value, }, fix: buildSpacingFix(decl.property, checked), attributes: { property: decl.property, rawValue: decl.value, scale: scale.name, allowed, suggestedValue: checked.suggestedValue, matchedToken: checked.matchedToken, suggestedToken: checked.suggestedToken, reason: checked.reason, normalizedValue: checked.normalizedValue, normalizedUnit: scale.unit, assumedRootFontSizePx: checked.assumedRootFontSizePx, assumedEmBasePx: checked.assumedEmBasePx, source: "css", }, }); } function checkInlineStyle( ix: FactIndex, inline: UsageInlineStyleFact, componentByNode: Map, lookupFor: (scale: ScaleFact) => SpacingTokenLookup ): Finding | null { const policy = ix.policy.propertyScale(cssPropertyName(inline.property)); if (!policy) return null; const scale = ix.policy.scale(policy.scale); if (!scale) return null; const allowed = ix.policy.scaleValues(policy.scale).map((v) => v.value); if (allowed.length === 0) return null; if (inline.valueKind === "css-variable") return null; const checked = resolveSpacingValue({ raw: inline.value, allowed, scale, tokens: lookupFor(scale), bareNumberFix: inline.valueKind === "number", }); if (!checked) return null; const node = readUsageNode(ix, inline.nodeId); if (!node) return null; const componentEvidenceId = componentByNode.get(node.id)?.id; const evidenceIds = componentEvidenceId ? [node.id, componentEvidenceId, inline.id, policy.id, scale.id] : [node.id, inline.id, policy.id, scale.id]; return makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: spacingMessage(inline.property, inline.value, allowed, scale.unit, checked), location: node.location, evidence: ix.evidence(evidenceIds), fingerprintIdentity: { source: "usage_inline_style", file: node.file, nodePath: node.nodePath, element: node.element, property: inline.property, value: inline.value, }, fix: buildSpacingFix(inline.property, checked), attributes: { property: inline.property, rawValue: inline.value, scale: scale.name, allowed, suggestedValue: checked.suggestedValue, matchedToken: checked.matchedToken, suggestedToken: checked.suggestedToken, reason: checked.reason, normalizedValue: checked.normalizedValue, normalizedUnit: scale.unit, assumedRootFontSizePx: checked.assumedRootFontSizePx, assumedEmBasePx: checked.assumedEmBasePx, source: "jsx", }, }); } function buildSpacingFix(property: string, checked: CheckedSpacingValue) { if (!checked.fixEmittable || checked.suggestedValue === undefined) return undefined; return { kind: "replaceStyleValue" as const, title: `Replace ${property} with ${checked.suggestedValue}`, property, value: checked.suggestedValue, deterministic: checked.deterministicFix, }; } function spacingMessage( property: string, value: string, allowed: number[], unit: "px" | "rem", checked: CheckedSpacingValue ): string { if (checked.reason === "token-equivalent" && checked.matchedToken) { return `\`${property}: ${value}\` is a raw literal that matches token \`${checked.matchedToken}\`. Reference the token instead.`; } const sample = allowed .slice() .sort((a, b) => a - b) .slice(0, 6); const suffix = `${sample.join(unit + ", ")}${unit}`; const ellipsis = allowed.length > sample.length ? ", …" : ""; return `\`${property}: ${value}\` is not on the spacing scale. Allowed: ${suffix}${ellipsis}.`; }