/** * Rule helpers — small internal queries shared by Phase 5 rules. * * These live next to the rules instead of on `FactIndex` until they prove * useful for two or more rules. The plan is to lift them onto the index only * when a third caller appears. */ import type { Fact, FactId, FactIndex, StyleDeclarationFact, TokenDefinitionFact, UsageComponentFact, UsageInlineStyleFact, UsageNodeFact, UsagePropResolvedFact, UsageTextChildFact, } from "../facts/index.js"; export interface LengthScale { unit: "px" | "rem"; rootFontSizePx?: number; emBasePx?: number; } /** Group `usage_prop_resolved` facts by their parent `usage_node` id. */ export function indexPropsByNodeId(ix: FactIndex): Map { const out = new Map(); for (const fact of ix.byKind("usage_prop_resolved")) { let bucket = out.get(fact.nodeId); if (!bucket) { bucket = []; out.set(fact.nodeId, bucket); } bucket.push(fact); } return out; } /** Group `usage_text_child` facts by their parent `usage_node` id. */ export function indexTextChildrenByNodeId(ix: FactIndex): Map { const out = new Map(); for (const fact of ix.byKind("usage_text_child")) { let bucket = out.get(fact.nodeId); if (!bucket) { bucket = []; out.set(fact.nodeId, bucket); } bucket.push(fact); } return out; } /** Group `usage_inline_style` facts by their parent `usage_node` id. */ export function indexInlineStylesByNodeId(ix: FactIndex): Map { const out = new Map(); for (const fact of ix.byKind("usage_inline_style")) { let bucket = out.get(fact.nodeId); if (!bucket) { bucket = []; out.set(fact.nodeId, bucket); } bucket.push(fact); } return out; } /** Map `usage_component` records by their `nodeId` for fast component lookup. */ export function indexComponentByNodeId(ix: FactIndex): Map { const out = new Map(); for (const fact of ix.byKind("usage_component")) { out.set(fact.nodeId, fact); } return out; } /** * A region: a container node paired with every `usage_node` nested beneath it * within the same root tree. Membership is decided purely by `nodePath` — no * component identity, no framework knowledge — so the same grouping works for * any adapter that emits conformant paths. */ export interface Region { /** The container node whose `nodePath` is a strict prefix of every member. */ container: UsageNodeFact; /** Every node strictly nested beneath the container, at any depth. */ members: UsageNodeFact[]; } /** * Group `usage_node` facts into regions by shared `nodePath` prefix. * * Every node is treated as a potential container; a node B is a member of node * A's region iff A's path segments are a *strict* prefix of B's and both share * the same `(file, rootIndex)`. The consuming rule decides which containers * matter via its region selector — this helper does not interpret what a * container "is." * * `nodePath` segments are opaque. The path is split into a leading `rootIndex` * token (everything before the first `:`) and `/`-joined segments; only whole * segments are compared. The `expr:` / `attr:` / `jsx:` tokens a JSX adapter * encodes inside a segment are never parsed — that is what keeps this helper * framework-blind (per the usage-node-fact contract). Non-conformant paths are * skipped rather than mis-grouped. * * Cost is O(nodes²) within one `(file, rootIndex)` bucket (each path is parsed * once up front); root trees are small in practice, so this stays well inside * the per-file scan budget. */ export function regionsByPrefix(ix: FactIndex): Region[] { // Bucket by (file, rootIndex) so a prefix comparison never crosses root // trees — separate roots in one file restart segment numbering, so their // paths can collide segment-for-segment without actually being nested. const buckets = new Map>(); for (const fact of ix.byKind("usage_node")) { const parsed = parseNodePath(fact.nodePath); if (!parsed) continue; // rootIndex is colon-free (it is the token before nodePath's first ":"), so // putting it first lets that ":" cleanly delimit it from the free-form file. const key = `${parsed.rootIndex}:${fact.file}`; let bucket = buckets.get(key); if (!bucket) { bucket = []; buckets.set(key, bucket); } bucket.push({ fact, segments: parsed.segments }); } const regions: Region[] = []; for (const bucket of buckets.values()) { for (const candidate of bucket) { const members: UsageNodeFact[] = []; for (const other of bucket) { if (other !== candidate && isStrictSegmentPrefix(candidate.segments, other.segments)) { members.push(other.fact); } } regions.push({ container: candidate.fact, members }); } } return regions; } /** * Split a `nodePath` into its leading `rootIndex` token and whole segments. * Returns `null` for a path with no `rootIndex:` prefix (non-conformant — e.g. * a future non-React adapter that hasn't adopted the contract); the caller * skips those rather than risk mis-grouping. Segments are never split below the * `/` boundary, keeping `expr:` / `attr:` / `jsx:` tokens opaque. */ function parseNodePath(nodePath: string): { rootIndex: string; segments: string[] } | null { const colon = nodePath.indexOf(":"); if (colon === -1) return null; const rest = nodePath.slice(colon + 1); return { rootIndex: nodePath.slice(0, colon), segments: rest === "" ? [] : rest.split("/"), }; } /** Whether `prefix` is a strict (shorter) whole-segment prefix of `full`. */ function isStrictSegmentPrefix(prefix: readonly string[], full: readonly string[]): boolean { if (prefix.length >= full.length) return false; for (let i = 0; i < prefix.length; i++) { if (prefix[i] !== full[i]) return false; } return true; } /** Read a `usage_node` fact through the index and narrow the kind. */ export function readUsageNode(ix: FactIndex, id: FactId): UsageNodeFact | undefined { const fact: Fact | undefined = ix.get(id); return fact && fact.kind === "usage_node" ? fact : undefined; } /** Read a `style_declaration` fact through the index and narrow the kind. */ export function readStyleDeclaration(ix: FactIndex, id: FactId): StyleDeclarationFact | undefined { const fact: Fact | undefined = ix.get(id); return fact && fact.kind === "style_declaration" ? fact : undefined; } /** * The set of CSS custom-property names a token vocabulary defines, normalized to * `--`-prefixed form. Shared by the token-vocabulary rules * (`require-dual-fallback`, `css-vars-must-be-defined`). */ export function cssVariableNames(tokens: readonly TokenDefinitionFact[]): Set { const names = new Set(); for (const token of tokens) { names.add(token.name.startsWith("--") ? token.name : `--${token.name}`); } return names; } /** * The prefix families of a token vocabulary — the namespace each token name * declares, up to and including the first hyphen after the leading `--` * (`--fui-color-accent` → `--fui-`). A single-segment token (`--accent`, no * namespace) contributes no family. Used to decide whether an unknown `var(--x)` * is "shaped like a contract token" (off-contract drift) or a genuinely foreign * custom property (`--swiper-*`, `--radix-*`, layout/animation vars — ignored). */ export function contractPrefixFamilies(names: ReadonlySet): Set { const families = new Set(); for (const name of names) { const hyphen = name.indexOf("-", 2); if (hyphen === -1) continue; families.add(name.slice(0, hyphen + 1)); } return families; } /** * Universal JSX props that pass `components/unknown-prop` regardless of the * component's prop schema. These are React/DOM concerns, not design system * concerns, and asking authors to declare them in `*.fragment.ts` would noise * up every component. */ const UNIVERSAL_JSX_PROPS = new Set([ "key", "ref", "children", "className", "style", "id", "role", "tabIndex", "title", "slot", "lang", "dir", "hidden", "draggable", "spellCheck", "translate", "contentEditable", "suppressContentEditableWarning", "suppressHydrationWarning", ]); export function isUniversalJsxProp(prop: string): boolean { if (UNIVERSAL_JSX_PROPS.has(prop)) return true; if (prop.startsWith("aria-")) return true; if (prop.startsWith("data-")) return true; if (prop.startsWith("on") && prop.length > 2 && prop[2] === prop[2].toUpperCase()) { return true; } return false; } /** * Parse a length-ish CSS value (e.g., `13px`, `13`, `1.5rem`, `0`) into a * numeric value and unit. Returns `null` for values that include CSS function * calls, multi-value shorthands, or anything else that can't be cleanly * compared against a numeric scale. */ export function parseLengthValue( raw: string ): { value: number; unit: "px" | "rem" | "em" | null } | null { const trimmed = raw.trim(); if (!trimmed) return null; const match = /^(-?\d+(?:\.\d+)?)(px|rem|em)?$/i.exec(trimmed); if (!match) return null; const value = Number.parseFloat(match[1]); if (!Number.isFinite(value)) return null; const rawUnit = match[2]?.toLowerCase(); const unit = rawUnit === "px" || rawUnit === "rem" || rawUnit === "em" ? rawUnit : null; return { value, unit }; } export type ParsedLengthValue = NonNullable>; export function matchesScale(value: number, allowed: readonly number[]): boolean { const magnitude = Math.abs(value); if (magnitude === 0) return allowed.includes(0); return allowed.includes(magnitude); } export function normalizeLengthForScale( parsed: ParsedLengthValue, scale: LengthScale ): { value: number; deterministicFix: boolean; assumedRootFontSizePx?: number; assumedEmBasePx?: number; } | null { const unit = parsed.unit ?? scale.unit; if (unit === scale.unit) { return { value: parsed.value, deterministicFix: true }; } if (scale.unit === "px" && unit === "rem") { const explicit = scale.rootFontSizePx !== undefined; const rootFontSizePx = scale.rootFontSizePx ?? 16; return { value: parsed.value * rootFontSizePx, deterministicFix: explicit, assumedRootFontSizePx: explicit ? undefined : rootFontSizePx, }; } if (scale.unit === "px" && unit === "em") { const explicit = scale.emBasePx !== undefined; const emBasePx = scale.emBasePx ?? scale.rootFontSizePx ?? 16; return { value: parsed.value * emBasePx, deterministicFix: explicit, assumedEmBasePx: explicit ? undefined : emBasePx, }; } if (scale.unit === "rem" && unit === "px") { const explicit = scale.rootFontSizePx !== undefined; if (!explicit) return null; return { value: parsed.value / scale.rootFontSizePx!, deterministicFix: true, }; } return null; } export function nearestScaleValue(value: number, allowed: readonly number[]): number | null { if (allowed.length === 0) return null; let best = allowed[0]; let bestDelta = Math.abs(value - best); for (let i = 1; i < allowed.length; i++) { const candidate = allowed[i]; const delta = Math.abs(value - candidate); if (delta < bestDelta || (delta === bestDelta && candidate < best)) { best = candidate; bestDelta = delta; } } return best; } export function nearestSignedScaleValue(value: number, allowed: readonly number[]): number | null { const nearest = nearestScaleValue(Math.abs(value), allowed); if (nearest === null) return null; if (nearest === 0) return 0; return value < 0 ? -nearest : nearest; } /** * Detect whether a CSS value contains a raw color literal — hex, rgb/rgba, * hsl/hsla, or a named color. Returns the color literal so the rule can quote * it in the message and as a fingerprint input. Returns `null` for token * references (`var(--...)`) and for values that don't look like a color. */ export function detectRawColor(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) return null; // A hex/color literal sitting inside a `var(--token, )` expression // is the *fallback*, not a raw color — flagging it both produces false // positives on shorthands (`border: 1px solid var(--x, #hex)`) and fights the // dual-fallback rule, which mandates exactly that literal. Strip every var() // expression (innermost-first, so nested fallbacks are handled) before // scanning, so only a literal *outside* any var() is treated as raw. const scanned = stripVarExpressions(trimmed); if (!scanned.trim()) return null; const hex = /#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\b/i.exec(scanned); if (hex) return hex[0]; const fn = /(rgb|rgba|hsl|hsla)\s*\([^)]*\)/i.exec(scanned); if (fn) return fn[0]; for (const part of scanned.split(/\s+/)) { const lower = part.toLowerCase(); if (/^[a-z]+$/i.test(lower) && CSS_NAMED_COLORS.has(lower)) { return lower; } } return null; } /** * Remove every `var(--token, …)` expression from a CSS value, innermost-first, * so a literal inside a var() fallback is not mistaken for a raw color. A bare * `var(--token)` (no fallback) also disappears, preserving the previous * behavior of exempting whole-value token references. */ function stripVarExpressions(value: string): string { let out = value; const innermost = /var\([^()]*\)/gi; // Iterate so nested fallbacks (`var(--a, var(--b, #fff))`) collapse fully. for (let i = 0; i < 10 && innermost.test(out); i += 1) { out = out.replace(innermost, " "); innermost.lastIndex = 0; } return out; } const CSS_NAMED_COLORS = new Set([ "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", "bisque", "black", "blanchedalmond", "blue", "blueviolet", "brown", "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue", "cornsilk", "crimson", "cyan", "darkblue", "darkcyan", "darkgoldenrod", "darkgray", "darkgreen", "darkgrey", "darkkhaki", "darkmagenta", "darkolivegreen", "darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen", "darkslateblue", "darkslategray", "darkslategrey", "darkturquoise", "darkviolet", "deeppink", "deepskyblue", "dimgray", "dimgrey", "dodgerblue", "firebrick", "floralwhite", "forestgreen", "fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod", "gray", "green", "greenyellow", "grey", "honeydew", "hotpink", "indianred", "indigo", "ivory", "khaki", "lavender", "lavenderblush", "lawngreen", "lemonchiffon", "lightblue", "lightcoral", "lightcyan", "lightgoldenrodyellow", "lightgray", "lightgreen", "lightgrey", "lightpink", "lightsalmon", "lightseagreen", "lightskyblue", "lightslategray", "lightslategrey", "lightsteelblue", "lightyellow", "lime", "limegreen", "linen", "magenta", "maroon", "mediumaquamarine", "mediumblue", "mediumorchid", "mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise", "mediumvioletred", "midnightblue", "mintcream", "mistyrose", "moccasin", "navajowhite", "navy", "oldlace", "olive", "olivedrab", "orange", "orangered", "orchid", "palegoldenrod", "palegreen", "paleturquoise", "palevioletred", "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", "purple", "rebeccapurple", "red", "rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", "seashell", "sienna", "silver", "skyblue", "slateblue", "slategray", "slategrey", "snow", "springgreen", "steelblue", "tan", "teal", "thistle", "tomato", "turquoise", "violet", "wheat", "white", "whitesmoke", "yellow", "yellowgreen", ]); /** * Default exemptions for raw color rule. Combines policy-supplied `except` * with universally meaningful keywords that aren't true colors. */ export const COLOR_KEYWORD_EXEMPTIONS = new Set([ "transparent", "currentcolor", "inherit", "initial", "unset", ]); export function isExemptColor(value: string, except: readonly string[]): boolean { const lower = value.trim().toLowerCase(); if (COLOR_KEYWORD_EXEMPTIONS.has(lower)) return true; for (const ex of except) { if (ex.toLowerCase() === lower) return true; } return false; } /** * Render a token name as the reference an author would write: SCSS `$vars` * pass through, dotted/bare names become a `var(--…)` custom property. Shared by * the color and spacing rules so a "use the token" fix is expressed the same way * everywhere. */ export function tokenReference(name: string): string { if (name.startsWith("$")) return name; const cssVarName = name.startsWith("--") ? name : `--${name.replace(/\./g, "-")}`; return `var(${cssVarName})`; } /** * Whether a token reference can be *applied* automatically (written verbatim * and still compile). A `css-var` / `scss-var` token resolves on its own; a * `scss-map` member (synthetic `$colors-primary` minted from a Sass map) or a * `dtcg` JSON token (no CSS custom property emitted by default) does not, so * those fixes are surfaced as suggestions rather than deterministic rewrites. * Tokens without a known format (legacy/hand-built facts) are treated as safe. */ export function isApplicableTokenReference( referenceFormat: TokenDefinitionFact["referenceFormat"] ): boolean { return ( referenceFormat === undefined || referenceFormat === "css-var" || referenceFormat === "scss-var" ); }