All files / packages/sds-customization-compliance/src/checks base-hook-assignments-reference-opened-hooks.ts

88% Statements 44/50
61.76% Branches 21/34
100% Functions 9/9
90.69% Lines 39/43

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                                                                      4x 4x               3x 3x 3x 3x 3x 3x       3x                                   2x               4x 4x           4x             5x 5x 5x 5x 4x     5x             9x 4x   5x     4x 14x 14x 5x                 9x 9x 9x 6x                   3x 4x   3x 1x 1x                 2x 2x 14x           2x                  
/**
 * Compliance row: hook assignments in `themes/base.css` only target hooks
 * that base reads via `var()`.
 *
 * Under the theme-layer architecture, base "opens" a component hook by
 * *reading* it on a paint property (e.g. `color: var(--slds-c-x-color-foreground)`).
 * Customers override opened hooks through `@layer customization`. Base is
 * also allowed to *assign* those hooks — typically to scope a state or
 * composition (e.g. `.slds-current-color .slds-icon
 * { --slds-c-icon-color-foreground: currentColor; }`). That's fine because
 * the hook is already part of the API.
 *
 * What's NOT ok is assigning a hook in this component's own base.css that
 * this component's base never reads. Such a "minted" hook has no presence
 * in the base API surface, can't be overridden through the documented
 * customer surface, and pins values outside the theme layer.
 *
 * Scope: only hooks carrying *this* component's prefix are checked.
 * Cross-component assignments (e.g. alert's base scoping
 * `.slds-notify_alert .slds-icon { --slds-c-icon-color-foreground: ... }`)
 * are accepted — that's sanctioned descendant-selector composition, and
 * the downstream component is responsible for opening its own hooks.
 *
 * The check is strictly binary:
 *   - no same-prefix assignments in `themes/base.css` → pass (nothing to check)
 *   - every same-prefix assignment targets a hook base also reads → pass
 *   - any same-prefix assignment targets a hook base never reads → fail
 *
 * Returns `info` only when themeData itself is missing (check not run).
 */
 
import type { ComplianceCheck, ComplianceRow, Offender, ThemeData } from '../types.js';
import { resolveHookPrefix } from './helpers/resolveHookPrefix.js';
import { sampleHooks, NOT_MIGRATED_DETAIL } from './helpers/themeDataAccess.js';
 
const LABEL = 'Hook assignments in `themes/base.css` only target hooks that base reads via `var()`';
const ID = 'base-hook-assignments-reference-opened-hooks';
 
/**
 * Collect every `--slds-c-{prefix}-*` hook base reads via `var()` anywhere
 * in its flattened selectors. This is the opened-hook API surface for the
 * current component.
 */
function hooksReadInBase(themeData: ThemeData, hookPrefix: string): Set<string> {
  const opened = new Set<string>();
  for (const categories of Object.values(themeData.selectors ?? {})) {
    for (const hooks of Object.values(categories)) {
      Iif (!Array.isArray(hooks)) continue;
      for (const h of hooks) {
        Eif (h.startsWith(hookPrefix)) opened.add(h);
      }
    }
  }
  return opened;
}
 
/**
 * Flatten `baseAssignments` into `{ selector, prop, rawValue }` rows
 * filtered to this component's prefix only. Cross-component assignments
 * (e.g. alert's base touching `--slds-c-icon-*`) are out of scope for
 * this check.
 */
interface BaseAssignmentRow {
  selector: string;
  prop: string;
  rawValue: string;
  source: string | null;
  line: number | null;
}
 
function locationOf(row: { source: string | null; line: number | null }): string | null {
  Eif (!row.source) return null;
  return row.line ? `${row.source}:${row.line}` : row.source;
}
 
function ownBaseAssignmentRowsFromRich(
  block: NonNullable<ThemeData['baseAssignmentRows']>,
  hookPrefix: string,
): BaseAssignmentRow[] {
  const rows: BaseAssignmentRow[] = [];
  for (const [selector, hookMap] of Object.entries(block)) {
    for (const [prop, row] of Object.entries(hookMap)) {
      if (!prop.startsWith(hookPrefix)) continue;
      rows.push({ selector, prop, rawValue: row.value, source: row.source, line: row.line });
    }
  }
  return rows;
}
 
function ownBaseAssignmentRowsFromBare(
  block: NonNullable<ThemeData['baseAssignments']>,
  hookPrefix: string,
): BaseAssignmentRow[] {
  const rows: BaseAssignmentRow[] = [];
  for (const [selector, hookMap] of Object.entries(block)) {
    for (const [prop, rawValue] of Object.entries(hookMap)) {
      if (!prop.startsWith(hookPrefix)) continue;
      rows.push({ selector, prop, rawValue: String(rawValue ?? ''), source: null, line: null });
    }
  }
  return rows;
}
 
function ownBaseAssignmentRows(themeData: ThemeData, hookPrefix: string): BaseAssignmentRow[] {
  // Prefer source-enriched rows when the CLI populated them; fall back to
  // the bare-string `baseAssignments` shape so themeData JSON loaded from
  // disk (without source positions) still works.
  if (themeData.baseAssignmentRows) {
    return ownBaseAssignmentRowsFromRich(themeData.baseAssignmentRows, hookPrefix);
  }
  return ownBaseAssignmentRowsFromBare(themeData.baseAssignments ?? {}, hookPrefix);
}
 
export const baseHookAssignmentsReferenceOpenedHooks: ComplianceCheck = (input): ComplianceRow => {
  const { componentName, themeData } = input;
  if (!themeData) {
    return {
      id: ID,
      label: LABEL,
      status: 'info',
      count: 0,
      detail: NOT_MIGRATED_DETAIL,
    };
  }
 
  const hookPrefix = `--slds-c-${resolveHookPrefix(componentName)}-`;
  const assignments = ownBaseAssignmentRows(themeData, hookPrefix);
  if (assignments.length === 0) {
    return {
      id: ID,
      label: LABEL,
      status: 'pass',
      count: 0,
      detail:
        "`themes/base.css` makes no assignments to this component's own hooks — nothing to mint, so the rule is trivially satisfied.",
    };
  }
 
  const opened = hooksReadInBase(themeData, hookPrefix);
  const offenders = assignments.filter((row) => !opened.has(row.prop));
 
  if (offenders.length === 0) {
    const plural = assignments.length === 1 ? '' : 's';
    return {
      id: ID,
      label: LABEL,
      status: 'pass',
      count: 0,
      detail: `All ${assignments.length} base-scoped assignment${plural} to this component's own hooks target hooks that base also reads via \`var()\` — those are sanctioned state/composition scopings, not minted API.`,
    };
  }
 
  const offenderHooks = [...new Set(offenders.map((o) => o.prop))];
  const plural = offenders.length === 1 ? '' : 's';
  return {
    id: ID,
    label: LABEL,
    status: 'fail',
    count: offenders.length,
    detail: `${offenders.length} of ${assignments.length} base assignment${plural} target hook${plural} that base never reads via \`var()\`: ${sampleHooks(offenderHooks)}. Minted hooks like these are invisible to the documented customer surface and pin values outside the theme layer. Either read the hook on a base paint declaration, or move the assignment into a theme file (\`@layer theme\`).`,
    offenders: offenders.map<Offender>((row) => ({
      selector: row.selector,
      hook: row.prop,
      location: locationOf(row) ?? undefined,
      note: row.rawValue ? `= ${row.rawValue}` : undefined,
      fix: `Either add a paint declaration in base that reads \`${row.prop}\` via \`var()\` so the hook becomes part of the public API, or move \`${row.selector} { ${row.prop}: ${row.rawValue}; }\` out of \`themes/base.css\` and into a theme file under \`@layer theme\`.`,
    })),
  };
};