All files / packages/sds-customization-compliance/src/checks border-color-fallback-transparent.ts

94.11% Statements 48/51
73.8% Branches 31/42
100% Functions 10/10
97.67% Lines 42/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 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187                                                                4x   4x 4x                 4x     11x         5x                       6x 2x 4x 4x   2x                             2x         2x 2x 2x 2x   2x 3x 2x 2x         2x                 2x 2x                   8x 8x 8x 11x 11x 5x 5x 2x     8x     4x 14x                 6x 6x                 8x   8x 6x                 2x 1x                 1x                  
/**
 * Compliance row: `border-color` declarations end with `, transparent`.
 *
 * Theme-layer `base.css` paints borders via
 *
 *   border-color: var(--slds-c-<comp>-color-border, transparent);
 *
 * The `, transparent` terminal matters: if no theme binds the hook (or
 * the selector isn't covered), CSS falls back to the `border-color`
 * initial value, which is `currentColor`. A button with `color: blue`
 * would suddenly render a blue border; an element with no `color` gets
 * a 1px black border sitting on top of the design. Authors always want
 * an explicit `transparent` terminal.
 *
 * This check walks every long-hand border-color declaration in the
 * component's `themes/base.css` (surfaced through
 * {@link ComponentCheckInput.baseDeclsBySelector}) and flags any whose
 * `var()` chain doesn't terminate in the literal `transparent`.
 * Shorthand `border:` declarations are not considered — SLDS2 paints
 * border-color explicitly, and shorthand parsing would add noise for
 * zero real coverage.
 *
 * When `baseDeclsBySelector` isn't supplied (browser / themeData-only
 * paths) the check degrades to `info` rather than silently passing —
 * the rule is objectively verifiable and we'd rather say "I didn't
 * run" than emit a false green.
 */
 
import valueParser from 'postcss-value-parser';
import type { BaseDecl, ComplianceCheck, ComplianceRow, Offender } from '../types.js';
import { NOT_MIGRATED_DETAIL } from './helpers/themeDataAccess.js';
 
const LABEL = '`border-color` declarations end with `, transparent`';
const BROWSER_PATH_DETAIL =
  'Base-file declarations are only available in the CLI build. Run `sds-compliance build` to evaluate this check.';
const MAX_OFFENDERS = 10;
 
/**
 * Matches the CSS long-hand border-color properties the rule applies to.
 * Covers the physical sides (`border-top-color`, etc.) and the logical
 * block/inline forms. `border-color` with no side segment matches too.
 * Shorthand `border*` (without `-color`) is intentionally excluded.
 */
const BORDER_COLOR_PROP_RE =
  /^border(?:-(?:top|right|bottom|left|block-start|block-end|block|inline-start|inline-end|inline))?-color$/;
 
function isBorderColorProp(prop: string): boolean {
  return BORDER_COLOR_PROP_RE.test(prop);
}
 
/** A decl is compliant when its outermost var() chain terminates in `transparent`. */
function isCompliantBorderColor(decl: BaseDecl): boolean {
  return decl.terminalFallback === 'transparent';
}
 
type ValueParserNode = {
  type: string;
  value: string;
  nodes?: ValueParserNode[];
  before?: string;
  after?: string;
};
 
function findInnermostVar(node: ValueParserNode): ValueParserNode | null {
  if (node.type !== 'function' || node.value !== 'var') return null;
  for (const child of node.nodes ?? []) {
    const deeper = findInnermostVar(child);
    Iif (deeper) return deeper;
  }
  return node;
}
 
/**
 * Inject `, transparent` at the terminal of the outermost `var()`
 * chain. Examples:
 *
 *   var(--x)               →  var(--x, transparent)
 *   var(--x, var(--y))     →  var(--x, var(--y, transparent))
 *   var(--x, currentColor) →  var(--x, transparent)   (replace bad fallback)
 *
 * Parses with `postcss-value-parser`, finds the innermost `var()` node
 * in the chain, replaces (or appends) its fallback with `transparent`.
 */
function formatLocation(decl: BaseDecl): string | undefined {
  Eif (!decl.source) return undefined;
  return decl.line ? `${decl.source}:${decl.line}` : decl.source;
}
 
function transparentTerminalFix(rawValue: string): string {
  const tree = valueParser(rawValue.trim()) as { nodes: ValueParserNode[]; toString: () => string };
  const top = tree.nodes.find((n) => n.type === 'function' && n.value === 'var') ?? tree.nodes[0];
  const innermost = top ? findInnermostVar(top) : null;
  Iif (!innermost) return `${rawValue.trim()}, transparent`;
 
  const children = innermost.nodes ?? [];
  const divIndex = children.findIndex((n) => n.type === 'div' && n.value === ',');
  const customProp = divIndex === -1 ? children : children.slice(0, divIndex);
  innermost.nodes = [
    ...customProp,
    { type: 'div', value: ',', before: '', after: ' ' },
    { type: 'word', value: 'transparent' },
  ];
  return tree.toString();
}
 
interface AuditTally {
  audited: number;
  offenders: Offender[];
}
 
function buildOffender(selector: string, decl: BaseDecl): Offender {
  const suggested = transparentTerminalFix(decl.rawValue);
  return {
    selector,
    prop: decl.prop,
    location: formatLocation(decl),
    note: decl.rawValue,
    fix: `Rewrite as \`${decl.prop}: ${suggested};\` so an unset hook paints \`transparent\` instead of \`currentColor\`.`,
  };
}
 
function tallyBorderColorDecls(bySelector: Record<string, BaseDecl[]>): AuditTally {
  const offenders: Offender[] = [];
  let audited = 0;
  for (const [selector, decls] of Object.entries(bySelector)) {
    for (const decl of decls) {
      if (!isBorderColorProp(decl.prop)) continue;
      audited += 1;
      if (isCompliantBorderColor(decl)) continue;
      offenders.push(buildOffender(selector, decl));
    }
  }
  return { audited, offenders };
}
 
export const borderColorFallbackTransparent: ComplianceCheck = (input): ComplianceRow => {
  if (!input.baseDeclsBySelector) {
    // No base-file declarations. Two very different reasons to get here:
    //   1. The component has not been migrated yet (no `themes/base.css`
    //      exists). Signal: themeData is also absent.
    //   2. The check is running in the browser audit path, which currently
    //      doesn't ship base-file declarations over the wire. Signal:
    //      themeData is present.
    // Emit different messages so readers know whether they need to rerun
    // the CLI or whether the component simply has nothing to audit yet.
    const notMigrated = !input.themeData;
    return {
      id: 'border-color-fallback-transparent',
      label: LABEL,
      status: 'info',
      count: 0,
      detail: notMigrated ? NOT_MIGRATED_DETAIL : BROWSER_PATH_DETAIL,
    };
  }
 
  const { audited, offenders } = tallyBorderColorDecls(input.baseDeclsBySelector);
 
  if (audited === 0) {
    return {
      id: 'border-color-fallback-transparent',
      label: LABEL,
      status: 'pass',
      count: 0,
      detail: 'No `border-color` declarations found in the base file — nothing to verify.',
    };
  }
 
  if (offenders.length === 0) {
    return {
      id: 'border-color-fallback-transparent',
      label: LABEL,
      status: 'pass',
      count: 0,
      detail: `All ${audited} \`border-color\` declaration${audited === 1 ? ' terminates' : 's terminate'} in \`, transparent\`.`,
    };
  }
 
  return {
    id: 'border-color-fallback-transparent',
    label: LABEL,
    status: 'fail',
    count: offenders.length,
    detail: `${offenders.length} of ${audited} \`border-color\` declaration${audited === 1 ? '' : 's'} do not terminate in \`, transparent\`. Without the fallback, unset hooks paint \`currentColor\` (a visible default border) instead of nothing.`,
    offenders: offenders.slice(0, MAX_OFFENDERS),
  };
};