All files / packages/sds-customization-compliance/src/checks/structural spacing-axis-only-assignment.ts

95.91% Statements 47/49
87.5% Branches 21/24
100% Functions 7/7
97.56% Lines 40/41

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                                                                                              4x 4x 4x           4x                             9x 9x 9x 293x 293x 12x 12x   281x 12x 12x   269x   9x                     12x 12x 12x 15x 15x 15x 9x 5x               12x       5x                         4x 17x 17x 12x         17x 12x 3x   9x 9x 12x 5x     9x 5x             4x                  
/**
 * Rule: theme files (`themes/<theme>.css`) MUST write logical-axis
 * spacing / margin / padding hooks with a single value. Compound
 * `<start> <end>` writes on the axis hook are forbidden because they
 * don't survive `var()` substitution into the longhand reads in
 * `themes/base.css` — the tuple substitutes whole into a property that
 * accepts a single value, hits Invalid-At-Computed-Value-Time, and
 * computes as `unset` (i.e. the initial 0). The bug is silent: the
 * axis property paints correctly, but each direction longhand
 * cancels.
 *
 *   ✓ --slds-c-foo-spacing-inline: var(--slds-g-spacing-4);
 *   ✓ --slds-c-foo-spacing-block:  var(--slds-g-spacing-2);
 *   ✓ --slds-c-foo-spacing-inline-start: var(--slds-g-spacing-6);
 *     --slds-c-foo-spacing-inline-end:   var(--slds-s-foo-spacing);
 *   ✗ --slds-c-foo-spacing-inline: var(--slds-g-spacing-6) var(--slds-s-foo-spacing);
 *
 * For asymmetric defaults, themes write the direction tier directly.
 * Each direction-tier hook substitutes cleanly into its respective
 * longhand read.
 *
 * The customer override surface stays two-tier in both cases. A
 * customer can override `--slds-c-foo-spacing-inline` (single value)
 * to set both sides, or `--slds-c-foo-spacing-inline-start` /
 * `-inline-end` to set one direction. The base-read shape
 * `var(<direction-hook>, var(<axis-hook>))` (see
 * {@link spacingDirectionSyntax}) lets either tier resolve.
 *
 * Severity: `fail` (`customer-reach`). A compound axis write produces
 * a silent visual regression at every consumer; treating it as a
 * cosmetic warning would let the bug ship.
 *
 * Carveouts:
 *   - The `themes/base.css` file is exempt; its read shape is
 *     governed by {@link spacingDirectionSyntax}.
 *   - Hook-write rules guarded by `THEME_LAYER_HOOK_WRITE_CARVEOUTS`
 *     in `internals.ts` (form-state allow-list, BEM modifier
 *     compounds, etc.) still apply: this check fires regardless of
 *     selector context because the violation is the *value shape*,
 *     not where it was authored.
 */
 
import type { Declaration, Rule } from 'postcss';
 
import type { ComplianceCheck, ComplianceRow, ComponentSourceFile, Offender } from '../../types.js';
import { declLocation, failRow, notRunYetRow, passRow, themeLayerFilesFor } from './internals.js';
 
const ID = 'structural-spacing-axis-only-assignment';
const LABEL = 'Theme files write single-value axis spacing hooks (no compound `<start> <end>` writes)';
const CATEGORY = 'customer-reach' as const;
 
/**
 * Axis-tier hook names: `--slds-c-<component>-{spacing|margin}-{inline|block}`
 * with no trailing `-start` / `-end` segment.
 */
const AXIS_HOOK_RE = /^--slds-c-[a-z0-9-]+-(?:spacing|margin)-(?:inline|block)$/i;
 
/**
 * True when a CSS value is a multi-token list at the top level (i.e.
 * a compound `<start> <end>` or longer). We tolerate calc(), var(),
 * and other functions whose internal commas/spaces don't constitute
 * the top-level value list.
 *
 * postcss-value-parser would be the principled tool, but the rule is
 * narrow enough that a token-balance walk over the raw value string
 * captures it. We strip balanced parentheses (which scope every
 * function call's whitespace), then look for any unescaped whitespace
 * in the remainder.
 */
function isCompoundValue(rawValue: string): boolean {
  let depth = 0;
  let stripped = '';
  for (let i = 0; i < rawValue.length; i++) {
    const ch = rawValue[i]!;
    if (ch === '(') {
      depth++;
      continue;
    }
    if (ch === ')') {
      Eif (depth > 0) depth--;
      continue;
    }
    if (depth === 0) stripped += ch;
  }
  return /\S\s+\S/.test(stripped.trim());
}
 
interface AssignOffender {
  selector: string;
  hookName: string;
  rawValue: string;
  location: string;
}
 
function collectAssignViolations(file: ComponentSourceFile): AssignOffender[] {
  const out: AssignOffender[] = [];
  file.root.walkRules((rule: Rule) => {
    rule.walkDecls((decl: Declaration) => {
      const prop = decl.prop;
      Iif (!prop.startsWith('--slds-c-')) return;
      if (!AXIS_HOOK_RE.test(prop)) return;
      if (!isCompoundValue(decl.value)) return;
      out.push({
        selector: rule.selector,
        hookName: prop,
        rawValue: decl.value.trim(),
        location: declLocation(file, decl),
      });
    });
  });
  return out;
}
 
function offenderFrom(violation: AssignOffender, file: ComponentSourceFile): Offender {
  return {
    selector: violation.selector,
    hook: violation.hookName,
    note:
      `${violation.location} — compound axis write \`${violation.rawValue}\` ` +
      `does not survive \`var()\` substitution into the direction-tier reads.`,
    fix:
      `Split into two single-value direction-tier writes: ` +
      `\`${violation.hookName}-start: <start-value>; ${violation.hookName}-end: <end-value>;\`. ` +
      `(file: \`${file.path}\`)`,
  };
}
 
export const spacingAxisOnlyAssignment: ComplianceCheck = (input): ComplianceRow => {
  const files = themeLayerFilesFor(input);
  if (!files) return notRunYetRow(ID, LABEL, CATEGORY);
  Iif (files.length === 0) {
    return passRow(ID, LABEL, 'Component has no theme or theme-base files; nothing to check.', CATEGORY);
  }
  // Only per-theme files (`role: 'theme'`). Base authoring is governed
  // by spacing-direction-syntax.
  const themeFiles = files.filter((f) => f.role === 'theme');
  if (themeFiles.length === 0) {
    return passRow(ID, LABEL, 'Component has no per-theme files; nothing to check.', CATEGORY);
  }
  const offenders: Offender[] = [];
  for (const file of themeFiles) {
    for (const v of collectAssignViolations(file)) {
      offenders.push(offenderFrom(v, file));
    }
  }
  if (offenders.length === 0) {
    return passRow(
      ID,
      LABEL,
      'All theme axis-tier spacing assignments are single-value; no compound writes detected.',
      CATEGORY,
    );
  }
  return failRow(
    ID,
    LABEL,
    `${offenders.length} theme assignment${offenders.length === 1 ? '' : 's'} write a compound \`<start> <end>\` value to a logical-axis spacing hook. ` +
      `The compound substitutes whole into the direction-tier longhand reads in \`themes/base.css\`, hits IACVT, and computes as \`unset\` (0). ` +
      `Split into single-value writes on the direction tier (\`-start\` / \`-end\`) instead.`,
    offenders,
  );
};