mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(lint): close the three bypasses in no-raw-palette-color
The rule only matched stock-hue-plus-step utilities, letting three colour escapes through CI: raw dracula-* utilities, hex/rgb/hsl/oklch literals in colour-shaped JS props (the Recharts color/stroke/fill blind spot behind the four hex chart colours just fixed), and Tailwind arbitrary-value colours (bg-[#...]). Add all three as selectable checks. color-props and arbitrary-value run at error; the dracula check ships as a warning under a second registration (healthlog/no-dracula-utility) because ~250 legacy sites predate the token system — the light-theme overrides keep them readable, and the check moves to error once the semantic sweep retires them (staged plan in the rule header). Exempt: global-error.tsx (renders without the stylesheet), the themeColor viewport metadata (matched by ancestry, not by file), test files. Add a RuleTester suite covering all four detectors and the exemptions.
This commit is contained in:
1 parent
d3ed9b8225
commit
e652ec146a
4 files changed
+359
-24
No files matched your search
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Unit tests for the `healthlog/no-raw-palette-color` rule — the four
|
||||
* detectors (`palette`, `dracula`, `color-props`, `arbitrary-value`), the
|
||||
* `checks` option that lets the flat config run them at split severities,
|
||||
* and the documented exemptions (global-error.tsx, themeColor metadata,
|
||||
* test files, `var(--token)` values).
|
||||
*
|
||||
* RuleTester needs a filename inside the enforced roots
|
||||
* (`src/components/` / `src/app/`) — the rule is scope-gated.
|
||||
*/
|
||||
|
||||
import { describe, it } from "vitest";
|
||||
import { RuleTester } from "eslint";
|
||||
import rule from "../no-raw-palette-color.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
RuleTester.itOnly = it.only;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "module",
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const APP_FILE = "/repo/src/components/insights/example.tsx";
|
||||
|
||||
ruleTester.run("no-raw-palette-color (default checks)", rule, {
|
||||
valid: [
|
||||
// Semantic tokens never carry a numeric shade.
|
||||
{
|
||||
code: 'const c = "text-warning bg-success/10 border-info";',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// QR quiet-zone style bare white/black stays legal.
|
||||
{ code: 'const c = "bg-white text-black";', filename: APP_FILE },
|
||||
// var(--token) is the sanctioned chart-colour carrier.
|
||||
{ code: '<Chart color="var(--success)" />', filename: APP_FILE },
|
||||
{ code: 'const cfg = { stroke: "var(--chart-1)" };', filename: APP_FILE },
|
||||
// Arbitrary values referencing a token are fine.
|
||||
{ code: 'const c = "stroke-[var(--warning)]";', filename: APP_FILE },
|
||||
// Non-colour props carrying a hash are not colour literals.
|
||||
{ code: '<a href="#section" />', filename: APP_FILE },
|
||||
// themeColor viewport metadata is exempt by ancestry.
|
||||
{
|
||||
code: 'export const viewport = { themeColor: [{ media: "(prefers-color-scheme: dark)", color: "#282a36" }] };',
|
||||
filename: "/repo/src/app/layout.tsx",
|
||||
},
|
||||
// Dracula utilities do NOT error under the default check set — they
|
||||
// ride the separate warn-level registration.
|
||||
{ code: 'const c = "text-dracula-green";', filename: APP_FILE },
|
||||
// Outside the enforced roots nothing matches.
|
||||
{ code: 'const c = "text-amber-500";', filename: "/repo/src/lib/x.ts" },
|
||||
// The global error boundary renders without the token stylesheet.
|
||||
{
|
||||
code: 'const c = "text-amber-500";',
|
||||
filename: "/repo/src/app/global-error.tsx",
|
||||
},
|
||||
// Test files are exempt.
|
||||
{
|
||||
code: 'const c = "text-amber-500";',
|
||||
filename: "/repo/src/components/__tests__/example.test.tsx",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
// (original) raw Tailwind stock palette utility.
|
||||
{
|
||||
code: 'const c = "text-amber-500";',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "rawPalette" }],
|
||||
},
|
||||
{
|
||||
code: "const c = `bg-green-500/10 ${extra}`;",
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "rawPalette" }],
|
||||
},
|
||||
// (b) colour-shaped JSX prop with a raw hex.
|
||||
{
|
||||
code: '<Chart color="#22c55e" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "colorProp" }],
|
||||
},
|
||||
{
|
||||
code: '<path stroke="rgb(34, 197, 94)" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "colorProp" }],
|
||||
},
|
||||
// (b) colour-shaped object property (Recharts config / style objects).
|
||||
{
|
||||
code: 'const cfg = { fill: "#8b5cf6" };',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "colorProp" }],
|
||||
},
|
||||
{
|
||||
code: '<div style={{ backgroundColor: "oklch(0.5 0.1 300)" }} />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "colorProp" }],
|
||||
},
|
||||
// (b) hex embedded in a longer value (gradient) still trips.
|
||||
{
|
||||
code: '<div style={{ background: "linear-gradient(90deg, #ff5555, #bd93f9)" }} />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "colorProp" }],
|
||||
},
|
||||
// (c) Tailwind arbitrary colour values.
|
||||
{
|
||||
code: 'const c = "bg-[#282a36]";',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "arbitraryColor" }],
|
||||
},
|
||||
{
|
||||
code: 'const c = "text-[oklch(0.7_0.1_300)]";',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "arbitraryColor" }],
|
||||
},
|
||||
{
|
||||
code: 'const c = "border-[rgb(40,42,54)]";',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "arbitraryColor" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ruleTester.run("no-raw-palette-color (dracula check)", rule, {
|
||||
valid: [
|
||||
// Semantic + token forms stay clean under the dracula-only check set.
|
||||
{
|
||||
code: 'const c = "text-success bg-warning/10 text-amber-500";',
|
||||
filename: APP_FILE,
|
||||
options: [{ checks: ["dracula"] }],
|
||||
},
|
||||
// dracula var() references in CSS-in-JS strings are not utilities.
|
||||
{
|
||||
code: 'const c = "stroke-[var(--dracula-purple)]";',
|
||||
filename: APP_FILE,
|
||||
options: [{ checks: ["dracula"] }],
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'const c = "text-dracula-green";',
|
||||
filename: APP_FILE,
|
||||
options: [{ checks: ["dracula"] }],
|
||||
errors: [{ messageId: "draculaUtility" }],
|
||||
},
|
||||
{
|
||||
code: 'const c = "bg-dracula-orange/15 border-dracula-purple";',
|
||||
filename: APP_FILE,
|
||||
options: [{ checks: ["dracula"] }],
|
||||
errors: [{ messageId: "draculaUtility" }],
|
||||
},
|
||||
{
|
||||
code: 'const c = cn(isUp ? "text-dracula-cyan" : "text-muted-foreground");',
|
||||
filename: APP_FILE,
|
||||
options: [{ checks: ["dracula"] }],
|
||||
errors: [{ messageId: "draculaUtility" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -21,5 +21,10 @@ module.exports = {
|
||||
"safe-fetch-required": safeFetchRequired,
|
||||
"api-fetch-required": apiFetchRequired,
|
||||
"no-raw-palette-color": noRawPaletteColor,
|
||||
// Same module as `no-raw-palette-color`, registered under a second
|
||||
// name so the flat config can run the `dracula` check at a different
|
||||
// severity (warn) than the error-level checks. See the staged plan in
|
||||
// the rule header.
|
||||
"no-dracula-utility": noRawPaletteColor,
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @fileoverview ESLint rule — raw Tailwind palette color utilities are
|
||||
* banned in app UI; route every tone through a semantic token.
|
||||
* @fileoverview ESLint rule — raw colour literals are banned in app UI;
|
||||
* route every tone through a semantic token.
|
||||
*
|
||||
* v1.26.0 — the design-consistency wave collapsed the app's colour
|
||||
* vocabulary onto semantic tokens (`text-warning` / `text-info` /
|
||||
@@ -12,18 +12,41 @@
|
||||
* rule flags those utilities at authoring time and in CI so the tokenised
|
||||
* palette cannot regress.
|
||||
*
|
||||
* Match: a className fragment
|
||||
* (text|bg|border|ring|stroke|fill|from|to|via)-<palette>-<step>
|
||||
* where <palette> is one of the Tailwind stock hues and <step> is a
|
||||
* numeric 50…950 shade. The `-<step>` suffix is what pins it to the raw
|
||||
* palette — bare `bg-white` / `text-black` (QR quiet-zone) and semantic
|
||||
* tokens (`text-warning`, `bg-success/10`) never carry a numeric shade and
|
||||
* so never match.
|
||||
* v1.27.x — the rule grows three checks that closed known bypasses:
|
||||
*
|
||||
* Scope: `src/components/**` + `src/app/**`. The check inspects string
|
||||
* literals and template-literal chunks (covering `className="…"`,
|
||||
* - `palette` (error) — the original check: a className fragment
|
||||
* `(text|bg|border|ring|stroke|fill|from|to|via)-<stock-hue>-<step>`
|
||||
* with a numeric 50…950 shade. Bare `bg-white` / `text-black` (QR
|
||||
* quiet-zone) and semantic tokens never carry a numeric shade and so
|
||||
* never match.
|
||||
* - `color-props` (error) — colour-shaped JS props / object keys
|
||||
* (`color` / `fill` / `stroke` / `background*`) receiving a string
|
||||
* that contains a `#hex` / `rgb(` / `hsl(` / `oklch(` literal, e.g.
|
||||
* `color="#22c55e"` or a Recharts `stroke`. Chart colours are passed
|
||||
* as `var(--token)` strings instead. The `themeColor` viewport
|
||||
* metadata is exempt (the PWA chrome colour is read by the browser
|
||||
* before any stylesheet loads, so it must stay a literal).
|
||||
* - `arbitrary-value` (error) — Tailwind arbitrary colour values
|
||||
* (`bg-[#…]`, `text-[oklch(…)]`). `…-[var(--token)]` never matches.
|
||||
* - `dracula` (WARN, staged) — raw `*-dracula-*` utilities. ~250
|
||||
* legacy sites predate the token system; the light-theme overrides in
|
||||
* `globals.css` defuse their AA failures, so the check ships as a
|
||||
* warning first (registered separately as
|
||||
* `healthlog/no-dracula-utility`). It moves to error once the
|
||||
* semantic sweep (status meaning → `text-success/warning/info/
|
||||
* destructive`, brand use → `--brand-*` tokens) has retired the
|
||||
* legacy sites.
|
||||
*
|
||||
* The `checks` option selects which detectors run, so the flat config can
|
||||
* register the same module twice at different severities (see
|
||||
* `eslint-plugins/healthlog/index.js` + `eslint.config.mjs`).
|
||||
*
|
||||
* Scope: `src/components/**` + `src/app/**`. The string checks inspect
|
||||
* string literals and template-literal chunks (covering `className="…"`,
|
||||
* `cn("…")`, cva variant maps, and `clsx` calls alike) — a syntactic
|
||||
* match, mirroring the queryKey-factory / safe-fetch-required rules.
|
||||
* match, mirroring the queryKey-factory / safe-fetch-required rules. The
|
||||
* `color-props` check additionally inspects JSX attributes and object
|
||||
* properties so it can key off the prop NAME.
|
||||
*
|
||||
* Exempt files (narrow, path-suffix matched):
|
||||
* - `src/app/global-error.tsx` — the App-Router global error boundary
|
||||
@@ -53,6 +76,27 @@ const ENFORCED_ROOTS = ["src/components/", "src/app/"];
|
||||
const RAW_PALETTE_RE =
|
||||
/\b(?:text|bg|border|ring|stroke|fill|from|to|via)-(?:amber|sky|red|green|emerald|blue|orange|yellow|pink|purple|violet|zinc|gray|slate|neutral|stone|rose|lime|teal|cyan|indigo|fuchsia)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/;
|
||||
|
||||
// Raw `dracula-*` utility: the pre-token palette accessed directly
|
||||
// (`text-dracula-green`, `bg-dracula-orange/15`). Same prefix set and
|
||||
// modifier tolerance as the palette check.
|
||||
const DRACULA_RE =
|
||||
/\b(?:text|bg|border|ring|stroke|fill|from|to|via)-dracula-[a-z]+\b/;
|
||||
|
||||
// Tailwind arbitrary colour value: `bg-[#…]`, `text-[rgb(…)]`,
|
||||
// `border-[oklch(…)]`. Matching on the bracket-open plus a colour-literal
|
||||
// head keeps `…-[var(--token)]` (the sanctioned escape hatch) clean.
|
||||
const ARBITRARY_COLOR_RE = /-\[(?:#|rgba?\(|hsla?\(|oklch\()/;
|
||||
|
||||
// Colour-shaped prop / object-key names for the `color-props` check.
|
||||
// Anchored so `themeColor` / `colorScheme` etc. never match.
|
||||
const COLOR_PROP_NAME_RE = /^(?:color|fill|stroke|background[A-Za-z]*)$/;
|
||||
|
||||
// A colour literal inside a string value: hex, rgb()/rgba(), hsl()/hsla(),
|
||||
// oklch(). `var(--token)` strings never match.
|
||||
const COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(|\boklch\(/;
|
||||
|
||||
const DEFAULT_CHECKS = ["palette", "color-props", "arbitrary-value"];
|
||||
|
||||
function toPosix(filename) {
|
||||
return filename.replace(/\\/g, "/");
|
||||
}
|
||||
@@ -74,18 +118,71 @@ function isEnforced(filename) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `themeColor` viewport metadata carries per-scheme colour literals
|
||||
* (`{ media, color }` entries) that the browser reads before any
|
||||
* stylesheet exists — the one sanctioned inline-colour site. Walk the
|
||||
* ancestor chain for a `themeColor` property so those entries stay exempt
|
||||
* without exempting the whole layout file.
|
||||
*/
|
||||
function isInsideThemeColor(node) {
|
||||
for (let cur = node.parent; cur; cur = cur.parent) {
|
||||
if (
|
||||
cur.type === "Property" &&
|
||||
!cur.computed &&
|
||||
((cur.key.type === "Identifier" && cur.key.name === "themeColor") ||
|
||||
(cur.key.type === "Literal" && cur.key.value === "themeColor"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Extract the static string content of a Literal / TemplateLiteral value. */
|
||||
function staticString(node) {
|
||||
if (!node) return null;
|
||||
if (node.type === "Literal" && typeof node.value === "string") {
|
||||
return node.value;
|
||||
}
|
||||
if (node.type === "TemplateLiteral") {
|
||||
return node.quasis.map((q) => q.value.cooked ?? q.value.raw).join(" ");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @type {import("eslint").Rule.RuleModule} */
|
||||
const noRawPaletteColorRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Ban raw Tailwind palette color utilities in app UI; route every tone through a semantic token (text-warning / text-info / text-success / text-destructive and their bg / border / opacity forms).",
|
||||
"Ban raw colour literals in app UI — Tailwind stock palette utilities, raw dracula-* utilities, hex/rgb/hsl/oklch in colour-shaped JS props, and arbitrary-value colours; route every tone through a semantic token.",
|
||||
},
|
||||
schema: [],
|
||||
schema: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
checks: {
|
||||
type: "array",
|
||||
items: {
|
||||
enum: ["palette", "dracula", "color-props", "arbitrary-value"],
|
||||
},
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
rawPalette:
|
||||
'Raw Tailwind palette utility "{{match}}" bypasses the semantic token system (drifts off the palette, breaks light/dark parity, often fails AA). Use a token: caution → text-warning / border-warning / bg-warning/10, info → text-info, success → text-success, destructive → text-destructive; SVG accents → stroke-[var(--warning)].',
|
||||
draculaUtility:
|
||||
'Raw dracula utility "{{match}}" reads the pre-token palette directly. Status meaning → text-success / text-warning / text-info / text-destructive (and their bg/border forms); brand identity → a named --brand-* token. Legacy sites survive on the light-theme overrides in globals.css; do not add new ones.',
|
||||
colorProp:
|
||||
'Colour-shaped prop "{{name}}" receives the raw colour literal "{{value}}". Pass a theme token instead — color="var(--success)" / stroke="var(--chart-1)" — so both themes and future token retunes apply.',
|
||||
arbitraryColor:
|
||||
'Arbitrary-value colour utility "{{match}}" hard-codes a tone outside the token system. Use a semantic utility (text-warning, bg-success/10) or a token reference (…-[var(--token)]).',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
@@ -94,16 +191,56 @@ const noRawPaletteColorRule = {
|
||||
return {};
|
||||
}
|
||||
|
||||
const options = context.options?.[0] ?? {};
|
||||
const checks = new Set(options.checks ?? DEFAULT_CHECKS);
|
||||
|
||||
function checkText(node, text) {
|
||||
if (typeof text !== "string") return;
|
||||
const m = RAW_PALETTE_RE.exec(text);
|
||||
if (m) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "rawPalette",
|
||||
data: { match: m[0] },
|
||||
});
|
||||
if (checks.has("palette")) {
|
||||
const m = RAW_PALETTE_RE.exec(text);
|
||||
if (m) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "rawPalette",
|
||||
data: { match: m[0] },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (checks.has("dracula")) {
|
||||
const m = DRACULA_RE.exec(text);
|
||||
if (m) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "draculaUtility",
|
||||
data: { match: m[0] },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (checks.has("arbitrary-value")) {
|
||||
const m = ARBITRARY_COLOR_RE.exec(text);
|
||||
if (m) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "arbitraryColor",
|
||||
data: { match: m[0] },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkColorProp(node, name, valueNode) {
|
||||
if (!checks.has("color-props")) return;
|
||||
if (!COLOR_PROP_NAME_RE.test(name)) return;
|
||||
const value = staticString(valueNode);
|
||||
if (value === null) return;
|
||||
const m = COLOR_LITERAL_RE.exec(value);
|
||||
if (!m) return;
|
||||
if (isInsideThemeColor(node)) return;
|
||||
context.report({
|
||||
node,
|
||||
messageId: "colorProp",
|
||||
data: { name, value: m[0] },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -115,6 +252,25 @@ const noRawPaletteColorRule = {
|
||||
TemplateElement(node) {
|
||||
checkText(node, node.value.cooked ?? node.value.raw);
|
||||
},
|
||||
JSXAttribute(node) {
|
||||
if (node.name?.type !== "JSXIdentifier") return;
|
||||
const valueNode =
|
||||
node.value?.type === "JSXExpressionContainer"
|
||||
? node.value.expression
|
||||
: node.value;
|
||||
checkColorProp(node, node.name.name, valueNode);
|
||||
},
|
||||
Property(node) {
|
||||
if (node.computed) return;
|
||||
const name =
|
||||
node.key.type === "Identifier"
|
||||
? node.key.name
|
||||
: node.key.type === "Literal" && typeof node.key.value === "string"
|
||||
? node.key.value
|
||||
: null;
|
||||
if (!name) return;
|
||||
checkColorProp(node, name, node.value);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
+16
-3
@@ -48,9 +48,22 @@ const eslintConfig = defineConfig([
|
||||
// vocabulary onto semantic tokens. Raw Tailwind palette utilities
|
||||
// (`text-amber-500`, `bg-green-500`, `dark:text-red-400`, …) under
|
||||
// src/components + src/app are banned so the tokenised palette can't
|
||||
// regress. `src/app/global-error.tsx` (renders without the token
|
||||
// stylesheet) and test files are exempt; see the rule file.
|
||||
"healthlog/no-raw-palette-color": "error",
|
||||
// regress. v1.27.x extends the rule with two further error-level
|
||||
// checks: colour-shaped JS props carrying a raw `#hex`/`rgb(`/`hsl(`/
|
||||
// `oklch(` literal (the Recharts `color`/`stroke`/`fill` blind spot),
|
||||
// and Tailwind arbitrary-value colours (`bg-[#…]`).
|
||||
// `src/app/global-error.tsx` (renders without the token stylesheet),
|
||||
// the `themeColor` viewport metadata, and test files are exempt; see
|
||||
// the rule file.
|
||||
"healthlog/no-raw-palette-color": [
|
||||
"error",
|
||||
{ checks: ["palette", "color-props", "arbitrary-value"] },
|
||||
],
|
||||
// Staged: raw `*-dracula-*` utilities warn for now — ~250 legacy
|
||||
// sites predate the token system and the light-theme overrides in
|
||||
// globals.css keep them readable. Moves to error once the semantic
|
||||
// sweep has retired the legacy sites (see the rule header).
|
||||
"healthlog/no-dracula-utility": ["warn", { checks: ["dracula"] }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in new issue
Block a user