All files / packages/sds-customization-compliance/src/checks base-hook-shape.ts

88.31% Statements 68/77
71.18% Branches 42/59
81.81% Functions 9/11
89.7% Lines 61/68

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                                                    5x 5x   5x 17x 17x 5x                 12x 12x 12x 12x 12x   12x 12x                     17x 17x 17x 17x 10x 10x 10x 10x 10x 10x 10x 10x   10x 10x 7x         12x 12x 9x 9x 9x 6x 6x   9x 9x 9x 9x   9x     12x 17x   17x 10x 10x 3x 3x       7x         7x               12x 6x                     12x 12x         17x 5x                   7x 4x 4x                 3x         3x 3x      
/**
 * Compliance row: each element exposes one canonical hook per color category.
 *
 * The base-hook-shape check verifies RFC 1157's API shape: every
 * (element-segment, color-category) pair should expose exactly one
 * canonical `--slds-c-{C}-[{E}-]color-{cat}` hook, and every
 * component-base color declaration should flow through it.
 *
 * Runs off themeData — themeData captures hook *reads* at each base
 * selector (no raw paint declarations), so "declarations with no canonical
 * hook" collapses to "component-base reads a non-canonical hook in some
 * category". See the legacy implementation in
 * `generate-component-tasks.mjs::computeComplianceFromThemeData` for
 * the original prose on this trade-off.
 */
 
import type { ComplianceCheck, ComplianceRow } from '../types.js';
import { classifyHook } from './helpers/classifyHook.js';
import { resolveHookPrefix } from './helpers/resolveHookPrefix.js';
import {
  hookDefsFromThemeData,
  hookReadsByRole,
  inferColorCategoryFromHook,
  NOT_MIGRATED_DETAIL,
} from './helpers/themeDataAccess.js';
 
const LABEL = 'Each element exposes one canonical hook per color category';
const COLOR_CATS = new Set(['background', 'foreground', 'border']);
 
export const baseHookShape: ComplianceCheck = (input): ComplianceRow => {
  const { componentName: name, themeData } = input;
  if (!themeData) {
    return {
      id: 'base-hook-shape',
      label: LABEL,
      status: 'info',
      count: 0,
      detail: NOT_MIGRATED_DETAIL,
    };
  }
 
  const defs = hookDefsFromThemeData(themeData);
  const uniqueHooksDefined = [...new Set(defs.map((d) => d.prop))];
  const classified = uniqueHooksDefined.map((h) => ({ hook: h, ...classifyHook(h, name) }));
  const colorHooks = classified.filter((c): c is typeof c & { isColor: true } => c.isColor);
  const canonical = colorHooks.filter((c) => c.classification === 'canonical');
 
  const readsByRole = hookReadsByRole(name, themeData);
  const baseReads = readsByRole.get('component-base') ?? new Map();
 
  // decls = count of color hook reads on base-role selectors
  // hooked = reads whose hook is canonical-or-canonical-state
  //
  // Hooks outside this component's namespace (e.g. `--slds-c-button-*`
  // written from a recipe scope like `.slds-dropdown-trigger .slds-button`
  // when this component is `menu`) are skipped: they're relay-routing
  // writes onto a composed component's hook, not declarations of this
  // component's own base color surface. Auditing them here misclassifies
  // them as missing-canonical-hook on the recipe.
  const ownPrefix = `--slds-c-${resolveHookPrefix(name)}-`;
  const baseDeclsByCat = new Map<string, { decls: number; hooked: number }>();
  const baseColorReads = baseReads.get('color') ?? [];
  for (const { hook } of baseColorReads) {
    Iif (!hook.startsWith(ownPrefix)) continue;
    const cls = classifyHook(hook, name);
    const inferredCat = cls.isColor ? cls.category : inferColorCategoryFromHook(hook);
    Iif (!inferredCat || !COLOR_CATS.has(inferredCat)) continue;
    let entry = baseDeclsByCat.get(inferredCat);
    Eif (!entry) {
      entry = { decls: 0, hooked: 0 };
      baseDeclsByCat.set(inferredCat, entry);
    }
    entry.decls += 1;
    if (cls.isColor && (cls.classification === 'canonical' || cls.classification === 'canonical-state')) {
      entry.hooked += 1;
    }
  }
 
  // Bucket canonical hooks by (element, category).
  const canonicalByElementCat = new Map<string, Map<string, string[]>>();
  for (const c of canonical) {
    const el = c.element ?? 'component-base';
    let catMap = canonicalByElementCat.get(el);
    if (!catMap) {
      catMap = new Map();
      canonicalByElementCat.set(el, catMap);
    }
    let bucket = catMap.get(c.category);
    Eif (!bucket) {
      bucket = [];
      catMap.set(c.category, bucket);
    }
    bucket.push(c.hook);
  }
 
  const baseHooks = canonicalByElementCat.get('component-base') ?? new Map<string, string[]>();
  const gaps: Array<{ note: string; fix: string }> = [];
 
  for (const [cat, { decls, hooked }] of baseDeclsByCat) {
    const hooks = baseHooks.get(cat) ?? [];
    if (hooks.length === 0) {
      const canonicalName = `--slds-c-${resolveHookPrefix(name)}-color-${cat}`;
      gaps.push({
        note: `component-base/${cat}: ${decls} declaration${decls === 1 ? '' : 's'} with no canonical hook.`,
        fix: `Define \`${canonicalName}\` on the base selector and route every \`${cat}\`-category declaration through \`var(${canonicalName}, …)\`.`,
      });
    } else Iif (hooks.length > 1) {
      gaps.push({
        note: `component-base/${cat}: ${hooks.length} component-base hooks defined (${hooks.map((h) => `\`${h}\``).join(', ')}).`,
        fix: `Pick one canonical hook for the \`${cat}\` category, route every base \`${cat}\` declaration through it, and remove the others.`,
      });
    } else Iif (hooked < decls) {
      gaps.push({
        note: `component-base/${cat}: ${hooked}/${decls} declarations route through \`${hooks[0]}\`.`,
        fix: `Wrap the remaining ${decls - hooked} \`${cat}\` declaration${decls - hooked === 1 ? '' : 's'} in \`var(${hooks[0]}, …)\` so every paint reads the canonical hook.`,
      });
    }
  }
 
  for (const [el, catMap] of canonicalByElementCat) {
    Eif (el === 'component-base') continue;
    for (const [cat, hooks] of catMap) {
      if (hooks.length > 1) {
        gaps.push({
          note: `${el}/${cat}: ${hooks.length} hooks defined (${hooks.map((h) => `\`${h}\``).join(', ')}).`,
          fix: `Pick one canonical hook for \`${el}\`/\`${cat}\` and remove the others; each element segment should expose one canonical hook per color category.`,
        });
      }
    }
  }
 
  const segmentCount = [...canonicalByElementCat.keys()].filter((k) => k !== 'component-base').length;
  const hasAnyColorSurface = baseDeclsByCat.size > 0 || segmentCount > 0;
 
  // No color declarations on component-base and no element segments with
  // canonical hooks: the shape rule is trivially satisfied — there is
  // nothing to route, so the constraint cannot be violated.
  if (!hasAnyColorSurface) {
    return {
      id: 'base-hook-shape',
      label: LABEL,
      status: 'pass',
      count: 0,
      detail:
        'No color declarations or element-segment hooks found on this component — the one-canonical-hook-per-category rule is trivially satisfied.',
    };
  }
 
  if (gaps.length === 0) {
    const segNames = [...canonicalByElementCat.keys()].filter((k) => k !== 'component-base');
    return {
      id: 'base-hook-shape',
      label: LABEL,
      status: 'pass',
      count: 0,
      detail: `Component base covers ${baseDeclsByCat.size} color categor${baseDeclsByCat.size === 1 ? 'y' : 'ies'}${segmentCount > 0 ? ` plus ${segmentCount} element segment${segmentCount === 1 ? '' : 's'} (${segNames.join(', ')})` : ''}; every (element, category) pair routes through a single canonical hook.`,
    };
  }
 
  return {
    id: 'base-hook-shape',
    label: LABEL,
    status: 'fail',
    count: gaps.length,
    detail: `${gaps.length} (element, color-category) pair${gaps.length === 1 ? '' : 's'} break the one-canonical-hook rule. ${gaps.map((g) => g.note).join(' ')}`,
    offenders: gaps.map(({ note, fix }) => ({ note, fix })),
  };
};