Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | 4x 4x 23x 23x 24x 25x 25x 18x 18x 18x 18x 23x 14x 14x 14x 9x 9x 9x 9x 11x 9x 9x 9x 14x 14x 14x 7x 7x 7x 5x 5x 7x 9x 9x 5x 5x 5x 2x 9x 9x 5x 7x 5x 5x 4x 1x 4x 18x 18x 13x 27x 27x 13x 1x 12x 3x 9x 9x 4x 5x 5x 5x | /**
* Rule: a CSS property declared on the same selector in BOTH
* `themes/base.css` AND a per-theme file is unreachable to customers.
*
* The theme-layer architecture asks themes to drive paint by writing
* `--slds-c-*` hook values; the base file paints once, reading those
* hooks via `var()`. When a theme file *also* writes the raw paint
* property (e.g. `padding: 12px` for cosmos) on the same selector
* `themes/base.css` paints (`padding: 8px`), three things go wrong:
*
* 1. The customer can't override either side. Neither write is
* hook-routed; there's no `--slds-c-*` API for them to reach.
* 2. Cascade order decides the visible value, not authorial intent —
* whichever stylesheet loads later wins.
* 3. Themes silently disagree with each other, and the
* `theme-base` value the audit captures is misleading because
* the base file's literal paint is what ships when no theme
* sets it.
*
* Fix:
* - If every theme overrides the property, drop the literal paint
* from `themes/base.css` and let the themes own it.
* - If only some themes override it, open a `--slds-c-*` hook on
* the base selector and route every theme through it. Themes
* that need the base value just don't set the hook.
*
* Offender shape includes the per-property list of overriding themes
* so reviewers can decide between "drop from base" and "open a hook"
* without round-tripping to the theme files.
*/
import type { Declaration, Rule } from 'postcss';
import type { ComplianceCheck, ComplianceRow, ComponentSourceFile, Offender } from '../../types.js';
import {
declLocation,
failRow,
isHookWriteDecl,
notRunYetRow,
passRow,
themeLayerFilesFor,
} from './internals.js';
const ID = 'structural-no-base-theme-paint-collisions';
const LABEL = 'No paint declarations duplicated between base and themes';
interface PaintEntry {
/** Selector this declaration appeared on, normalized via trim. */
selector: string;
/** PostCSS property (already lowercased CSS property name). */
prop: string;
/** First-seen location for the citation. */
location: string;
/** PostCSS line for sort stability. */
line: number;
}
/** Collect every non-hook paint declaration in the file, keyed by `selector|prop`. */
function collectPaintDecls(file: ComponentSourceFile): Map<string, PaintEntry> {
const out = new Map<string, PaintEntry>();
file.root.walkRules((rule: Rule) => {
rule.walkDecls((decl: Declaration) => {
Iif (decl.parent !== rule) return;
if (isHookWriteDecl(decl)) return;
const selector = rule.selector.trim();
const key = `${selector}|${decl.prop}`;
// Keep the first occurrence — later duplicates within the same
// file are a separate concern (lints already exist for that).
Iif (out.has(key)) return;
out.set(key, {
selector,
prop: decl.prop,
location: declLocation(file, decl),
line: decl.source?.start?.line ?? 0,
});
});
});
return out;
}
/**
* Derive the theme name from a file path. The CLI assigns `role:
* 'theme'` to every `themes/<name>.css` except `themes/base.css`
* (which becomes `theme-base`), so the basename minus `.css` is the
* theme name. Falls back to the full path for synthesized fixtures.
*/
function themeNameFor(file: ComponentSourceFile): string {
const segments = file.path.split(/[\\/]/);
const name = segments[segments.length - 1] ?? file.path;
return name.endsWith('.css') ? name.slice(0, -'.css'.length) : name;
}
interface Collision {
selector: string;
prop: string;
baseLocation: string;
baseLine: number;
themes: string[];
}
function buildCollisions(baseFiles: ComponentSourceFile[], themeFiles: ComponentSourceFile[]): Collision[] {
Iif (baseFiles.length === 0 || themeFiles.length === 0) return [];
// Aggregate the base paint surface across every base file (usually
// just one, but the role allows multiple).
const basePaint = new Map<string, PaintEntry>();
for (const file of baseFiles) {
for (const [key, entry] of collectPaintDecls(file)) {
Eif (!basePaint.has(key)) basePaint.set(key, entry);
}
}
Iif (basePaint.size === 0) return [];
/** key `selector|prop` → set of theme names that re-paint it. */
const themeOverrides = new Map<string, Set<string>>();
for (const file of themeFiles) {
const themeName = themeNameFor(file);
const paint = collectPaintDecls(file);
for (const key of paint.keys()) {
Iif (!basePaint.has(key)) continue;
let bucket = themeOverrides.get(key);
if (!bucket) {
bucket = new Set<string>();
themeOverrides.set(key, bucket);
}
bucket.add(themeName);
}
}
const collisions: Collision[] = [];
for (const [key, themes] of themeOverrides) {
const base = basePaint.get(key);
Iif (!base) continue;
collisions.push({
selector: base.selector,
prop: base.prop,
baseLocation: base.location,
baseLine: base.line,
themes: [...themes].sort((a, b) => a.localeCompare(b)),
});
}
collisions.sort(
(a, b) => a.baseLine - b.baseLine || a.selector.localeCompare(b.selector) || a.prop.localeCompare(b.prop),
);
return collisions;
}
function fixForCollision(c: Collision, totalThemeCount: number): string {
const allThemesOverride = c.themes.length === totalThemeCount;
const themeList = c.themes.map((t) => `\`${t}\``).join(', ');
const hookHint = `--slds-c-...-${c.prop.replace(/^--/, '').replace(/[^a-z0-9-]/gi, '')}`;
if (allThemesOverride) {
return (
`Every theme (${themeList}) re-paints \`${c.prop}\` on \`${c.selector}\`, so the literal value in ` +
`\`themes/base.css\` is dead code (the theme always wins or ties on cascade order). ` +
`Drop the \`${c.prop}\` declaration from base and let the theme files own this property; ` +
`or open a \`${hookHint}\` hook on the base selector and route every theme through it.`
);
}
return (
`\`${c.prop}\` on \`${c.selector}\` is painted in both \`themes/base.css\` and ${themeList}. ` +
`The customer cannot override either side because there is no hook routing the value. ` +
`Open a \`${hookHint}\` hook on the base selector (paint via \`var(${hookHint})\`) and have ` +
`${themeList} set the hook instead of re-declaring the property. Themes that want the base ` +
`value just leave the hook unset.`
);
}
export const noBaseThemePaintCollisions: ComplianceCheck = (input): ComplianceRow => {
const files = themeLayerFilesFor(input);
if (!files) return notRunYetRow(ID, LABEL);
Iif (files.length === 0) {
return passRow(ID, LABEL, 'Component has no theme or theme-base files; nothing to check.');
}
const baseFiles = files.filter((f) => f.role === 'theme-base');
const themeFiles = files.filter((f) => f.role === 'theme');
if (baseFiles.length === 0) {
return passRow(
ID,
LABEL,
'Component has no `themes/base.css`; the base contract that themes would collide with does not exist.',
);
}
if (themeFiles.length === 0) {
return passRow(
ID,
LABEL,
'Component has no per-theme files (`themes/<theme>.css`); nothing can collide with the base paint surface.',
);
}
const collisions = buildCollisions(baseFiles, themeFiles);
if (collisions.length === 0) {
return passRow(
ID,
LABEL,
'No paint declarations are repeated between `themes/base.css` and any per-theme file. Every property is either painted once on base (hook-routed via `var()`) or owned exclusively by the themes.',
);
}
const totalThemeCount = themeFiles.length;
const offenders: Offender[] = collisions.map<Offender>((c) => ({
selector: c.selector,
prop: c.prop,
location: c.baseLocation,
note: `Overridden in ${c.themes.length}/${totalThemeCount} theme${totalThemeCount === 1 ? '' : 's'}: ${c.themes.join(', ')}`,
fix: fixForCollision(c, totalThemeCount),
}));
return failRow(
ID,
LABEL,
`${collisions.length} CSS propert${collisions.length === 1 ? 'y is' : 'ies are'} declared in both \`themes/base.css\` and at least one per-theme file. ` +
`These paint values aren't routed through a customer hook, so the customer can't override them and cascade order decides the visible value. ` +
`Either drop the declaration from base (when every theme overrides it) or open a \`--slds-c-*\` hook so every theme can route through it.`,
offenders,
);
};
|