All files / packages/sds-customization-compliance/src/checks/structural no-bare-bem-paint-rules.ts

95.34% Statements 41/43
87.5% Branches 21/24
100% Functions 5/5
100% Lines 35/35

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                                                                                          5x 5x 5x   5x           5x               5x 24x 24x 20x 4x     16x 16x   16x             20x 14x 15x 15x 9x 9x 9x 10x 10x 8x         9x 7x 7x   8x   7x 15x 8x 8x 5x                 16x 12x               4x                        
/**
 * Rule 5 (paint): paint declarations on bare modifier or element
 * selectors.
 *
 * Modifiers and BEM elements are system-owned painting vocabulary;
 * customers don't override them directly. What this check enforces is
 * **cascade hygiene**: a bare modifier or element class can match any
 * element carrying that class — including a wrapper that doesn't carry
 * the component root. When that happens, the rule's paint declarations
 * apply to the wrapper itself and any other element that picks up the
 * class, producing styling outside an actual component instance.
 *
 * Unlike {@link noBareBemHookWrites}, this check covers rules whose
 * declarations are direct CSS properties (not custom-property writes).
 * The harm is local rather than amplified by inheritance: only the
 * element matching the bare selector is mispainted, not every
 * descendant. Still a Rule 5 violation worth fixing.
 *
 * Carveout — `theme-base` files: paint rules in `themes/base.css`
 * are EXEMPT from this check. Anchoring a BEM-element selector as
 * `.slds-foo .slds-foo__bar` raises specificity from 0,1,0 to 0,2,0,
 * which beats utility classes (`.slds-p-horizontal_small` etc., spec
 * 0,1,0) in the cascade. That's a real-world breakage every time a
 * customer or recipe composes a utility onto a card body, dropdown
 * item, etc. The inheritance-leak risk that motivates Rule 5 is
 * theoretical for paint declarations (BEM-element classes only land
 * inside their root in practice); the cascade breakage is observed
 * day-one. Theme-base files are paint-only by file-role definition,
 * so the carveout is safe scoped here.
 *
 * Hook writes still require root anchoring even in theme-base files
 * (see {@link noBareBemHookWrites}): hook leaks via custom-property
 * inheritance can repaint distant unrelated elements, which IS the
 * concrete harm.
 *
 * This is the only structural check that walks rules without hook
 * writes; it is otherwise the read-side mirror of
 * {@link noBareBemHookWrites}.
 */
 
import { classifySurface, isAnchoringIncorrect, type SelectorSurface } from '../helpers/selectorSurface.js';
import { resolveBemRoot } from '../helpers/resolveHookPrefix.js';
import type { ComplianceCheck, ComplianceRow, Offender } from '../../types.js';
import { failRow, isHookWriteDecl, notRunYetRow, passRow, themeLayerFilesFor } from './internals.js';
 
const ID = 'structural-no-bare-bem-paint-rules';
const LABEL = 'No paint declarations on bare child-element selectors';
const CATEGORY = 'customer-reach' as const;
 
const BEM_NOTATION_RE = /\.slds-[a-z][a-z0-9]*(__|_)[a-z]/;
 
function fixForSurface(_surface: SelectorSurface, part: string, prefix: string): string {
  // Only `system-element-bare` reaches this function; the surface
  // parameter is kept on the signature so future surface variants
  // can branch here without an API change.
  return (
    `Anchor \`${part}\` as a root-anchored descendant (\`.slds-${prefix} .slds-${prefix}__element\`) ` +
    `so the rule only matches inside an actual \`.slds-${prefix}\` instance. ` +
    `Without the anchor, this rule paints any element carrying the BEM-element class — even outside a \`.slds-${prefix}\`. ` +
    `(Bare modifier classes are exempt — they're component-prefixed and only ever land on \`.slds-${prefix}\` instances.)`
  );
}
 
export const noBareBemPaintRules: ComplianceCheck = (input): ComplianceRow => {
  const files = themeLayerFilesFor(input);
  if (!files) return notRunYetRow(ID, LABEL, CATEGORY);
  if (files.length === 0) {
    return passRow(ID, LABEL, 'Component has no theme or theme-base files; nothing to check.', CATEGORY);
  }
 
  const ownPrefix = resolveBemRoot(input.componentName);
  const offenders: Offender[] = [];
 
  for (const file of files) {
    // theme-base files are exempt; see header comment for the rationale.
    // Briefly: anchoring BEM-element paint at theme-base raises
    // specificity to 0,2,0 and breaks utility-class cascade overrides,
    // which is the concrete day-one harm. Hook writes still get
    // checked (noBareBemHookWrites) because the inheritance-leak
    // risk IS real for hooks.
    if (file.role === 'theme-base') continue;
    file.root.walkRules((rule) => {
      const selector = rule.selector;
      if (!BEM_NOTATION_RE.test(selector)) return;
      let hasHookWrite = false;
      let hasPaintDecl = false;
      rule.walkDecls((decl) => {
        Iif (decl.parent !== rule) return;
        if (isHookWriteDecl(decl)) hasHookWrite = true;
        else hasPaintDecl = true;
      });
      // Hook-write rules belong to noBareBemHookWrites; skip them here
      // even when they also contain paint declarations, so a single
      // offending rule is reported in exactly one row.
      if (hasHookWrite) return;
      Iif (!hasPaintDecl) return;
      const parts = selector
        .split(',')
        .map((p) => p.trim())
        .filter(Boolean);
      const line = rule.source?.start?.line;
      for (const part of parts) {
        const surface = classifySurface(part, input.componentName);
        if (!isAnchoringIncorrect(surface)) continue;
        offenders.push({
          selector: part,
          location: line ? `${file.path}:${line}` : file.path,
          fix: fixForSurface(surface, part, ownPrefix),
        });
      }
    });
  }
 
  if (offenders.length === 0) {
    return passRow(
      ID,
      LABEL,
      'Every paint declaration on a child-element selector is anchored to the component root, so the rule cannot match outside an actual instance of the component.',
      CATEGORY,
    );
  }
 
  return failRow(
    ID,
    LABEL,
    `${offenders.length} paint rule${offenders.length === 1 ? '' : 's'} on bare child-element selectors. ` +
      `BEM-element class shapes (\`.slds-${ownPrefix}__…\`) can land as descendants of any cascade context; ` +
      `without an instance anchor (\`.slds-${ownPrefix} .slds-${ownPrefix}__…\`), the rule paints any element ` +
      `carrying the class — even outside an actual \`.slds-${ownPrefix}\` instance. ` +
      `Anchor each selector to the component root per RFC 1157 Rule 5. ` +
      `Bare modifier selectors are exempt — they're component-prefixed and only ever land on real instances.`,
    offenders,
  );
};