import type { FactIndex } from "../facts/index.js"; import { makeFinding } from "./finding.js"; import type { Finding } from "./types.js"; import { contractPrefixFamilies } from "./utils.js"; export const RULE_ID = "tokens/css-vars-must-be-defined"; export const RULE_VERSION = "1"; // The leading `var(--name` of a reference. The `var(` keyword is matched // case-insensitively (CSS function names are case-insensitive per spec, so // `VAR(`/`Var(` must be caught — #33) but the captured custom-property name stays // case-preserving (custom-property names ARE case-sensitive; vocabulary.has must // stay exact). We judge ONLY the PRIMARY var() position — everything in fallback // position (a nested `var()`, a `$`/literal fallback) is ignored uniformly (#17): // after each match we skip past this var()'s matching close-paren so an inner // fallback var() is never re-scanned as a fresh primary. const CSS_VAR_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/gi; /** * Off-contract token drift (FUI2015). * * Activation is gated entirely on the `style.cssVars.mustBeDefined` policy fact, * which is declared ONLY when a project has authored token source files (the * cloud appends it when `tokenSourceFiles` is non-empty). No fact → no-op, so * the `fragments` preset alone never activates this rule. * * When active, a `var(--x)` is drift iff `--x` is shaped like a contract token * but is NOT in the vocabulary. "Shaped like a contract token" means one of: * 1. it shares a prefix family of the authored vocabulary (e.g. `--fui-`); OR * 2. it is single-segment (no hyphen after `--`, e.g. `--accent`) AND the * vocabulary itself contains at least one single-segment token. * Clause 2 keeps the rule live for FLAT token systems (`--accent`, `--bg`, which * yield no prefix family — #22/#25/#31/#36) and enforces the flat half of a MIXED * `--fui-*` + `--accent` vocabulary symmetrically with the namespaced half (#18), * order-independently. Genuinely foreign custom properties (`--swiper-*`, * `--radix-*`, layout/animation vars) are multi-segment with no shared family, so * they remain ignored — the rule neither floods on unrelated `var()`s nor fires * for projects without a contract. Hardcoded values are handled by the unchanged * `styles/no-raw-color` rule; this rule only judges custom-property references. */ export function ruleTokensCssVarsMustBeDefined(ix: FactIndex): Finding[] { const policy = ix.policy.cssVarsMustBeDefined(); if (!policy) return []; // The contract vocabulary travels on its OWN fact channel (contract_token), // injected by the CLI from the user's token source files — kept separate from // the local `token_definition` facts the raw-* rules + scale detection read. const vocabulary = new Set(ix.byKind("contract_token").map((token) => token.name)); // The truly-unauthored case (no contract token facts) is the only hard return — // it preserves the §11 inert-without-contract regression lock. if (vocabulary.size === 0) return []; // A flat/single-segment vocabulary (`--accent`, `--bg`) yields no prefix family, // but the rule must STILL enforce it (#22/#25/#31/#36) — we no longer hard-return // on an empty family set. `hasFlatTokens` lets single-segment refs through the // shaped gate whenever the vocabulary itself declares single-segment tokens, // which also enforces the flat half of a mixed vocabulary symmetrically (#18). const prefixes = contractPrefixFamilies(vocabulary); const hasFlatTokens = [...vocabulary].some(isSingleSegmentVarName); const locallyDefinedCustomProperties = new Set( ix .byKind("style_declaration") .filter((decl) => decl.property.startsWith("--")) .map((decl) => decl.property) ); const findings: Finding[] = []; for (const decl of ix.byKind("style_declaration")) { // Dedupe token names WITHIN one declaration so a value referencing the SAME // off-contract var twice yields ONE finding (#11) — otherwise the two share an // identical fingerprintIdentity and silently collapse to one at ingest. Scoped // per-declaration: the same var in two DIFFERENT declarations still yields two. const seen = new Set(); CSS_VAR_REFERENCE.lastIndex = 0; let match: RegExpExecArray | null; while ((match = CSS_VAR_REFERENCE.exec(decl.value)) !== null) { const tokenName = match[1]; // Advance past this var()'s matching close-paren so an inner fallback var() // is never re-scanned as a fresh primary — only the PRIMARY position is // judged (#17). `var(` starts at match.index, so the `(` is at +3. CSS_VAR_REFERENCE.lastIndex = skipToCloseParen(decl.value, match.index + 3); if (vocabulary.has(tokenName)) continue; const shaped = sharesContractPrefix(tokenName, prefixes) || (hasFlatTokens && isSingleSegmentVarName(tokenName)) || locallyDefinedCustomProperties.has(tokenName); if (!shaped) continue; if (seen.has(tokenName)) continue; seen.add(tokenName); findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: `var(${tokenName}) is not in the contract token vocabulary. Use a token defined in your contract's token source files.`, location: decl.location, evidence: ix.evidence([decl.id, policy.id]), fingerprintIdentity: { file: decl.file, selector: decl.selector, declarationPath: decl.declarationPath, property: decl.property, tokenName, }, attributes: { property: decl.property, rawValue: decl.value, tokenName, source: "css", }, }) ); } } return findings; } function sharesContractPrefix(name: string, prefixes: ReadonlySet): boolean { for (const prefix of prefixes) { if (name.startsWith(prefix)) return true; } return false; } /** * Whether a `--name` is single-segment (no hyphen after the leading `--`), e.g. * `--accent` (true) vs `--fui-color-accent` / `--swiper-theme-color` (false). * Mirrors `contractPrefixFamilies`' index-of(`-`, 2) so the two stay consistent. */ function isSingleSegmentVarName(name: string): boolean { return name.indexOf("-", 2) === -1; } /** * Index just past the `)` that closes the open paren AT `openParenIndex` * (a balanced-paren scan, so a nested fallback `var(--a, var(--b))` is skipped as * one unit). Returns the string length if the value is unbalanced (no close), * which safely ends the scan. */ function skipToCloseParen(value: string, openParenIndex: number): number { let depth = 0; for (let i = openParenIndex; i < value.length; i++) { const ch = value[i]; if (ch === "(") depth++; else if (ch === ")") { depth--; if (depth === 0) return i + 1; } } return value.length; }