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

92.3% Statements 24/26
86.36% Branches 19/22
100% Functions 3/3
92% Lines 23/25

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                                                                              23x 39x   39x       39x 39x 39x   39x 8x 8x               39x 1x                       27x   27x       27x   27x             27x 108x 22x       27x                         6x   6x 2x 2x                
/**
 * @fds-uif/core - Marker Validation
 *
 * Verifies that no item in an identified array has both `$before` and
 * `$after` positional markers. The merge engine resolves one or the other,
 * but having both is ambiguous (which sibling does this go next to?) and
 * silently picking one would mask author errors.
 */
 
import { UifConflictingMarkersError } from '../errors.js';
import { MARKER_AFTER, MARKER_BEFORE } from '../constants.js';
import { getByPath } from './shared.js';
 
/**
 * Result of marker validation.
 */
export interface MarkerValidationResult {
  /** Whether all markers are valid */
  valid: boolean;
  /** Items with conflicting markers */
  errors: Array<{
    path: string;
    identifier?: string;
    beforeValue: string;
    afterValue: string;
  }>;
}
 
/**
 * Validate an array for items with conflicting `$before`/`$after` markers,
 * recursing into `children` so deeply nested structure entries are also
 * caught.
 */
function checkArrayMarkers(
  arr: unknown[],
  path: string,
  identifierField: string | undefined,
  errors: MarkerValidationResult['errors'],
): void {
  for (let i = 0; i < arr.length; i++) {
    const item = arr[i];
 
    Iif (typeof item !== 'object' || item === null) {
      continue;
    }
 
    const record = item as Record<string, unknown>;
    const hasBefore = typeof record[MARKER_BEFORE] === 'string';
    const hasAfter = typeof record[MARKER_AFTER] === 'string';
 
    if (hasBefore && hasAfter) {
      const identifier = identifierField ? (record[identifierField] as string) : undefined;
      errors.push({
        path: `${path}[${i}]`,
        identifier,
        beforeValue: record[MARKER_BEFORE] as string,
        afterValue: record[MARKER_AFTER] as string,
      });
    }
 
    if (Array.isArray(record.children)) {
      checkArrayMarkers(record.children as unknown[], `${path}[${i}].children`, 'name', errors);
    }
  }
}
 
/**
 * Validate that no items have both `$before` and `$after` markers.
 *
 * @param uif - The UIF definition to validate
 * @returns Validation result
 */
export function validateMarkers(uif: unknown): MarkerValidationResult {
  const errors: MarkerValidationResult['errors'] = [];
 
  Iif (typeof uif !== 'object' || uif === null) {
    return { valid: true, errors: [] };
  }
 
  const record = uif as Record<string, unknown>;
 
  const arraysToCheck = [
    { path: 'states', arr: record.states, field: 'name' },
    { path: 'structure.modifiers', arr: getByPath(record, 'structure.modifiers'), field: 'name' },
    { path: 'structure.children', arr: getByPath(record, 'structure.children'), field: 'name' },
    { path: 'stateClasses', arr: record.stateClasses, field: 'state' },
  ];
 
  for (const { path, arr, field } of arraysToCheck) {
    if (Array.isArray(arr)) {
      checkArrayMarkers(arr, path, field, errors);
    }
  }
 
  return {
    valid: errors.length === 0,
    errors,
  };
}
 
/**
 * Validate markers and throw if conflicts are found.
 *
 * @param uif - The UIF definition to validate
 * @throws {UifConflictingMarkersError} If conflicting markers are found
 */
export function validateMarkersOrThrow(uif: unknown): void {
  const result = validateMarkers(uif);
 
  if (!result.valid && result.errors.length > 0) {
    const firstError = result.errors[0];
    throw new UifConflictingMarkersError(
      firstError.path,
      firstError.identifier,
      firstError.beforeValue,
      firstError.afterValue,
    );
  }
}