From 7cc1f4b796a40fbc99531c5b3e7b7f4a4e2a3277 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 19:46:57 -0400 Subject: [PATCH] ci: verify colour values in token migrations --- .github/workflows/ci.yml | 14 ++ scripts/check-token-parity.mjs | 272 ++++++++++++++++++++++++++++ scripts/check-token-parity.test.mjs | 84 +++++++++ scripts/token-parity-allowlist.json | 1 + scripts/token-parity-baseline.json | 1 + 5 files changed, 372 insertions(+) create mode 100644 scripts/check-token-parity.mjs create mode 100644 scripts/check-token-parity.test.mjs create mode 100644 scripts/token-parity-allowlist.json create mode 100644 scripts/token-parity-baseline.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7449e587a2..6ec4750b45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,20 @@ concurrency: cancel-in-progress: true jobs: + token-parity: + name: Token value parity + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - run: node --test scripts/check-token-parity.test.mjs + - run: node scripts/check-token-parity.mjs origin/main + changes: name: Detect changes runs-on: ubuntu-latest diff --git a/scripts/check-token-parity.mjs b/scripts/check-token-parity.mjs new file mode 100644 index 0000000000..9363daecf6 --- /dev/null +++ b/scripts/check-token-parity.mjs @@ -0,0 +1,272 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const THEME = "packages/studio/src/styles/theme.css"; +const BASELINE = "scripts/token-parity-baseline.json"; +const ALLOWLIST = "scripts/token-parity-allowlist.json"; +const COLOR = /#[\da-f]{3,8}\b|\b(?:rgba?|hsla?)\([^)]*\)/gi; +const TOKEN = /var\(\s*(--[\w-]+)(?:\s*,\s*([^()]*|(?:rgba?|hsla?)\([^)]*\)))?\s*\)/g; + +function hexChannels(text) { + let hex = text.slice(1); + if ([3, 4].includes(hex.length)) hex = [...hex].map((c) => c + c).join(""); + if (![6, 8].includes(hex.length)) throw new Error(`Unsupported colour: ${text}`); + const channels = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16)); + return [...channels, hex.length === 8 ? parseInt(hex.slice(6), 16) / 255 : 1]; +} +function hueDegrees(hue) { + const n = parseFloat(hue); + if (hue.endsWith("turn")) return n * 360; + if (hue.endsWith("grad")) return n * 0.9; + if (hue.endsWith("rad")) return (n * 180) / Math.PI; + return n; +} +function hslChannels(parts) { + const h = (((hueDegrees(parts[0]) % 360) + 360) % 360) / 30; + const saturation = parseFloat(parts[1]) / 100; + const lightness = parseFloat(parts[2]) / 100; + const amplitude = saturation * Math.min(lightness, 1 - lightness); + return [0, 8, 4].map( + (n) => + 255 * + (lightness - amplitude * Math.max(-1, Math.min(((n + h) % 12) - 3, 9 - ((n + h) % 12), 1))), + ); +} +function functionalChannels(text) { + const parts = text + .slice(text.indexOf("(") + 1, -1) + .split(/[\s,/]+/) + .filter(Boolean); + const alpha = parts[3] ?? "1"; + const opacity = parseFloat(alpha) / (alpha.endsWith("%") ? 100 : 1); + const channels = text.startsWith("rgb") + ? parts.slice(0, 3).map((p) => parseFloat(p) * (p.endsWith("%") ? 2.55 : 1)) + : hslChannels(parts); + return [...channels, opacity]; +} +export function normalizeColor(value) { + const text = value.toLowerCase().trim(); + const channels = text.startsWith("#") ? hexChannels(text) : functionalChannels(text); + if (channels.length !== 4 || channels.some((n) => !Number.isFinite(n))) + throw new Error(`Unsupported colour: ${value}`); + return channels + .map((n, i) => Number(Math.max(0, Math.min(i === 3 ? 1 : 255, n)).toFixed(12))) + .join(","); +} + +function canonical(line) { + return line + .replace( + /\b([\w:-]+)-(white|black)(?:\/(\d+(?:\.\d+)?))?\b/g, + (_, prefix, color, alpha = "100") => { + const n = color === "white" ? 255 : 0; + return `${prefix}-[rgba(${n},${n},${n},${Number(alpha) / 100})]`; + }, + ) + .replace(COLOR, (color) => `COLOR(${normalizeColor(color)})`) + .replace(/\s+/g, ""); +} + +export function tokenValues(css) { + const values = new Map(); + for (const [, name, value] of css + .replace(/\/\*[\s\S]*?\*\//g, "") + .matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) { + if (values.has(name)) throw new Error(`Ambiguous token default: ${name}`); + values.set(name, value.trim()); + } + return values; +} + +function expandTokens(line, values, seen = []) { + return line.replace(TOKEN, (reference, name, fallback) => { + if (!values.has(name)) return fallback ?? reference; + if (seen.includes(name)) throw new Error(`Cyclic token alias: ${name}`); + return expandTokens(values.get(name), values, [...seen, name]); + }); +} + +function quotedEnd(line, start) { + let end = start + 1; + while (end < line.length) { + if (line[end] === line[start] && line[end - 1] !== "\\") return end + 1; + end++; + } + return end; +} +function scalarEnd(line, start) { + let depth = 0; + for (let end = start; end < line.length; end++) { + if (depth === 0 && /[,;}]/.test(line[end])) return end; + depth += Number(line[end] === "(") - Number(line[end] === ")"); + } + return line.length; +} + +function valueEnd(line, start) { + return /["'`]/.test(line[start]) ? quotedEnd(line, start) : scalarEnd(line, start); +} + +function propertySlots(line) { + const result = []; + const properties = /([\w-]+)["']?\s*[:=]\s*/g; + for (let match = properties.exec(line); match; match = properties.exec(line)) { + if (line[properties.lastIndex] === "{") continue; + const end = valueEnd(line, properties.lastIndex); + const value = line.slice(properties.lastIndex, end).replace(/^["'`]|["'`]$/g, ""); + result.push({ key: match[1], value }); + properties.lastIndex = end; + } + return result; +} +function classSlots(line) { + const result = []; + const classes = [ + ...line.matchAll( + /([\w:-]+)-(white|black)(?:\/(\d+(?:\.\d+)?))?\b|([\w:-]+)-\[(var\([^)]*\))\]/g, + ), + ]; + for (const match of classes) { + const key = match[1] ?? match[4]; + result.push({ key: `class:${key}`, value: match[0] }); + } + return result; +} +function slots(line) { + const classes = classSlots(line); + if (classes.length) return classes; + const properties = propertySlots(line); + return properties.length ? properties : [{ key: "line", value: line }]; +} + +function matchReplacement(slot, oldSlots, values) { + const names = [...slot.value.matchAll(TOKEN)].map((match) => match[1]); + if (!names.length) return undefined; + const candidates = oldSlots.filter( + (old) => + old.key === slot.key && + /#[\da-f]{3,8}\b|\b(?:rgba?|hsla?)\(|-(?:white|black)(?:\/\d+)?\b/i.test(old.value), + ); + if (!candidates.length) return undefined; + const expanded = canonical(expandTokens(slot.value, values)); + const exact = candidates.find((old) => canonical(old.value) === expanded); + return { before: exact ?? candidates[0], equal: Boolean(exact), names }; +} +function compareSlot(file, line, slot, oldSlots, values, allowlist) { + const match = matchReplacement(slot, oldSlots, values); + if (!match) return []; + oldSlots.splice(oldSlots.indexOf(match.before), 1); + if (match.equal) return []; + const entry = { file, before: match.before.line.trim(), after: line.trim() }; + if (allowedReplacement(entry, allowlist)) return []; + return [{ ...entry, tokens: match.names, message: "token default changes the replaced value" }]; +} + +function replacementIssues(file, removed, added, values, allowlist) { + const oldSlots = removed.flatMap((line) => slots(line).map((slot) => ({ ...slot, line }))); + const issues = []; + for (const line of added) { + for (const slot of slots(line)) { + issues.push(...compareSlot(file, line, slot, oldSlots, values, allowlist)); + } + } + return issues; +} +function allowedReplacement(entry, allowlist) { + return allowlist.some((rule) => { + if (typeof rule.reason !== "string") return false; + return [ + rule.file === entry.file, + rule.before === entry.before, + rule.after === entry.after, + rule.reason.trim(), + ].every(Boolean); + }); +} + +function diffLineKind(line) { + if (line.startsWith("+++ b/")) return "file"; + if (/^(?:--- |\+\+\+ )/.test(line)) return "skip"; + return { "-": "removed", "+": "added" }[line[0]] ?? "context"; +} +function finishesBlock(kind, block) { + if (["file", "context"].includes(kind)) return true; + return kind === "removed" && block.added.length > 0; +} +function recordLine(block, kind, line) { + if (kind === "file") block.file = line.slice(6); + if (["added", "removed"].includes(kind)) block[kind].push(line.slice(1)); +} +function diffBlocks(diff) { + const blocks = []; + let block = { file: "", removed: [], added: [] }; + for (const line of diff.split("\n")) { + const kind = diffLineKind(line); + if (finishesBlock(kind, block)) { + blocks.push(block); + block = { file: block.file, removed: [], added: [] }; + } + recordLine(block, kind, line); + } + return [...blocks, block]; +} + +export function findTokenParityIssues(diff, css, allowlist = []) { + const values = tokenValues(css); + return diffBlocks(diff).flatMap(({ file, removed, added }) => + replacementIssues(file, removed, added, values, allowlist), + ); +} + +function baselineError(file, count, counts, previous) { + const allowed = previous.files[file] ?? 0; + const current = counts[file] ?? 0; + if (![count <= allowed, count === current].every(Boolean)) + return [{ file, message: "baseline must only shrink and bank improvements" }]; + return []; +} +export function parityVerdict(issues, baseline, previous = baseline) { + const counts = issues.reduce((counts, issue) => { + counts[issue.file] = (counts[issue.file] ?? 0) + 1; + return counts; + }, {}); + const errors = issues.filter((issue) => counts[issue.file] > (baseline.files[issue.file] ?? 0)); + errors.push( + ...Object.entries(baseline.files).flatMap(([file, count]) => + baselineError(file, count, counts, previous), + ), + ); + if (baseline.total !== Object.values(baseline.files).reduce((a, b) => a + b, 0)) + throw new Error("Incorrect baseline total"); + return errors; +} + +function baseRef(base) { + if (!base || base.startsWith("-")) + throw new Error("Usage: node scripts/check-token-parity.mjs "); + return base; +} + +function main() { + const base = baseRef(process.argv[2]); + const git = (args) => + execFileSync("git", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); + const diff = git(["diff", "--no-ext-diff", "--no-color", "--unified=3", `${base}...HEAD`, "--"]); + const baseline = JSON.parse(readFileSync(BASELINE, "utf8")); + const previous = git(["ls-tree", "--name-only", base, "--", BASELINE]).trim() + ? JSON.parse(git(["show", `${base}:${BASELINE}`])) + : baseline; + const issues = findTokenParityIssues( + diff, + readFileSync(THEME, "utf8"), + JSON.parse(readFileSync(ALLOWLIST, "utf8")), + ); + const errors = parityVerdict(issues, baseline, previous); + if (errors.length) { + console.error(JSON.stringify(errors, null, 2)); + process.exitCode = 1; + } else console.log(`Token value parity verified: ${issues.length} baselined migrations.`); +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/check-token-parity.test.mjs b/scripts/check-token-parity.test.mjs new file mode 100644 index 0000000000..b1c7b88a0a --- /dev/null +++ b/scripts/check-token-parity.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { findTokenParityIssues, normalizeColor, parityVerdict } from "./check-token-parity.mjs"; + +const diff = (old, next) => + `--- a/packages/a.ts\n+++ b/packages/a.ts\n@@ -1 +1 @@\n-${old}\n+${next}\n`; +const empty = { total: 0, files: {} }; + +test("planted wrong token fails and matching token passes", () => { + const change = diff('color: "#fff"', 'color: "var(--text)"'); + assert.equal(parityVerdict(findTokenParityIssues(change, "--text: #0f0;"), empty).length, 1); + assert.deepEqual(findTokenParityIssues(change, "--text: #ffffff;"), []); +}); + +test("normalizes colour notation and alpha precision", () => { + assert.equal(normalizeColor("#fff"), normalizeColor("rgb(255 255 255)")); + assert.equal(normalizeColor("#ffffff33"), normalizeColor("rgba(255,255,255,0.20)")); + assert.equal(normalizeColor("hsl(120 100% 50% / 50%)"), normalizeColor("rgba(0,255,0,0.5)")); +}); + +test("checks Tailwind slash opacity swaps", () => { + const change = diff('className="border-white/20"', 'className="border-[var(--border)]"'); + assert.equal(findTokenParityIssues(change, "--border: rgba(60,230,172,0.2);").length, 1); + assert.deepEqual(findTokenParityIssues(change, "--border: rgba(255,255,255,0.2);"), []); +}); + +test("compares a whole shorthand rather than its colour alone", () => { + const change = diff('boxShadow: "0 1px 2px rgba(0,0,0,0.85)"', 'boxShadow: "var(--shadow)"'); + assert.deepEqual(findTokenParityIssues(change, "--shadow: 0 1px 2px rgba(0,0,0,0.85);"), []); + assert.equal(findTokenParityIssues(change, "--shadow: 0 4px 2px rgba(0,0,0,0.85);").length, 1); +}); + +test("aliases resolve and undocumented tokens fail", () => { + const change = diff('color: "#fff"', 'color: "var(--alias)"'); + assert.deepEqual(findTokenParityIssues(change, "--alias: var(--white); --white: #fff;"), []); + assert.equal(findTokenParityIssues(change, "").length, 1); + assert.throws(() => findTokenParityIssues(change, "--alias: var(--alias);"), /Cyclic/); +}); + +test("allowlist requires an exact replacement and a reason", () => { + const before = 'color: "#fff"', + after = 'color: "var(--text)"'; + const rule = { file: "packages/a.ts", before, after, reason: "Intentional contrast change" }; + assert.deepEqual(findTokenParityIssues(diff(before, after), "--text: #000;", [rule]), []); + assert.equal( + findTokenParityIssues(diff(before, after), "--text: #000;", [{ ...rule, reason: "" }]).length, + 1, + ); +}); + +test("baseline cannot be raised to hide a migration", () => { + const issues = findTokenParityIssues(diff('color: "#fff"', 'color: "var(--x)"'), "--x: #000;"); + assert.equal(parityVerdict(issues, { total: 1, files: { "packages/a.ts": 1 } }, empty).length, 1); +}); + +test("reordered properties still compare the colour slot", () => { + const change = + '--- a/packages/a.ts\n+++ b/packages/a.ts\n@@ -1,2 +1,2 @@\n-color: "#fff"\n-width: 1\n+width: 2\n+color: "var(--x)"'; + assert.equal(findTokenParityIssues(change, "--x: #000;").length, 1); +}); + +test("unrelated variable changes beside a colour are not migrations", () => { + assert.deepEqual( + findTokenParityIssues( + diff('color: "#fff", width: 1', 'color: "#fff", width: "var(--size)"'), + "--size: 1px;", + ), + [], + ); +}); + +test("defined tokens override fallbacks and fractional alpha stays distinct", () => { + assert.equal( + findTokenParityIssues(diff('color: "#fff"', 'color: "var(--x, #fff)"'), "--x: #000;").length, + 1, + ); + assert.notEqual(normalizeColor("rgba(0,0,0,.100)"), normalizeColor("rgba(0,0,0,.101)")); +}); + +test("duplicate additions cannot hide a second wrong replacement", () => { + const change = + '--- a/packages/a.ts\n+++ b/packages/a.ts\n@@ -1,2 +1,2 @@\n-color: "#fff"\n-color: "#000"\n+color: "var(--x)"\n+color: "var(--x)"'; + assert.equal(findTokenParityIssues(change, "--x: #f00;").length, 2); +}); diff --git a/scripts/token-parity-allowlist.json b/scripts/token-parity-allowlist.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/scripts/token-parity-allowlist.json @@ -0,0 +1 @@ +[] diff --git a/scripts/token-parity-baseline.json b/scripts/token-parity-baseline.json new file mode 100644 index 0000000000..612672504a --- /dev/null +++ b/scripts/token-parity-baseline.json @@ -0,0 +1 @@ +{ "total": 0, "files": {} }