/** * `styles/no-raw-typography` — fires when a `font-size` declaration or JSX * inline `fontSize` uses a raw length literal instead of a typography token. * * Consumes the `style.fontSize.mustMatchScale` policy (a `style_font_size_scale` * fact) plus a backing scale. Without both it is silent — typography is only * enforced once a project actually has a font-size scale (derived from its * detected typography tokens). This closes the teach-vs-enforce gap where the * agent context advertises typography tokens but nothing flags off-scale text. * * Behaviour mirrors `styles/no-raw-spacing`: * - off the scale (`font-size: 13px` on [12,14,16]) → suggestion, never applied * silently (snapping changes rendered size); * - on the scale but a raw literal equal to a token (`font-size: 16px` where * `--font-size-md` is `16px`) → deterministic, value-preserving token swap. */ import type { FactId, FactIndex, ScaleFact, StyleDeclarationFact, TokenDefinitionFact, UsageComponentFact, UsageInlineStyleFact, } from "../facts/index.js"; import { makeFinding } from "./finding.js"; import type { Finding } from "./types.js"; import { indexComponentByNodeId, isApplicableTokenReference, matchesScale, nearestSignedScaleValue, normalizeLengthForScale, parseLengthValue, readUsageNode, tokenReference, } from "./utils.js"; export const RULE_ID = "styles/no-raw-typography"; export const RULE_VERSION = "1"; const FONT_SIZE_PROPERTIES = new Set(["font-size", "fontsize"]); export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] { const policy = ix.policy.fontSizeScale(); if (!policy) return []; const scale = ix.policy.scale(policy.scale); if (!scale) return []; const allowed = ix.policy.scaleValues(policy.scale).map((v) => v.value); if (allowed.length === 0) return []; const tokens = buildTypographyTokenLookup(ix, scale); const findings: Finding[] = []; for (const decl of ix.byKind("style_declaration")) { if (decl.property.toLowerCase() !== "font-size") continue; const checked = checkFontSize(decl.value, allowed, scale, tokens); if (!checked) continue; findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: typographyMessage(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: buildFix(decl.property, checked), attributes: { property: decl.property, rawValue: decl.value, scale: scale.name, allowed, suggestedValue: checked.suggestedValue, matchedToken: checked.matchedToken, source: "css", }, }) ); } const componentByNode = indexComponentByNodeId(ix); for (const inline of ix.byKind("usage_inline_style")) { if (inline.valueKind === "css-variable") continue; if (!FONT_SIZE_PROPERTIES.has(inline.property.toLowerCase())) continue; const checked = checkFontSize(inline.value, allowed, scale, tokens, inline.valueKind === "number"); if (!checked) continue; const node = readUsageNode(ix, inline.nodeId); if (!node) continue; 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]; findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: typographyMessage(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: buildFix(inline.property, checked), attributes: { property: inline.property, rawValue: inline.value, scale: scale.name, allowed, suggestedValue: checked.suggestedValue, matchedToken: checked.matchedToken, source: "jsx", }, }) ); } return findings; } function buildTypographyTokenLookup( ix: FactIndex, scale: ScaleFact ): Map { const out = new Map(); for (const token of ix.tokens.byCategory("typography")) { const parsed = parseLengthValue(token.value); if (!parsed || parsed.unit === null) continue; const normalized = normalizeLengthForScale(parsed, scale); if (normalized === null) continue; const key = Math.abs(normalized.value); if (!out.has(key)) out.set(key, token); } return out; } interface CheckedFontSize { suggestedValue?: string; fixEmittable: boolean; deterministicFix: boolean; reason: "off-scale" | "token-equivalent"; matchedToken?: string; } function checkFontSize( raw: string, allowed: number[], scale: ScaleFact, tokens: Map, bareNumberFix = false ): CheckedFontSize | null { const trimmed = raw.trim(); if (!trimmed || /var\(|calc\(|clamp\(|min\(|max\(|,|\s/.test(trimmed)) return null; const parsed = parseLengthValue(trimmed); if (!parsed) return null; const normalized = normalizeLengthForScale(parsed, scale); if (normalized === null) return null; const magnitude = Math.abs(normalized.value); const exactToken = tokens.get(magnitude); if (matchesScale(normalized.value, allowed)) { if (!exactToken) return null; const applicable = isApplicableTokenReference(exactToken.referenceFormat) && normalized.deterministicFix; return { suggestedValue: tokenReference(exactToken.name), fixEmittable: true, deterministicFix: applicable, reason: "token-equivalent", matchedToken: exactToken.name, }; } const nearest = nearestSignedScaleValue(normalized.value, allowed); if (nearest === null || (nearest === 0 && normalized.value !== 0)) { return { fixEmittable: false, deterministicFix: false, reason: "off-scale" }; } const nearestToken = tokens.get(Math.abs(nearest)); const suggestedValue = nearestToken ? tokenReference(nearestToken.name) : bareNumberFix ? `${nearest}` : `${nearest}${scale.unit}`; // Off-scale snaps change rendered size → suggestion only; and an assumed // root base makes the px guess unreliable, so attach no fix then. return { suggestedValue, fixEmittable: normalized.deterministicFix, deterministicFix: false, reason: "off-scale", }; } function buildFix(property: string, checked: CheckedFontSize) { 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 typographyMessage( property: string, value: string, allowed: number[], unit: "px" | "rem", checked: CheckedFontSize ): 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 typography scale. Allowed: ${suffix}${ellipsis}.`; }