All files / packages/sds-customization-compliance/src/checks/structural required-themes-present.ts

96.42% Statements 54/56
84.61% Branches 22/26
100% Functions 13/13
100% Lines 45/45

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                                                              6x 6x 6x               6x                   17x 17x 17x 17x 17x 17x       13x 13x 33x 17x 17x   13x       17x                     18x       10x 10x                     2x 2x                   36x       14x     6x 19x 19x         14x 1x               13x 26x 13x   13x 6x 6x               7x 7x 7x   7x 7x     19x 19x   19x       19x       19x    
/**
 * Rule: every component must ship the full theme-layer scaffold —
 * `themes/base.css` plus paint for both required themes
 * (`themes/cosmos.css` and `themes/lightning-blue.css`).
 *
 * The customer surface is per-theme: a customer using `lightning-blue`
 * doesn't see paint a `cosmos`-only theme file emits, and vice
 * versa. A component missing any of the three reads as "themed" in
 * one product surface and "default" / unstyled in others; the
 * migration is at best half-done.
 *
 * Severity: `fail` (`customer-reach`). A missing required file
 * walls a tier of paint from the customer's resolved cascade.
 *
 * A component with no `themes/` directory at all hasn't opted into
 * the theme layer; there is nothing for this rule to gate, so it
 * passes trivially. The rule only has teeth once a `themes/` folder
 * exists, at which point two failure shapes share it:
 *
 *   - **Scaffold started but base absent**: a `themes/` folder exists
 *     (e.g. it already ships a per-theme file) but there's no
 *     `themes/base.css`. The migration is under way but the wiring
 *     layer every theme reads from is missing.
 *   - **Partial scaffold**: `themes/base.css` exists but one or both
 *     required theme files are missing. The migration started but
 *     hasn't shipped paint for every product surface yet.
 */
 
import type { ComplianceCheck, ComplianceRow, ComponentSourceFile, Offender } from '../../types.js';
import { failRow, notRunYetRow, passRow, sourceFilesFor } from './internals.js';
 
const ID = 'structural-required-themes-present';
const LABEL = 'Required themes are present (cosmos + lightning-blue)';
const CATEGORY = 'customer-reach' as const;
 
/**
 * Themes the design-system-2 contract guarantees coverage for.
 * Adding a new required theme is a deliberate migration step:
 * extend this list, regenerate per-component reports, and the rule
 * surfaces every component that hasn't shipped paint for it yet.
 */
const REQUIRED_THEMES: ReadonlyArray<string> = ['cosmos', 'lightning-blue'];
 
/**
 * Extract the theme name from a `themes/<theme>.css` path. Returns
 * `null` for any other shape (`themes/base.css`, top-level files,
 * unrecognised nesting). Path separator is platform-aware in the
 * source loader, but emitted relative paths use `/` after
 * normalisation; we tolerate both for resilience.
 */
function themeNameFromPath(filePath: string): string | null {
  const normalised = filePath.replaceAll('\\', '/');
  const m = /(?:^|\/)themes\/([^/]+)\.css$/.exec(normalised);
  Iif (!m) return null;
  const name = m[1];
  Iif (name === 'base') return null;
  return name;
}
 
function presentThemeNames(files: ComponentSourceFile[]): Set<string> {
  const out = new Set<string>();
  for (const file of files) {
    if (file.role !== 'theme') continue;
    const name = themeNameFromPath(file.path);
    Eif (name) out.add(name);
  }
  return out;
}
 
function hasThemeBase(files: ComponentSourceFile[]): boolean {
  return files.some((f) => f.role === 'theme-base');
}
 
/**
 * True when the component ships anything under a `themes/` directory
 * (`themes/base.css`, `themes/<theme>.css`, or any other nested file).
 * Path separators are normalised so the check stays cross-platform.
 * When this is `false` the component has no theme-layer scaffold at
 * all, which is a valid state the rule skips rather than fails.
 */
function hasThemesFolder(files: ComponentSourceFile[]): boolean {
  return files.some((f) => /(?:^|\/)themes\//.test(f.path.replaceAll('\\', '/')));
}
 
function offenderForTheme(missingTheme: string, componentName: string): Offender {
  const filePath = `packages/design-system-2/src/slds2/${componentName}/themes/${missingTheme}.css`;
  return {
    note: `Required theme \`${missingTheme}\` is missing — no \`themes/${missingTheme}.css\` was found for this component.`,
    fix:
      `Create \`${filePath}\` and assign every public hook the base ` +
      `contract reads. Customers running the \`${missingTheme}\` theme ` +
      `will otherwise resolve to the base-layer fallback, which is not ` +
      `the theme's intended paint.`,
  };
}
 
function offenderForMissingBase(componentName: string): Offender {
  const filePath = `packages/design-system-2/src/slds2/${componentName}/themes/base.css`;
  return {
    note: 'Theme-layer scaffold not started — no `themes/base.css` is on disk for this component.',
    fix:
      `Create \`${filePath}\` as the paint-only base contract that ` +
      `reads canonical \`--slds-c-*\` hooks via \`var()\`. Then add the ` +
      `required per-theme files (see the other offenders on this row).`,
  };
}
 
function buildBackticked(items: ReadonlyArray<string>, joiner: string): string {
  return items.map((t) => `\`${t}\``).join(joiner);
}
 
function pluralise(n: number, singular: string): string {
  return n === 1 ? singular : `${singular}s`;
}
 
export const requiredThemesPresent: ComplianceCheck = (input): ComplianceRow => {
  const files = sourceFilesFor(input);
  if (!files) return notRunYetRow(ID, LABEL, CATEGORY);
 
  // No `themes/` directory at all is a valid state: the component
  // hasn't opted into the theme layer, so there is nothing for this
  // rule to gate. Skip validation and pass trivially.
  if (!hasThemesFolder(files)) {
    return passRow(
      ID,
      LABEL,
      'No `themes/` directory is present for this component, so there is no theme-layer scaffold to validate.',
      CATEGORY,
    );
  }
 
  const present = presentThemeNames(files);
  const missingThemes = REQUIRED_THEMES.filter((t) => !present.has(t));
  const baseMissing = !hasThemeBase(files);
 
  if (!baseMissing && missingThemes.length === 0) {
    const allRequired = buildBackticked(REQUIRED_THEMES, ', ');
    return passRow(
      ID,
      LABEL,
      `All required theme files are present: \`themes/base.css\`, ${allRequired}.`,
      CATEGORY,
    );
  }
 
  const offenders: Offender[] = [];
  if (baseMissing) offenders.push(offenderForMissingBase(input.componentName));
  for (const t of missingThemes) offenders.push(offenderForTheme(t, input.componentName));
 
  const requiredList = buildBackticked(REQUIRED_THEMES, ' and ');
  const baseSegment = baseMissing
    ? '`themes/base.css` is missing (the component has no theme-layer scaffold)'
    : '`themes/base.css` is present';
  const themesMissingLabel = buildBackticked(missingThemes, ', ');
  const isAre = missingThemes.length === 1 ? 'is' : 'are';
  const themesSegment =
    missingThemes.length === 0
      ? ''
      : ` and ${missingThemes.length} required ${pluralise(missingThemes.length, 'theme')} ${pluralise(missingThemes.length, 'file')} ${isAre} missing (${themesMissingLabel})`;
  const detail =
    `${baseSegment}${themesSegment}. Every SLDS2 component must ship ` +
    `\`themes/base.css\` plus paint for ${requiredList} so the ` +
    `customer's resolved cascade matches the active theme regardless of ` +
    `which product surface they're running.`;
  return failRow(ID, LABEL, detail, offenders);
};