All files / packages/sds-customization-compliance/src/checks/helpers selectorSurface.ts

95.89% Statements 70/73
87.23% Branches 41/47
100% Functions 18/18
98.3% Lines 58/59

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 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289                                                                                                                9x 9x                                   9x                                                       29x           29x 29x   29x                     207x       41x 41x 64x 49x   41x       41x 41x 41x 79x 15x 15x   64x     41x       97x                                 19x 19x 19x 19x 18x                     9x 9x 9x 8x                   41x               44x 41x   25x 22x   13x 11x 7x       41x 41x 41x 41x         41x 41x 41x 41x 41x 9x   9x         41x 41x                   41x 41x 41x   41x                   7x                                                   27x    
/**
 * Selector-surface classifier. Tells callers which kind of API surface
 * a given selector targets, in the theme-layer customization model.
 *
 * The customer surface for a component is its root class (the bare
 * `.slds-{C}` for Light DOM, or the host element in LBC). Customers
 * compose against this surface and override its hooks; everything they
 * read or set on this selector is API.
 *
 * Modifiers (`.slds-{C}_neutral`, `.slds-{C}_full-width`,
 * `.slds-{C}_brand`) and child elements (`.slds-{C}__icon`) are system
 * surface: vocabulary the system uses to paint modifiers and inner
 * structure. Customers do not override these directly; they compose
 * `.slds-{C}` with their own class and let the system's modifier paint
 * carry through.
 *
 * The two concepts answer different questions:
 *
 *   - **Customer reach** (Rule 4 territory): "can a customer's
 *     override land here?" Only the customer surface answers yes.
 *     Hook writes here must respect customer-reach constraints.
 *
 *   - **Selector anchoring** (Rule 5 territory): "can this selector
 *     match outside an actual instance of the component, leaking via
 *     inheritance?" Bare modifier and element forms can; root-anchored
 *     compounds and descendants cannot. This applies regardless of
 *     whether the surface is customer-reachable.
 *
 * A selector can be system surface (no customer-reach concern) yet
 * still be anchoring-incorrect (Rule 5 cascade-leakage concern). The
 * checks that consume this helper use the surface kind to phrase fix
 * messages accurately.
 */
 
import selectorParser from 'postcss-selector-parser';
import { resolveBemRoot, resolveBemRoots, resolveHookPrefix } from './resolveHookPrefix.js';
 
export type SelectorSurface =
  /** The customer-reachable canonical surface: bare `.slds-{C}` (with optional pseudo-state or attribute tail). */
  | 'customer-base'
  /** A root-anchored modifier compound (`.slds-{C}.slds-{C}_modifier`). System surface; correctly anchored. */
  | 'system-modifier-anchored'
  /** A bare modifier class (`.slds-{C}_modifier`) without the root anchor. System surface; cascade-leakage risk. */
  | 'system-modifier-bare'
  /** A root-anchored element descendant (`.slds-{C} .slds-{C}__element`). System surface; correctly anchored. */
  | 'system-element-anchored'
  /** A bare element class (`.slds-{C}__element`) without the root anchor. System surface; cascade-leakage risk. */
  | 'system-element-bare'
  /** A descendant chain rooted at the component (`.slds-{C} .child`, where `.child` is not a BEM element of `.slds-{C}`). */
  | 'own-descendant'
  /** Anything else: foreign descendant chain, generic wrapper context, cross-component reach. */
  | 'foreign-or-generic';
 
/* BEM root segments are kebab-case (`avatar-group`, `button-group`,
   `color-picker`), not single tokens. The capture group allows
   internal hyphens; `_` and `__` still terminate the root. */
const BEM_MODIFIER_RE = /^slds-([a-z][a-z0-9-]*)_([a-z][a-z0-9-]*)$/;
const BEM_ELEMENT_RE = /^slds-([a-z][a-z0-9-]*)__[a-z][a-z0-9-]*/;
 
/**
 * 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.). When one
 * of these appears on the leftmost compound, we treat the rule as
 * anchored inside the wrapping component's own scope rather than as
 * a bare descendant write. The single source of truth for this
 * allow-list — `no-descendant-hook-writes.ts` imports it from here.
 *
 * `slds-hint-parent` is included for the "child reacts when parent is
 * hovered/focused" pattern: row-level wrappers (treeGrid rows, list
 * items, etc.) toggle hover/focus state and the contained icon
 * buttons recolor in response. Same shape as the form-state
 * wrappers — a documented context class many components legitimately
 * read against — so it gets the same anchoring treatment.
 */
export const STATE_CONTEXT_CLASSES: ReadonlySet<string> = new Set([
  'slds-has-error',
  'slds-is-disabled',
  'slds-is-editing',
  'slds-is-active',
  'slds-is-selected',
  'slds-is-loading',
  'slds-is-required',
  'slds-is-open',
  'slds-is-focused',
  'slds-hint-parent',
]);
 
/**
 * True when the leftmost compound of a selector chain anchors the
 * rule inside the component's own scope. Three cases pass:
 *
 *   1. Leftmost compound contains `slds-{bemRoot}` directly
 *      (`slds-avatar-group`, `slds-button`).
 *   2. Leftmost compound contains a class prefixed with
 *      `slds-{hookPrefix}-` or `slds-{bemRoot}-` (component-specific
 *      wrapper class such as `slds-input-has-icon_left` or
 *      `slds-avatar-group-stack`).
 *   3. Leftmost compound contains a documented form-state wrapper
 *      class (`slds-has-error`, `slds-is-disabled`, etc.) — see
 *      {@link STATE_CONTEXT_CLASSES}.
 */
function leftmostIsOwnScope(left: string[], ownBemRoot: string, ownHookPrefix: string): boolean {
  const ownRoot = `slds-${ownBemRoot}`;
  // Wrapper classes are named after the hook prefix
  // (`slds-input-has-icon_left`, `slds-pill-has-error`, …), not the
  // BEM root. Match both forms so multi-word components like
  // `avatarGroup` (BEM root `avatar-group`, hook prefix
  // `avatargroup`) accept wrapper classes that may use either spelling.
  const ownWrapperPrefixes = [`slds-${ownHookPrefix}-`, `${ownRoot}-`];
  return left.some(
    (cls) =>
      cls === ownRoot || ownWrapperPrefixes.some((p) => cls.startsWith(p)) || STATE_CONTEXT_CLASSES.has(cls),
  );
}
 
interface SelectorAstNode {
  type: string;
  value?: string;
  nodes?: SelectorAstNode[];
}
 
function isCombinator(node: SelectorAstNode): boolean {
  return node.type === 'combinator';
}
 
function leftmostCompound(sel: SelectorAstNode): SelectorAstNode[] {
  const out: SelectorAstNode[] = [];
  for (const node of sel.nodes ?? []) {
    if (isCombinator(node)) break;
    out.push(node);
  }
  return out;
}
 
function rightmostCompound(sel: SelectorAstNode): SelectorAstNode[] {
  const groups: SelectorAstNode[][] = [[]];
  let current: SelectorAstNode[] = groups[0];
  for (const node of sel.nodes ?? []) {
    if (isCombinator(node)) {
      current = [];
      groups.push(current);
    } else {
      current.push(node);
    }
  }
  return current;
}
 
function classNamesIn(compound: SelectorAstNode[]): string[] {
  return compound.filter((n) => n.type === 'class').map((n) => n.value ?? '');
}
 
/**
 * Classify the surface a single selector (one comma-list entry) targets,
 * for the given component name. The classification considers the
 * rightmost compound (what the rule paints on) plus the leftmost
 * compound (what anchors it).
 */
interface OwnIdentity {
  /** Kebab-case BEM root (`avatar-group`, `button`). */
  bemRoot: string;
  /** Squashed hook prefix (`avatargroup`, `button`). */
  hookPrefix: string;
}
 
function classifyElement(rightmostElement: string, own: OwnIdentity, left: string[]): SelectorSurface {
  const elementMatch = BEM_ELEMENT_RE.exec(rightmostElement);
  Iif (!elementMatch) return 'foreign-or-generic';
  const elementOwner = elementMatch[1];
  if (elementOwner !== own.bemRoot) return 'foreign-or-generic';
  return leftmostIsOwnScope(left, own.bemRoot, own.hookPrefix)
    ? 'system-element-anchored'
    : 'system-element-bare';
}
 
function classifyModifier(
  rightmostModifier: string,
  own: OwnIdentity,
  ownRoot: string,
  right: string[],
): SelectorSurface {
  const modifierMatch = BEM_MODIFIER_RE.exec(rightmostModifier);
  Iif (!modifierMatch) return 'foreign-or-generic';
  if (modifierMatch[1] !== own.bemRoot) return 'foreign-or-generic';
  return right.includes(ownRoot) ? 'system-modifier-anchored' : 'system-modifier-bare';
}
 
function classifyAgainstRoot(
  sel: SelectorAstNode,
  own: OwnIdentity,
  left: string[],
  right: string[],
  isSingleCompound: boolean,
): SelectorSurface {
  const ownRoot = `slds-${own.bemRoot}`;
 
  // A BEM root that itself contains an underscore (e.g.
  // `radio_button-group`, `radio_button`) would falsely match
  // BEM_MODIFIER_RE / BEM_ELEMENT_RE if we let the find() scan it.
  // Skip the own-root class when looking for an inner modifier or
  // element shape so the customer-base / wrapper-anchored cases
  // resolve correctly.
  const rightmostElement = right.find((c) => c !== ownRoot && BEM_ELEMENT_RE.test(c));
  if (rightmostElement) return classifyElement(rightmostElement, own, left);
 
  const rightmostModifier = right.find((c) => c !== ownRoot && BEM_MODIFIER_RE.test(c));
  if (rightmostModifier) return classifyModifier(rightmostModifier, own, ownRoot, right);
 
  if (isSingleCompound && right.includes(ownRoot)) return 'customer-base';
  if (leftmostIsOwnScope(left, own.bemRoot, own.hookPrefix)) return 'own-descendant';
  return 'foreign-or-generic';
}
 
function classifySingleSelector(sel: SelectorAstNode, componentName: string): SelectorSurface {
  const left = classNamesIn(leftmostCompound(sel));
  const right = classNamesIn(rightmostCompound(sel));
  const isSingleCompound = !(sel.nodes ?? []).some(isCombinator);
  const hookPrefix = resolveHookPrefix(componentName);
 
  // Try each BEM root the directory owns (primary + siblings). The first
  // non-foreign classification wins. Hook prefix stays the same across
  // roots — it's the directory-level identity, not per-root.
  let fallback: SelectorSurface = 'foreign-or-generic';
  for (const bemRoot of resolveBemRoots(componentName)) {
    const own: OwnIdentity = { bemRoot, hookPrefix };
    const result = classifyAgainstRoot(sel, own, left, right, isSingleCompound);
    if (result !== 'foreign-or-generic') return result;
    fallback = result;
  }
  return fallback;
}
 
export function classifySurface(selector: string, componentName: string): SelectorSurface {
  let ast;
  try {
    ast = selectorParser().astSync(selector);
  } catch {
    // postcss-selector-parser doesn't accept every modern CSS selector
    // form (e.g. comma-separated arguments to `:not(...)` from
    // Selectors Level 4). Treat unparseable selectors as foreign so
    // anchoring checks don't crash on them. They simply won't be
    // classified as system surface; structural integrity rules with
    // tighter scope can still flag them via other paths.
    return 'foreign-or-generic';
  }
  let result: SelectorSurface = 'foreign-or-generic';
  ast.each((sel) => {
    result = classifySingleSelector(sel, componentName);
  });
  return result;
}
 
/**
 * Returns true when the surface is system-owned painting vocabulary
 * (modifiers and child elements). Customers do not override these
 * positions; the customer-reach concept does not apply, but selector
 * anchoring (Rule 5 cascade hygiene) does.
 */
export function isSystemSurface(surface: SelectorSurface): boolean {
  return (
    surface === 'system-modifier-anchored' ||
    surface === 'system-modifier-bare' ||
    surface === 'system-element-anchored' ||
    surface === 'system-element-bare'
  );
}
 
/**
 * Returns true when the surface has a Rule 5 anchoring problem (the
 * selector can match outside an actual instance of the component and
 * leak via inheritance into unrelated descendants).
 *
 * Limited to bare *element* selectors. Modifier selectors
 * (`.slds-foo_brand`) are name-spaced to a single component by
 * convention and only ever land on a `.slds-foo` instance; even
 * when a hook write happens on a bare modifier, the actual paint
 * occurs at the read site, which is anchored to `.slds-foo`. So
 * `.slds-foo_brand { --slds-c-foo-size: 16px }` is fine: the value
 * inherits through the cascade but only paints where a `.slds-foo`
 * descendant reads it. Bare *elements* (`.slds-foo__bar`) are
 * different: the BEM-element shape is shared across components and
 * can land on descendants of any cascade context, so a hook write
 * with no instance anchor genuinely leaks.
 */
export function isAnchoringIncorrect(surface: SelectorSurface): boolean {
  return surface === 'system-element-bare';
}