All files / packages/sds-stylelint-config/src/validators/component antiPatterns.ts

100% Statements 39/39
100% Branches 22/22
100% Functions 7/7
100% Lines 33/33

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                                                                                      4x               4x     4x     4x   4x     76x 286x   68x     8x               4x     68x 68x   65x 65x 3x     3x   62x 62x 7x 7x 5x     8x               4x     60x 2x     2x                 4x               89x 76x 204x 204x 18x               58x    
/**
 * Component-hook naming anti-patterns rejected by RFC 1157.
 *
 * Detection runs before the structural validator so that hooks following
 * a known-bad shape get a clear rename hint, not a generic
 * `category=...` / `property=...` error from the segment walker.
 *
 * Each detector returns a {@link ValidationError} with a `hint` describing
 * the canonical replacement. Order matters: the first anti-pattern that
 * matches wins (single-finding-per-hook keeps reporting digestible).
 *
 * Coverage:
 *
 *   1. **Property-first color** — RFC 1157 §Naming Doctrine (lines 41-78)
 *      retires `-text-color`, `-bg-color`, `-background-color`,
 *      `-border-color` in favor of category-last forms
 *      (`-color-foreground`, `-color-background`, `-color-border`).
 *
 *   2. **Bare `-height` / `-width`** — RFC 1157 §Sizing requires the
 *      `-sizing-` segment (`--slds-c-foo-sizing-height`). Bare names and
 *      reversed `-min-{width|height}` / `-max-{width|height}` legacy
 *      property shapes are rejected.
 *
 *   3. **Retired `-context-` infix** — early-draft RFC reserved
 *      `--slds-c-{C}-context-{property}` for child-side fallback tiers;
 *      retired in the final RFC.
 *
 * The detector operates on the full hook string (`--slds-c-...`), not on
 * tokenized segments — anti-patterns are most readable matched against
 * the literal name a human typed.
 */
 
import type { ValidationError } from '../../types.js';
 
interface AntiPatternRule {
  /** Stable identifier surfaced as `error.segment`. */
  id: string;
  /** Predicate-and-rename: returns the canonical form, or `null` to skip. */
  rewrite(hook: string): string | null;
  /** Build the rename hint shown to the user. */
  hint(hook: string, canonical: string): string;
}
 
const PROPERTY_FIRST_REPLACEMENTS: Array<[RegExp, string]> = [
  [/-text-color(?=-|$)/, '-color-foreground'],
  [/-background-color(?=-|$)/, '-color-background'],
  [/-bg-color(?=-|$)/, '-color-background'],
  [/-border-color(?=-|$)/, '-color-border'],
];
 
/** Match a `--slds-c-*` hook ending in `-height` / `-width` (with optional `-min` / `-max`). */
const BARE_SIZING_RE = /^(--slds-c-[a-z0-9-]+?)-(height|width)(-min|-max)?$/;
 
/** Match the reversed legacy `-{min|max}-{width|height}` property shape. */
const REVERSED_MINMAX_RE = /^(--slds-c-[a-z0-9-]+?)-(min|max)-(height|width)$/;
 
/** Carveouts for bare `-{height|width}` checks. */
const BARE_SIZING_CARVEOUTS = [/-line-height$/];
 
const PROPERTY_FIRST_COLOR: AntiPatternRule = {
  id: 'naming-property-first-color',
  rewrite(hook) {
    for (const [pattern, replacement] of PROPERTY_FIRST_REPLACEMENTS) {
      if (pattern.test(hook)) return hook.replace(pattern, replacement);
    }
    return null;
  },
  hint(hook, canonical) {
    return (
      `Color hook \`${hook}\` is property-first; rename to \`${canonical}\`. ` +
      `Per RFC 1157, color hook names put the category segment last: ` +
      `\`-color-foreground\`, \`-color-background\`, or \`-color-border\`.`
    );
  },
};
 
const BARE_SIZING: AntiPatternRule = {
  id: 'naming-bare-sizing',
  rewrite(hook) {
    for (const carveout of BARE_SIZING_CARVEOUTS) {
      if (carveout.test(hook)) return null;
    }
    const reversed = REVERSED_MINMAX_RE.exec(hook);
    if (reversed) {
      const [, body, minmax, dim] = reversed;
      // `body` already ends in `-sizing` for `--slds-c-foo-sizing-min-height`;
      // otherwise we need to inject the segment.
      return body.endsWith('-sizing') ? `${body}-${dim}-${minmax}` : `${body}-sizing-${dim}-${minmax}`;
    }
    const m = BARE_SIZING_RE.exec(hook);
    if (!m) return null;
    const [, body, dim, tail] = m;
    if (body.endsWith('-sizing')) return null; // already canonical
    return `${body}-sizing-${dim}${tail ?? ''}`;
  },
  hint(hook, canonical) {
    return (
      `Sizing hook \`${hook}\` is missing the \`-sizing-\` segment; rename to \`${canonical}\`. ` +
      `Per RFC 1157, per-axis physical sizing hooks are \`--slds-c-{C}-[{E}-]sizing-{height|width}\` ` +
      `(with optional terminal \`-min\` / \`-max\`).`
    );
  },
};
 
const RETIRED_CONTEXT_INFIX: AntiPatternRule = {
  id: 'naming-retired-context-infix',
  rewrite(hook) {
    if (!/-context-/.test(hook)) return null;
    return hook.replace(/-context-/, '-');
  },
  hint(hook, canonical) {
    return (
      `Hook \`${hook}\` uses the retired \`-context-\` infix; rename to \`${canonical}\`. ` +
      `RFC 1157 retired the infix; context hooks are now parent-scoped ` +
      `(declared by the parent component on a descendant selector).`
    );
  },
};
 
/** All anti-pattern rules in detection priority. First match wins. */
const RULES: AntiPatternRule[] = [PROPERTY_FIRST_COLOR, BARE_SIZING, RETIRED_CONTEXT_INFIX];
 
/**
 * Return the first matching anti-pattern error for `hook`, or `null` if
 * none of the documented bad shapes apply. The validator runs this
 * before structural parsing.
 */
export function detectAntiPattern(hook: string): ValidationError | null {
  if (!hook.startsWith('--slds-c-') && !hook.startsWith('--slds-s-')) return null;
  for (const rule of RULES) {
    const canonical = rule.rewrite(hook);
    if (canonical) {
      return {
        segment: rule.id,
        expected: [canonical],
        received: hook,
        hint: rule.hint(hook, canonical),
      };
    }
  }
  return null;
}