All files / packages/sds-customization-compliance/src/checks/structural no-descendant-hook-writes.ts

91.66% Statements 44/48
81.48% Branches 22/27
100% Functions 6/6
97.43% Lines 38/39

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                                                                                                      5x 5x 5x                                                                                           11x 11x       11x                   11x 11x   11x 11x       11x 11x 11x 16x 11x 11x 16x         6x 6x       11x     5x 23x 23x 19x 3x     16x 16x   16x 20x 16x 16x       11x               5x 5x 5x                         16x 11x               5x                
/**
 * Rule 4a: no cross-component hook writes on descendant selectors.
 *
 * A descendant write whose leftmost compound is NOT the component's own
 * root reaches into another component's scope. The customer's override
 * on the owning component cannot beat the descendant write because the
 * customer's class composes with the owning component's root, never the
 * foreign descendant chain.
 *
 * What this check passes: same-component descendant writes, including
 * the canonical Rule 5 element-descendant form
 * (`.slds-{C} .slds-{C}__element`) and the element-modifier form
 * (`.slds-{C} .slds-{C}__element_modifier`). These are the theme
 * decorating canonical children of the component it owns; customer
 * overrides on the component root flow into descendants by inheritance.
 *
 * What this check flags: cross-context descendants
 * (`.slds-current-color .slds-icon { ... }` in `icon/themes/cosmos.css`)
 * and parent-component reach (`.slds-button .slds-icon { ... }` in
 * `icon/themes/cosmos.css`, where the icon component is reaching out
 * to write hooks scoped under another component's root). The owning
 * parent component is the right home for those rules.
 *
 * Fix: relocate the rule into the owning component's theme file, or
 * if the rule represents the icon component's own contextualized
 * appearance, write it as a same-component descendant in icon's theme
 * (`.slds-icon ...`).
 *
 * Carveout — relay routing: a rule whose hook writes all use the
 * `var(<relay>, revert-layer)` shape is a Path 4 recipe-level relay
 * route, not a cross-component override. `revert-layer` defers to
 * the composed component's own layered paint when the relay isn't
 * set, so the composed component's customer surface stays reachable.
 * See `helpers/relayRouting.ts`.
 */
 
import selectorParser from 'postcss-selector-parser';
import { classifySelector } from '../helpers/selectorIssues.js';
import { resolveBemRoots, resolveHookPrefix } from '../helpers/resolveHookPrefix.js';
import { STATE_CONTEXT_CLASSES } from '../helpers/selectorSurface.js';
import { isRelayRoutingRule } from '../helpers/relayRouting.js';
import type { ComplianceCheck, ComplianceRow, Offender } from '../../types.js';
import {
  declLocation,
  failRow,
  notRunYetRow,
  passRow,
  themeLayerFilesFor,
  visitHookWriteRules,
} from './internals.js';
 
const ID = 'structural-no-descendant-hook-writes';
const LABEL = 'No cross-component hook writes on descendant selectors';
const CATEGORY = 'customer-reach' as const;
 
/**
 * Form-element / list-item state classes that intentionally wrap many
 * components (`.slds-form-element` toggles `slds-has-error`,
 * `.slds-listbox__option` toggles `slds-is-selected`, etc.). A
 * descendant chain whose leftmost compound is one of these — e.g.
 * `.slds-has-error .slds-input` — is NOT a cross-component reach;
 * it's the documented way the wrapping form-element styles its
 * nested control. The customer can still override the canonical hook
 * on the component root because the inheritance flows from the
 * wrapper down through the input.
 *
 * Carveout limited to a hand-curated allow-list rather than every
 * `slds-has-*`/`slds-is-*` class so generic utility wrappers
 * (`slds-current-color`, etc.) keep getting flagged. The allow-list
 * itself is declared in `selectorSurface.ts` and imported above.
 */
 
/**
 * Returns true when the selector's leftmost compound is anchored
 * inside the component's own scope. Three cases pass:
 *
 *   1. The leftmost compound includes the component's own root class
 *      (`.slds-button.slds-button_brand:hover .slds-button__icon`).
 *   2. The leftmost compound is a class prefixed with the component's
 *      own name (`.slds-input-has-icon_left .slds-input`). These
 *      wrapper classes only make sense alongside this component, so
 *      a hook write under them is same-scope authoring.
 *   3. The leftmost compound is a documented form-state wrapper class
 *      from {@link STATE_CONTEXT_CLASSES} (`.slds-has-error
 *      .slds-input`). Form-element et al. own these classes; nested
 *      inputs/buttons take their state cues from them.
 *
 * Components with a hook prefix override (e.g. `buttonIcon` →
 * `button`) anchor on the prefix root, so `.slds-button
 * .slds-button__icon` is same-component for `buttonIcon`.
 *
 * Hook prefix vs. BEM root: hook names squash camelCase
 * (`--slds-c-avatargroup-*`) while CSS classes are kebab-case
 * (`.slds-avatar-group`). Match against the BEM root for class
 * comparison; reserve the hook prefix for the wrapper-class match
 * (`.slds-input-has-icon_left` is a wrapper around `.slds-input`,
 * named after the hook prefix `input`, not the BEM root).
 */
function leftmostAnchoredToOwnRoot(selector: string, componentName: string): boolean {
  const ownPrefix = resolveHookPrefix(componentName);
  const ownBemRoots = resolveBemRoots(componentName);
  // Accept ANY of the directory's BEM roots as a valid anchor — for
  // multi-root components like card (`.slds-card` + `.slds-card-wrapper`),
  // a hook write on either root counts as own-scope.
  const ownRootClasses = ownBemRoots.map((r) => `slds-${r}`);
  // Wrapper classes are named after either the hook prefix
  // (`slds-input-has-icon_left`) or the BEM root
  // (`slds-combobox-button`, where combobox's hook prefix is `input`
  // but the recipe variant uses the directory-name root). Both forms
  // anchor a rule inside the component's own scope. Mirrors the dual-
  // prefix shape in `selectorSurface.leftmostIsOwnScope`; without it
  // multi-prefix components (combobox, menu, buttonIcon,
  // progressIndicator, trees) get false-flagged on
  // `slds-{bemroot}-{recipeVariant}` selectors.
  const ownWrapperPrefixes = [`slds-${ownPrefix}-`, ...ownBemRoots.map((r) => `slds-${r}-`)];
  let anchored = false;
  let ast;
  try {
    ast = selectorParser().astSync(selector);
  } catch {
    return false;
  }
  ast.each((sel) => {
    Iif (anchored) return;
    for (const node of sel.nodes) {
      if (node.type === 'combinator') return;
      Iif (node.type !== 'class') continue;
      const cls = node.value ?? '';
      if (
        ownRootClasses.includes(cls) ||
        ownWrapperPrefixes.some((p) => cls.startsWith(p)) ||
        STATE_CONTEXT_CLASSES.has(cls)
      ) {
        anchored = true;
        return;
      }
    }
  });
  return anchored;
}
 
export const noDescendantHookWrites: 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 ownHookPrefix = resolveHookPrefix(input.componentName);
  const offenders: Offender[] = [];
 
  for (const file of files) {
    visitHookWriteRules(file, ({ rule, hookDecls }) => {
      const cls = classifySelector(rule.selector, input.componentName);
      if (!cls.issues.includes('descendant-or-child')) return;
      // Same-component descendant writes are the canonical theme-layer
      // composition shape. Only flag when the leftmost compound is NOT
      // anchored to the component's own root.
      if (leftmostAnchoredToOwnRoot(rule.selector, input.componentName)) return;
      // Relay-routing carveout: a rule whose hook writes all forward
      // INTO an embedded component's hook FROM this component's own
      // `--slds-c-{ownHookPrefix}-*` namespace with a `revert-layer`
      // fallback is an embedding-component relay, not a cross-component
      // override. `revert-layer` defers to the embedded component's
      // own layered paint when the relay isn't set, so its customer
      // surface stays reachable.
      Iif (isRelayRoutingRule(hookDecls, ownHookPrefix)) return;
      for (const decl of hookDecls) {
        offenders.push({
          selector: rule.selector,
          hook: decl.prop,
          location: declLocation(file, decl),
          fix:
            `\`${rule.selector}\` writes \`${decl.prop}\` on a descendant chain not anchored to \`.slds-${input.componentName}\`. ` +
            `Customer overrides on \`.slds-${input.componentName}\` cannot reach it. ` +
            `Anchor the rule to \`.slds-${input.componentName}\` for own-component composition, or move the rule into the owning parent component's theme file.`,
        });
      }
    });
  }
 
  if (offenders.length === 0) {
    return passRow(
      ID,
      LABEL,
      'No cross-component descendant hook writes. Every descendant hook write is anchored to the component that owns the rule, so customer overrides on that component flow through inheritance.',
      CATEGORY,
    );
  }
 
  return failRow(
    ID,
    LABEL,
    `${offenders.length} hook write${offenders.length === 1 ? '' : 's'} on descendant selectors anchored outside the component's own root. ` +
      `Customer overrides on the owning component cannot reach these positions; relocate each rule to the owning component's theme file.`,
    offenders,
  );
};