All files / packages/fds-uif/core/src/validate deprecated-defaults.ts

93.87% Statements 92/98
92.1% Branches 105/114
100% Functions 11/11
98.64% Lines 73/74

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 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212                                                              19x 15x 15x 13x 13x 13x 13x 13x 13x                 19x 12x 12x 10x 9x 8x 7x 6x 5x                     19x                         19x 19x 19x                 23x 19x               23x 24x 24x 24x 23x 23x   4x                             19x 19x 19x 19x                           23x 23x 23x 23x 23x 23x 23x   22x 22x 22x                 3x 5x 5x 5x 5x 5x 5x   1x 1x 1x         32x 32x   32x 23x   32x 3x   32x 2x 3x 3x 3x                           37x 35x 35x   35x 35x 29x     35x    
/**
 * @fds-uif/core - Deprecated Default Validation
 *
 * Verifies that a modifier's or variant's `default` does not point at a
 * deprecated option. A deprecated default is one of the easiest UIF
 * mistakes to ship by accident: the deprecation metadata says "stop using
 * this", but the default still steers every consumer into it.
 *
 * Severity follows the deprecation's `severity` field:
 * - `severity: 'error'` on the option propagates as an error.
 * - Anything else (including the default `'warning'`) is a warning.
 *
 * The replacement formatter is consciously verbose because each
 * combination of `{component, prop, value}` produces a different natural
 * phrase, and a single template would read awkwardly.
 */
 
import type { PlainObject, ValidationError } from '../types.js';
import { ValidationErrorCode } from './shared.js';
 
// ============================================================================
// Replacement metadata helpers
// ============================================================================
 
interface ReplacementFields {
  component?: string;
  prop?: string;
  value?: string;
}
 
function getReplacementFields(dep: unknown): ReplacementFields | undefined {
  if (typeof dep !== 'object' || dep === null || !('replacement' in dep)) return undefined;
  const r = (dep as { replacement?: unknown }).replacement;
  if (typeof r !== 'object' || r === null || Array.isArray(r)) return undefined;
  const rec = r as Record<string, unknown>;
  const fields: ReplacementFields = {};
  if (typeof rec.component === 'string') fields.component = rec.component;
  if (typeof rec.prop === 'string') fields.prop = rec.prop;
  if (typeof rec.value === 'string') fields.value = rec.value;
  return fields.component || fields.prop || fields.value ? fields : undefined;
}
 
/**
 * Build a human-readable phrase from a structured replacement, suitable
 * for appending to a "X is deprecated. Use ___ instead." message. Returns
 * `undefined` when no replacement is set.
 */
function formatReplacement(fields: ReplacementFields | undefined): string | undefined {
  if (!fields) return undefined;
  const { component, prop, value } = fields;
  if (component && prop && value) return `"${value}" on ${component}'s "${prop}" prop`;
  if (component && prop) return `${component}'s "${prop}" prop`;
  if (component && value) return `"${value}" on ${component}`;
  if (component) return `the ${component} component`;
  if (prop && value) return `"${value}" on the "${prop}" prop`;
  if (prop) return `the "${prop}" prop`;
  Eif (value) return `"${value}"`;
  return undefined;
}
 
/**
 * The single most-specific identifier from a structured replacement, used
 * for the {@link ValidationError.suggestion} field which downstream "did
 * you mean?" UIs surface as a quick hint. Prefers `value` > `prop` >
 * `component`.
 */
function getReplacementSuggestion(fields: ReplacementFields | undefined): string | undefined {
  return fields?.value ?? fields?.prop ?? fields?.component;
}
 
// ============================================================================
// Deprecation lifecycle helpers
// ============================================================================
 
/**
 * Map the deprecation's `severity` field to a {@link ValidationError.severity}
 * level. `'error'` escalates urgent deprecations; anything else (including
 * the default `'warning'` or an unset severity) is `'warning'`.
 */
function getDeprecatedDiagnosticSeverity(dep: unknown): 'error' | 'warning' {
  Iif (typeof dep !== 'object' || dep === null) return 'warning';
  const record = dep as { severity?: unknown };
  return record.severity === 'error' ? 'error' : 'warning';
}
 
/**
 * Returns the deprecated metadata object if present and well-formed,
 * otherwise `undefined`. The boolean shorthand (`deprecated: true`) is
 * not accepted: `deprecated` must be an object with at least a `message`.
 */
function getDeprecatedObject(value: unknown): Record<string, unknown> | undefined {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
  return value as Record<string, unknown>;
}
 
function findDeprecatedOption(
  options: unknown[],
  defaultVal: string,
  matchKey: 'propValue' | 'value',
): Record<string, unknown> | undefined {
  for (const optRaw of options) {
    Iif (typeof optRaw !== 'object' || optRaw === null) continue;
    const opt = optRaw as Record<string, unknown>;
    if (opt[matchKey] !== defaultVal) continue;
    const dep = getDeprecatedObject(opt.deprecated);
    if (dep) return dep;
  }
  return undefined;
}
 
// ============================================================================
// Walk + emit
// ============================================================================
 
function emitDeprecatedDefaultWarning(
  kind: 'Modifier' | 'Variant',
  name: string,
  defaultVal: string,
  deprecated: Record<string, unknown>,
  path: string,
  warnings: ValidationError[],
): void {
  const replacementFields = getReplacementFields(deprecated);
  const replacementPhrase = formatReplacement(replacementFields);
  const suffix = replacementPhrase ? ` Use ${replacementPhrase} instead.` : '';
  warnings.push({
    code: ValidationErrorCode.DEPRECATED_DEFAULT,
    message: `${kind} "${name}" default "${defaultVal}" is deprecated.${suffix}`,
    path,
    severity: getDeprecatedDiagnosticSeverity(deprecated),
    suggestion: getReplacementSuggestion(replacementFields),
  });
}
 
function checkModifiersForDeprecatedDefaults(
  modifiers: unknown[],
  path: string,
  warnings: ValidationError[],
): void {
  for (const raw of modifiers) {
    Iif (typeof raw !== 'object' || raw === null) continue;
    const mod = raw as PlainObject;
    const name = mod.name as string | undefined;
    const defaultVal = mod.default as string | undefined;
    const options = mod.options as unknown[] | undefined;
    if (!name || !defaultVal || !Array.isArray(options)) continue;
 
    const dep = findDeprecatedOption(options, defaultVal, 'propValue');
    if (dep)
      emitDeprecatedDefaultWarning('Modifier', name, defaultVal, dep, `${path}.modifiers[${name}]`, warnings);
  }
}
 
function checkVariantsForDeprecatedDefaults(
  variants: unknown[],
  path: string,
  warnings: ValidationError[],
): void {
  for (const raw of variants) {
    Iif (typeof raw !== 'object' || raw === null) continue;
    const v = raw as PlainObject;
    const name = v.name as string | undefined;
    const defaultVal = v.default as string | undefined;
    const options = v.options as unknown[] | undefined;
    if (!name || !defaultVal || !Array.isArray(options)) continue;
 
    const dep = findDeprecatedOption(options, defaultVal, 'value');
    Eif (dep)
      emitDeprecatedDefaultWarning('Variant', name, defaultVal, dep, `${path}.variants[${name}]`, warnings);
  }
}
 
function walkStructureForDeprecatedDefaults(node: unknown, path: string, warnings: ValidationError[]): void {
  Iif (typeof node !== 'object' || node === null) return;
  const obj = node as PlainObject;
 
  if (Array.isArray(obj.modifiers)) {
    checkModifiersForDeprecatedDefaults(obj.modifiers, path, warnings);
  }
  if (Array.isArray(obj.variants)) {
    checkVariantsForDeprecatedDefaults(obj.variants, path, warnings);
  }
  if (Array.isArray(obj.children)) {
    for (const child of obj.children) {
      Eif (typeof child === 'object' && child !== null) {
        const childName = (child as PlainObject).name as string | undefined;
        walkStructureForDeprecatedDefaults(child, `${path}.children[${childName ?? '?'}]`, warnings);
      }
    }
  }
}
 
/**
 * Validate that modifier and variant defaults do not point to deprecated
 * options. Returns warnings (not errors) since deprecated defaults are
 * not invalid, just inadvisable; severity is escalated to `'error'` per
 * {@link getDeprecatedDiagnosticSeverity} when the option's deprecation
 * sets `severity: 'error'`.
 */
export function validateDeprecatedDefaults(uif: unknown): ValidationError[] {
  if (typeof uif !== 'object' || uif === null) return [];
  const obj = uif as PlainObject;
  const warnings: ValidationError[] = [];
 
  const structure = obj.structure as PlainObject | undefined;
  if (structure) {
    walkStructureForDeprecatedDefaults(structure, 'structure', warnings);
  }
 
  return warnings;
}