From a075d89bdbe60b2b00220cb0527fb5091e84215e Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 5 Aug 2026 15:28:17 -0700 Subject: [PATCH] Simplify CSS color channel parsing (#520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralize CSS numeric token parsing and characterize every supported color unit while preserving config and filtering behavior. AI-assisted: Codex implemented this refactor under pbakaus’s scheduled architecture-simplification authorization. --- cli/lib/impeccable-config.mjs | 98 ++++++++++++----------------- tests/lib/impeccable-config.test.js | 47 ++++++++++++++ 2 files changed, 87 insertions(+), 58 deletions(-) diff --git a/cli/lib/impeccable-config.mjs b/cli/lib/impeccable-config.mjs index 0c052d264..827b26845 100644 --- a/cli/lib/impeccable-config.mjs +++ b/cli/lib/impeccable-config.mjs @@ -206,10 +206,10 @@ function parseIgnoreColor(value) { if (rgb) { const parts = splitColorArgs(rgb[1]); if (parts.length < 3 || parts.length > 4) return null; - const r = parseRgbChannel(parts[0]); - const g = parseRgbChannel(parts[1]); - const b = parseRgbChannel(parts[2]); - const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]); + const r = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.rgb); + const g = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.rgb); + const b = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.rgb); + const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha); if ([r, g, b, a].some((v) => v === null)) return null; return { r, g, b, a }; } @@ -218,10 +218,10 @@ function parseIgnoreColor(value) { if (hsl) { const parts = splitColorArgs(hsl[1]); if (parts.length < 3 || parts.length > 4) return null; - const h = parseHueChannel(parts[0]); - const s = parsePercentChannel(parts[1]); - const l = parsePercentChannel(parts[2]); - const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]); + const h = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.hue); + const s = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.percent); + const l = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.percent); + const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha); if ([h, s, l, a].some((v) => v === null)) return null; return hslToRgb(h, s, l, a); } @@ -230,18 +230,13 @@ function parseIgnoreColor(value) { } function parseHexIgnoreColor(hex) { - if (hex.length === 3 || hex.length === 4) { - const r = parseInt(hex[0] + hex[0], 16); - const g = parseInt(hex[1] + hex[1], 16); - const b = parseInt(hex[2] + hex[2], 16); - const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1; - return { r, g, b, a }; - } - const r = parseInt(hex.slice(0, 2), 16); - const g = parseInt(hex.slice(2, 4), 16); - const b = parseInt(hex.slice(4, 6), 16); - const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1; - return { r, g, b, a }; + const expanded = hex.length <= 4 + ? [...hex].map((digit) => digit.repeat(2)).join('') + : hex; + const [r, g, b, alpha = 255] = expanded + .match(/../g) + .map((channel) => Number.parseInt(channel, 16)); + return { r, g, b, a: alpha / 255 }; } function splitColorArgs(body) { @@ -259,47 +254,34 @@ function splitColorArgs(body) { return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/'); } -function parseRgbChannel(raw) { - const text = String(raw || '').trim(); - const match = text.match(/^(-?\d*\.?\d+)(%)?$/); - if (!match) return null; - const value = Number.parseFloat(match[1]); - if (!Number.isFinite(value)) return null; - const scaled = match[2] ? value * 2.55 : value; - if (scaled < 0 || scaled > 255) return null; - return Math.round(scaled); -} +const CSS_NUMBER_RE = /^(-?\d*\.?\d+)(%|deg|rad|turn|grad)?$/; +const identity = (value) => value; +const COLOR_CHANNEL_FORMATS = { + rgb: { units: { '': identity, '%': (value) => value * 2.55 }, min: 0, max: 255, round: true }, + alpha: { units: { '': identity, '%': (value) => value / 100 }, min: 0, max: 1 }, + hue: { + units: { + '': identity, + deg: identity, + rad: (value) => value * (180 / Math.PI), + turn: (value) => value * 360, + grad: (value) => value * 0.9, + }, + }, + percent: { units: { '%': (value) => value / 100 }, min: 0, max: 1 }, +}; -function parseAlphaChannel(raw) { +function parseColorChannel(raw, { units, min = -Infinity, max = Infinity, round = false }) { const text = String(raw || '').trim(); - const match = text.match(/^(-?\d*\.?\d+)(%)?$/); + const match = text.match(CSS_NUMBER_RE); if (!match) return null; - const value = Number.parseFloat(match[1]); - if (!Number.isFinite(value)) return null; - const alpha = match[2] ? value / 100 : value; - return alpha >= 0 && alpha <= 1 ? alpha : null; -} - -function parseHueChannel(raw) { - const text = String(raw || '').trim(); - const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/); - if (!match) return null; - const value = Number.parseFloat(match[1]); - if (!Number.isFinite(value)) return null; - const unit = match[2] || 'deg'; - if (unit === 'turn') return value * 360; - if (unit === 'rad') return value * (180 / Math.PI); - if (unit === 'grad') return value * 0.9; - return value; -} - -function parsePercentChannel(raw) { - const text = String(raw || '').trim(); - const match = text.match(/^(-?\d*\.?\d+)%$/); - if (!match) return null; - const value = Number.parseFloat(match[1]); - if (!Number.isFinite(value)) return null; - return value >= 0 && value <= 100 ? value / 100 : null; + const convert = units[match[2] || '']; + if (!convert) return null; + const number = Number.parseFloat(match[1]); + if (!Number.isFinite(number)) return null; + const value = convert(number); + if (value < min || value > max) return null; + return round ? Math.round(value) : value; } function hslToRgb(hue, saturation, lightness, alpha) { diff --git a/tests/lib/impeccable-config.test.js b/tests/lib/impeccable-config.test.js index 039fef2a3..8ca7aeaa3 100644 --- a/tests/lib/impeccable-config.test.js +++ b/tests/lib/impeccable-config.test.js @@ -235,6 +235,53 @@ describe('cli/lib/impeccable-config', () => { ]); }); + test('filterDetectionFindings normalizes every supported CSS color unit', () => { + const findings = [ + { antipattern: 'design-system-color', line: 1, ignoreValue: '#f00' }, + { antipattern: 'design-system-color', line: 2, ignoreValue: 'rgb(100% 0% 0%)' }, + { antipattern: 'design-system-color', line: 3, ignoreValue: 'hsl(360deg 100% 50%)' }, + { antipattern: 'design-system-color', line: 4, ignoreValue: 'hsl(180deg 100% 50%)' }, + { antipattern: 'design-system-color', line: 5, ignoreValue: 'hsl(3.141592653589793rad 100% 50%)' }, + { antipattern: 'design-system-color', line: 6, ignoreValue: 'hsl(0.5turn 100% 50%)' }, + { antipattern: 'design-system-color', line: 7, ignoreValue: 'hsl(200grad 100% 50%)' }, + { antipattern: 'design-system-color', line: 8, ignoreValue: 'rgba(255, 0, 0, 0.5)' }, + { antipattern: 'design-system-color', line: 9, ignoreValue: 'rgb(100% 0% 0% / 50%)' }, + { antipattern: 'design-system-color', line: 10, ignoreValue: 'hsla(0, 100%, 50%, 50%)' }, + { antipattern: 'design-system-color', line: 11, ignoreValue: '#f008' }, + ]; + const filtered = filterDetectionFindings(findings, { + ignoreValues: [ + { rule: 'design-system-color', value: '#ff0000' }, + { rule: 'design-system-color', value: '#00ffff' }, + { rule: 'design-system-color', value: '#ff000080' }, + { rule: 'design-system-color', value: '#ff000088' }, + ], + }); + + expect(filtered).toEqual([]); + }); + + test('filterDetectionFindings rejects out-of-range and malformed CSS colors', () => { + const findings = [ + { antipattern: 'design-system-color', line: 1, ignoreValue: 'rgb(256 0 0)' }, + { antipattern: 'design-system-color', line: 2, ignoreValue: 'rgb(100.1% 0% 0%)' }, + { antipattern: 'design-system-color', line: 3, ignoreValue: 'rgba(255, 0, 0, 101%)' }, + { antipattern: 'design-system-color', line: 4, ignoreValue: 'rgba(255, 0, 0, -0.1)' }, + { antipattern: 'design-system-color', line: 5, ignoreValue: 'hsl(0 100 50%)' }, + { antipattern: 'design-system-color', line: 6, ignoreValue: 'hsl(0 101% 50%)' }, + { antipattern: 'design-system-color', line: 7, ignoreValue: 'hsl(0foo 100% 50%)' }, + { antipattern: 'design-system-color', line: 8, ignoreValue: '#ff00000' }, + ]; + const filtered = filterDetectionFindings(findings, { + ignoreValues: [ + { rule: 'design-system-color', value: '#ff0000' }, + { rule: 'design-system-color', value: '#ff000080' }, + ], + }); + + expect(filtered.map((finding) => finding.line)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + }); + test('extractFindingIgnoreValue handles fonts, Google font URLs, and motion snippets', () => { expect(extractFindingIgnoreValue({ antipattern: 'overused-font', snippet: 'Primary font: Avenir Next (80% of text)' })).toBe('avenir next'); expect(extractFindingIgnoreValue({ antipattern: 'overused-font', snippet: 'https://fonts.googleapis.com/css2?family=Alumni+Sans:wght@700' })).toBe('alumni sans');