/** * `styles/no-raw-dimensions` — fires when a length-bearing CSS declaration or * JSX inline style uses a raw dimension literal (`16px`, `1rem`, …) on a * property covered by the global `style.rawDimensions.forbid` policy. * * Suggestion strength is tiered, mirroring `styles/no-raw-spacing`: * * - **exact token match** — the raw value EXACTLY equals a tenant dimension * token of the same unit (units are never cross-converted — matching `1rem` * to a `16px` token would assume a root font size we cannot guarantee). Emits * a value-preserving, deterministic `var(--token)` fix. * - **off-scale single value** — `border-radius: 10px` when the tenant has no * `10px` token. Names the NEAREST token of the same unit via `suggestedToken` * and the message, but attaches NO fix — snapping changes the rendered value, * so it must stay a suggestion the author confirms, never an auto-rewrite. * Same-unit only: a `px` value never snaps to a `rem` token, keeping the * suggestion free of an assumed root-font-size. * - **no nearby token** (or a multi-value shorthand off-scale) — flagged with no * fix and no suggestion, leaving the choice to the author. * * Nearest snapping adds NO new findings and changes NO rendered values: an * off-scale dimension already fires under this policy; the snap only enriches * that finding with a `suggestedToken` hint. * * The policy is intentionally opt-in (no preset enables it): a plain * `fragments govern scan` never flags raw dimensions. Only callers that supply * a `style.rawDimensions.forbid` record (e.g. the conform tool) activate it. */ import type { FactId, FactIndex, TokenDefinitionFact } from "../facts/index.js"; import { makeFinding } from "./finding.js"; import type { Finding, FindingReplaceStyleValueFix } from "./types.js"; import { indexComponentByNodeId, parseLengthValue, readUsageNode } from "./utils.js"; export const RULE_ID = "styles/no-raw-dimensions"; export const RULE_VERSION = "1"; interface DimensionFixMatch { tokens: TokenDefinitionFact[]; fixValue: string; suggestedToken: string; } /** * How a raw dimension resolves against the tenant's tokens: * - `exact` — value-preserving token swap (deterministic, auto-applicable fix) * - `nearest` — closest same-unit token, surfaced as a `suggestedToken` hint with * NO fix object: snapping changes the rendered value, and the conform engine * auto-applies any token-backed `replaceStyleValue` fix, so attaching one would * silently rewrite the value. The suggestion rides on the message + attribute. * - `raw` — flagged, but no token to suggest (no fix) */ type DimensionResolution = | { kind: "exact"; tokens: TokenDefinitionFact[]; fixValue: string; suggestedToken: string } | { kind: "nearest"; token: TokenDefinitionFact; suggestedToken: string } | { kind: "raw" }; export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] { const policy = ix.policy.rawDimensionPolicy(); if (!policy) return []; const appliesTo = new Set(policy.appliesTo); const spacingTokens = ix.tokens.byCategory("spacing"); const radiusTokens = ix.tokens.byCategory("radius"); const preferLabel = policy.prefer === "token" ? "design token" : "CSS variable"; const findings: Finding[] = []; for (const decl of ix.byKind("style_declaration")) { const property = toKebabCase(decl.property); if (!appliesTo.has(property)) continue; const tokens = tokensForProperty(property, spacingTokens, radiusTokens); const resolution = resolveDimension(decl.value, tokens); if (!resolution) continue; const tokenIds = resolutionTokenIds(resolution); const evidenceIds = tokenIds.length ? [decl.id, policy.id, ...tokenIds] : [decl.id, policy.id]; findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: dimensionMessage(decl.value, decl.property, preferLabel, resolution, false), location: decl.location, evidence: ix.evidence(evidenceIds), fingerprintIdentity: { source: "style_declaration", file: decl.file, selector: decl.selector, declarationPath: decl.declarationPath, property: decl.property, value: decl.value, }, fix: dimensionFix(decl.property, resolution), attributes: { property: decl.property, rawValue: decl.value, source: "css", suggestedToken: resolutionSuggestedToken(resolution), }, }) ); } const componentByNode = indexComponentByNodeId(ix); for (const inline of ix.byKind("usage_inline_style")) { if (inline.valueKind === "css-variable") continue; const property = toKebabCase(inline.property); if (!appliesTo.has(property)) continue; const node = readUsageNode(ix, inline.nodeId); if (!node) continue; const componentEvidenceId = componentByNode.get(node.id)?.id; const tokens = tokensForProperty(property, spacingTokens, radiusTokens); const resolution = resolveDimension(inline.value, tokens); if (!resolution) continue; const baseEvidence = componentEvidenceId ? [node.id, componentEvidenceId, inline.id, policy.id] : [node.id, inline.id, policy.id]; const tokenIds = resolutionTokenIds(resolution); const evidenceIds = tokenIds.length ? [...baseEvidence, ...tokenIds] : baseEvidence; findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: dimensionMessage(inline.value, inline.property, preferLabel, resolution, true), 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: dimensionFix(inline.property, resolution), attributes: { property: inline.property, rawValue: inline.value, source: "jsx", suggestedToken: resolutionSuggestedToken(resolution), }, }) ); } return findings; } /** Normalize a JSX camelCase style key (`paddingTop`) to CSS kebab (`padding-top`). */ function toKebabCase(property: string): string { return property.replace(/([A-Z])/g, "-$1").toLowerCase(); } function tokensForProperty( property: string, spacingTokens: readonly TokenDefinitionFact[], radiusTokens: readonly TokenDefinitionFact[] ): readonly TokenDefinitionFact[] { return property.includes("radius") ? radiusTokens : spacingTokens; } /** * Resolve a raw dimension against the candidate tokens, preferring an exact * (value-preserving) match, then a nearest same-unit snap, then no token. * Returns `null` when the value is neither a token match nor a raw dimension — * i.e. nothing to flag. */ function resolveDimension( raw: string, tokens: readonly TokenDefinitionFact[] ): DimensionResolution | null { const exact = findDimensionFixMatch(raw, tokens); if (exact) { return { kind: "exact", tokens: exact.tokens, fixValue: exact.fixValue, suggestedToken: exact.suggestedToken, }; } if (!isRawDimensionValue(raw)) return null; const nearest = findNearestDimensionToken(raw, tokens); if (nearest) return { kind: "nearest", ...nearest }; return { kind: "raw" }; } function resolutionTokenIds(resolution: DimensionResolution): FactId[] { if (resolution.kind === "exact") return resolution.tokens.map((token) => token.id); if (resolution.kind === "nearest") return [resolution.token.id]; return []; } function resolutionSuggestedToken(resolution: DimensionResolution): string | undefined { if (resolution.kind === "exact") return resolution.suggestedToken; if (resolution.kind === "nearest") return resolution.suggestedToken; return undefined; } function dimensionMessage( rawValue: string, property: string, preferLabel: string, resolution: DimensionResolution, inline: boolean ): string { const where = inline ? `inline \`${property}\`` : `\`${property}\``; if (resolution.kind === "nearest") { return `Raw dimension ${rawValue} on ${where}. Closest token is \`${resolution.suggestedToken}\` (${resolution.token.value}); use a ${preferLabel} instead.`; } return `Raw dimension ${rawValue} on ${where}. Use a ${preferLabel} instead.`; } function dimensionFix( property: string, resolution: DimensionResolution ): FindingReplaceStyleValueFix | undefined { if (resolution.kind === "exact") { return { kind: "replaceStyleValue", title: `Replace ${property} with ${resolution.fixValue}`, property, value: resolution.fixValue, deterministic: true, }; } // `nearest` deliberately emits NO fix — a value-changing snap must stay a // suggestion (see DimensionResolution); only the exact, value-preserving swap // is safe to auto-apply. return undefined; } /** * Find a dimension token whose value matches the raw literal exactly, compared * in canonical px. No fuzzy/nearest matching — only value-preserving swaps earn * an exact match. */ function findDimensionMatch( raw: string, tokens: readonly TokenDefinitionFact[] ): TokenDefinitionFact | null { const target = normalizeDimension(raw); if (target === null) return null; for (const token of tokens) { if (normalizeDimension(token.value) === target) return token; } return null; } function findDimensionFixMatch( raw: string, tokens: readonly TokenDefinitionFact[] ): DimensionFixMatch | null { const single = findDimensionMatch(raw, tokens); if (single) { return { tokens: [single], fixValue: tokenVar(single), suggestedToken: single.name, }; } const parts = raw.trim().split(/\s+/); if (parts.length < 2 || parts.length > 4) return null; const matches: TokenDefinitionFact[] = []; for (const part of parts) { const match = findDimensionMatch(part, tokens); if (!match) return null; matches.push(match); } return { tokens: matches, fixValue: matches.map(tokenVar).join(" "), suggestedToken: matches.map((token) => token.name).join(" "), }; } /** * For an off-scale SINGLE dimension value, find the nearest token of the SAME * unit. Same-unit only keeps the suggestion value-safe: matching a `px` value * to a `rem` token would assume a root font size we cannot guarantee. A non-zero * value never snaps to a `0` token (that silently removes the dimension), and a * multi-value shorthand returns null (no single nearest target). */ function findNearestDimensionToken( raw: string, tokens: readonly TokenDefinitionFact[] ): { token: TokenDefinitionFact; suggestedToken: string } | null { const parsed = parseLengthValue(raw); if (!parsed) return null; const rawUnit = parsed.unit ?? "px"; let best: { token: TokenDefinitionFact; value: number } | null = null; for (const token of tokens) { const tokenLength = parseLengthValue(token.value); if (!tokenLength) continue; const tokenUnit = tokenLength.unit ?? "px"; if (tokenUnit !== rawUnit) continue; if (tokenLength.value === parsed.value) continue; // exact handled elsewhere if (tokenLength.value === 0 && parsed.value !== 0) continue; // never snap to 0 if (best === null) { best = { token, value: tokenLength.value }; continue; } const delta = Math.abs(parsed.value - tokenLength.value); const bestDelta = Math.abs(parsed.value - best.value); // Ties break toward the smaller token value, then first-seen, for stability. if (delta < bestDelta || (delta === bestDelta && tokenLength.value < best.value)) { best = { token, value: tokenLength.value }; } } if (!best) return null; return { token: best.token, suggestedToken: best.token.name, }; } function isRawDimensionValue(raw: string): boolean { if (normalizeDimension(raw) !== null) return true; const parts = raw.trim().split(/\s+/); return ( parts.length >= 2 && parts.length <= 4 && parts.every((part) => normalizeDimension(part) !== null) ); } /** * Canonical match key `${amount}${unit}` (unitless treated as px, for React * inline numerics and `0`). Units are intentionally NOT cross-converted: * matching `1rem` to a `16px` token would assume a 16px root font size we * cannot guarantee, so a unit mismatch never matches. This keeps every emitted * fix value-preserving. */ function normalizeDimension(value: string): string | null { const parsed = parseLengthValue(value); if (!parsed) return null; return `${parsed.value}${parsed.unit ?? "px"}`; } function tokenVar(token: TokenDefinitionFact): string { if (token.name.startsWith("$")) return token.name; const cssVarName = token.name.startsWith("--") ? token.name : `--${token.name.replace(/\./g, "-")}`; return `var(${cssVarName})`; }