All files / packages/sds-customization-compliance/src/checks/structural no-shared-to-global-fallback.ts

98.33% Statements 59/60
92.68% Branches 38/41
100% Functions 7/7
98.07% Lines 51/52

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                                                                                        4x 4x 4x             4x 4x                     7x 7x 7x 257x 257x 252x 12x 12x 240x 5x                                     83x 10x 10x 10x   7x 7x                           6x 6x 6x 83x 83x 76x 76x   7x 5x 5x 5x     7x   6x       5x 5x       16x 17x 6x 6x 5x                       4x 20x 20x 15x 3x     12x 16x   12x 8x               4x                  
/**
 * Rule: shared-tier (`--slds-s-*`) tokens never carry a global-tier
 * (`--slds-g-*`) fallback.
 *
 * Both tiers always carry theme-resolved values: every supported theme
 * is required to supply a value for every shared token, the same way it
 * supplies values for global tokens. That makes a chain of the form
 *
 *   var(--slds-s-foo, var(--slds-g-bar))
 *
 * dead code on the global side: under any active theme, `--slds-s-foo`
 * is defined and the `--slds-g-bar` branch is unreachable. Worse, it
 * misleads readers into thinking the shared layer might be missing,
 * obscures which tier is authoritative for the property, and would
 * silently mask a real bug if a shared token were ever undefined
 * instead of failing loudly.
 *
 * Canonical chains:
 *   - `var(--slds-c-foo, var(--slds-s-foo))` — component → shared, both
 *     theme-resolved.
 *   - `var(--slds-c-foo, var(--slds-g-bar))` — component → global, when
 *     the property has no shared layer.
 * Never: `var(--slds-s-*, var(--slds-g-*))`, in any nesting.
 *
 * Detection is scoped to theme-layer files (role `theme` or `theme-base`,
 * i.e. `themes/<theme>.css` and `themes/base.css`). Legacy paint files
 * (`<component>.css`, role `base`) and auxiliary CSS (role `aux`) are
 * out of scope: they are not part of the RFC 1157 theme-layer
 * architecture and routinely carry legacy `var(--slds-s-*, var(--slds-g-*))`
 * patterns by design until the file is replaced. Flagging them would
 * misreport legacy code as a theme-layer violation.
 *
 * Within theme-layer files, every declaration value is scanned for a
 * `var(--slds-s-...)` whose argument list (after the first comma at
 * depth 1) references any `--slds-g-*`. The common encoded form is a
 * nested `var(--slds-s-foo, var(--slds-g-bar))`, but a literal global
 * token name appearing inside the fallback tail counts the same way.
 */
 
import type { Declaration } from 'postcss';
 
import type { ComplianceCheck, ComplianceRow, ComponentSourceFile, Offender } from '../../types.js';
import { declLocation, failRow, notRunYetRow, passRow, themeLayerFilesFor } from './internals.js';
 
const ID = 'structural-no-shared-to-global-fallback';
const LABEL = 'No `--slds-s-*` → `--slds-g-*` fallback chains';
const CATEGORY = 'customer-reach' as const;
 
interface FoundChain {
  sharedToken: string;
  globalToken: string;
}
 
const SHARED_TOKEN_RE = /^\s*(--slds-s-[A-Za-z0-9_-]+)/;
const GLOBAL_TOKEN_RE = /(--slds-g-[A-Za-z0-9_-]+)/;
 
interface ArgSpan {
  /** Index of the closing `)` that matches the opening `(`, or `-1` if unbalanced. */
  end: number;
  /** Index of the first `,` at depth 1 (the start of the fallback tail), or `-1`. */
  firstComma: number;
}
 
/** Scan from `argStart` (just past `var(`) to the matching close paren. */
function findArgSpan(value: string, argStart: number): ArgSpan {
  let depth = 1;
  let firstComma = -1;
  for (let j = argStart; j < value.length; j++) {
    const ch = value[j];
    if (ch === '(') depth++;
    else if (ch === ')') {
      depth--;
      if (depth === 0) return { end: j, firstComma };
    } else if (ch === ',' && depth === 1 && firstComma === -1) {
      firstComma = j;
    }
  }
  return { end: -1, firstComma };
}
 
interface SharedVarMatch {
  sharedToken: string;
  /** Argument-list tail after the first depth-1 comma, or `null` when none. */
  tail: string | null;
  /** Index just past the matched `var(...)`; advances the outer scanner. */
  nextIndex: number;
}
 
/**
 * If `value` starts a `var(--slds-s-*, …)` expression at index `i`,
 * return the parsed shape. Otherwise `null`.
 */
function matchSharedVarAt(value: string, i: number): SharedVarMatch | null {
  if (value[i] !== 'v' || !value.startsWith('var(', i)) return null;
  const argStart = i + 4;
  const sharedMatch = SHARED_TOKEN_RE.exec(value.slice(argStart));
  if (!sharedMatch) return null;
 
  const { end, firstComma } = findArgSpan(value, argStart);
  return {
    sharedToken: sharedMatch[1],
    tail: end !== -1 && firstComma !== -1 ? value.slice(firstComma + 1, end) : null,
    nextIndex: end === -1 ? value.length : end + 1,
  };
}
 
/**
 * Scan a declaration value for `var(--slds-s-*, ...fallback...)` where
 * the fallback tail references a `--slds-g-*` token. Multiple matches
 * per declaration are returned — a single value can contain more than
 * one offending chain.
 */
function findSharedToGlobalChains(value: string): FoundChain[] {
  const out: FoundChain[] = [];
  let i = 0;
  while (i < value.length) {
    const match = matchSharedVarAt(value, i);
    if (!match) {
      i++;
      continue;
    }
    if (match.tail !== null) {
      const globalMatch = GLOBAL_TOKEN_RE.exec(match.tail);
      Eif (globalMatch) {
        out.push({ sharedToken: match.sharedToken, globalToken: globalMatch[1] });
      }
    }
    i = match.nextIndex;
  }
  return out;
}
 
function selectorOf(decl: Declaration): string {
  const parent = decl.parent;
  return parent?.type === 'rule' ? (parent as { selector: string }).selector : '<root>';
}
 
function collectOffenders(file: ComponentSourceFile, into: Offender[]): void {
  file.root.walkDecls((decl) => {
    if (!decl.value.includes('--slds-s-')) return;
    const chains = findSharedToGlobalChains(decl.value);
    for (const chain of chains) {
      into.push({
        selector: selectorOf(decl),
        hook: chain.sharedToken,
        prop: decl.prop,
        location: declLocation(file, decl),
        note: `\`${chain.sharedToken}\` falls back to \`${chain.globalToken}\``,
        fix: `Drop the \`var(${chain.globalToken})\` fallback. \`${chain.sharedToken}\` is theme-resolved and always defined under any active theme; the global tail is unreachable.`,
      });
    }
  });
}
 
export const noSharedToGlobalFallback: 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 offenders: Offender[] = [];
  for (const file of files) collectOffenders(file, offenders);
 
  if (offenders.length === 0) {
    return passRow(
      ID,
      LABEL,
      'No `var(--slds-s-*, var(--slds-g-*))` chains. Shared and global tokens both carry theme-resolved values, so a global fallback under a shared token is dead code.',
      CATEGORY,
    );
  }
 
  return failRow(
    ID,
    LABEL,
    `${offenders.length} \`var(--slds-s-*, ... --slds-g-* ...)\` chain${offenders.length === 1 ? '' : 's'}. ` +
      `Shared-tier tokens are theme-resolved and always defined under any active theme, so a global-tier fallback under a shared-tier token is dead code that misleads readers about which tier is authoritative. ` +
      `Drop the \`--slds-g-*\` tail and let the shared token stand alone.`,
    offenders,
  );
};