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

96.15% Statements 50/52
83.33% Branches 20/24
100% Functions 11/11
100% Lines 42/42

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                                                              4x 4x 4x               4x                   13x 13x 13x 13x 13x 13x       11x 11x 28x 13x 13x   11x       14x       10x 10x                     1x 1x                   32x       12x     4x 16x 16x   11x 22x 11x   11x 5x 5x               6x 6x 6x   6x 6x     16x 16x   16x       16x       16x    
/**
 * 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.
 *
 * Two failure shapes share this rule:
 *
 *   - **Scaffold absent**: no `themes/base.css` at all. The component
 *     hasn't started its theme-layer migration. This is the strongest
 *     failure mode: every other structural / hook-surface check is
 *     trivially passing or running on no data, which collectively
 *     reads as a misleading "all green" status. One unambiguous
 *     failure row here is the right shape — the component is *not*
 *     done.
 *   - **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');
}
 
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);
 
  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);
};