All files / src/aggregator/manifest resolver.ts

100% Statements 41/41
100% Branches 38/38
100% Functions 7/7
100% Lines 36/36

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                              6x                               660x 428x   232x 660x 215x   17x 11x   6x                         631x 394x   237x 117x   120x                       610x 610x                     29x 29x 27x 27x 27x   9x                     65x 70x   5x 2x   3x                             31x 25x 25x 25x 65x 72x 68x 68x     25x    
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
 
/**
 * @module Aggregator/Manifest/Resolver
 * @description Pure resolution helpers over a parsed {@link Manifest}.
 * Consolidates the article-type precedence ladder (`articleType` →
 * `articleTypes[0]` → `runType`), the latest-gate-result lookup, and the
 * `manifest.files` flattener so they live in one bounded context instead of
 * being duplicated across `analysis-aggregator.ts` and `article-generator.ts`.
 */
 
import type { Manifest, ManifestFiles } from './types.js';
 
/** Sentinel used when no schema variant supplies a usable article type. */
export const UNKNOWN_ARTICLE_TYPE = 'unknown';
 
/**
 * Resolve the article-type slug from a manifest, tolerating legacy schemas.
 *
 * Resolution order (highest precedence first):
 *   1. `articleType` — canonical singular field
 *   2. `articleTypes[0]` — pre-aggregator-pipeline plural array
 *   3. `runType` — legacy field on older breaking-run manifests
 *
 * Falls back to `'unknown'` when none of the above is a non-empty string.
 *
 * @param manifest - Parsed manifest (any of the supported schemas)
 * @returns Article-type slug usable as a filename component
 */
export function resolveArticleType(manifest: Manifest): string {
  if (typeof manifest.articleType === 'string' && manifest.articleType) {
    return manifest.articleType;
  }
  const first = manifest.articleTypes?.[0];
  if (typeof first === 'string' && first) {
    return first;
  }
  if (typeof manifest.runType === 'string' && manifest.runType) {
    return manifest.runType;
  }
  return UNKNOWN_ARTICLE_TYPE;
}
 
/**
 * Resolve the run-id from a manifest, falling back to a caller-provided
 * default (typically the run-directory basename) when the manifest carries
 * neither a string nor a numeric `runId`.
 *
 * @param manifest - Parsed manifest
 * @param fallback - Default returned when `runId` is missing or non-string
 * @returns Best-effort run identifier
 */
export function resolveRunId(manifest: Manifest, fallback: string): string {
  if (typeof manifest.runId === 'string' && manifest.runId) {
    return manifest.runId;
  }
  if (typeof manifest.runId === 'number') {
    return String(manifest.runId);
  }
  return fallback;
}
 
/**
 * Resolve the ISO date for a manifest, accepting only a strictly-formed
 * `YYYY-MM-DD` value. Returns `undefined` when the manifest has no usable
 * date so callers can fall through to a path-based heuristic.
 *
 * @param manifest - Parsed manifest
 * @returns Strict ISO date or `undefined`
 */
export function resolveDate(manifest: Manifest): string | undefined {
  const candidate = typeof manifest.date === 'string' ? manifest.date : '';
  return /^\d{4}-\d{2}-\d{2}$/.test(candidate) ? candidate : undefined;
}
 
/**
 * Pick the latest non-`PENDING` `gateResult` from `manifest.history[]`,
 * falling back to `'PENDING'` when no closed gate is recorded.
 *
 * @param manifest - Parsed manifest
 * @returns Latest non-PENDING gate result, or `'PENDING'`
 */
export function latestGateResult(manifest: Manifest): string {
  const history = manifest.history ?? [];
  for (let i = history.length - 1; i >= 0; i--) {
    const entry = history[i];
    const gr = entry?.gateResult;
    if (gr && gr !== 'PENDING') return gr;
  }
  return 'PENDING';
}
 
/**
 * Extract every string entry from a single `files` value (which may be an
 * array of strings or a `path → description` object).
 *
 * @param value - One value from `Object.values(files)`
 * @returns Strings contained within, or `[]` when the shape is unknown
 */
function extractFileEntries(value: unknown): string[] {
  if (Array.isArray(value)) {
    return value.filter((e): e is string => typeof e === 'string');
  }
  if (value && typeof value === 'object') {
    return Object.keys(value as Record<string, unknown>);
  }
  return [];
}
 
/**
 * Normalise `manifest.files` into a flat list of `runRelPath` strings.
 *
 * De-duplicates while preserving first-seen order so callers downstream
 * (the aggregator's `availableSet`, `materialiseManifestFiles`, etc.)
 * never observe the same path twice when a manifest section accidentally
 * lists it under two top-level keys.
 *
 * @param files - Manifest `files` section (nested or flat)
 * @returns De-duplicated, first-seen-ordered list of run-relative artifact paths
 */
export function flattenManifestFiles(files: ManifestFiles | undefined): string[] {
  if (!files) return [];
  const seen = new Set<string>();
  const out: string[] = [];
  for (const value of Object.values(files)) {
    for (const entry of extractFileEntries(value)) {
      if (seen.has(entry)) continue;
      seen.add(entry);
      out.push(entry);
    }
  }
  return out;
}