All files / src/aggregator/manifest manifest-writer.ts

100% Statements 19/19
100% Branches 20/20
100% Functions 2/2
100% Lines 16/16

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                                                                          29x 27x 27x   18x 18x 29x   29x                                                                     17x   15x 15x   15x       7x 2x 2x   5x     8x    
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
 
/**
 * @module Aggregator/Manifest/Writer
 * @description Helpers that mutate or enrich an in-memory {@link Manifest}
 * before it is serialised to `manifest.json`. The current writer surface is
 * intentionally narrow — it only owns the {@link HorizonProfile} bucket
 * derived from the canonical article-horizons registry. Workflows still
 * write the rest of the manifest (articleType, runId, history) directly
 * through `mergeManifestHistory` and equivalent shell helpers.
 */
 
import { getHorizonConfig } from '../../config/article-horizons.js';
import { resolveArticleType } from './resolver.js';
import type { HorizonProfile, Manifest } from './types.js';
 
/**
 * Build a {@link HorizonProfile} for the given article-type slug from the
 * canonical {@link import('../../config/article-horizons.js').ARTICLE_HORIZONS}
 * registry.
 *
 * `horizonDays` derivation:
 *  - `forward` / `backward` → `dataWindow.days`
 *  - `span` / `point`        → `forwardStatementsHorizonDays` (covers
 *    `election-cycle` → 1825, `breaking` → 0, etc.)
 *  - When `dataWindow.days` is absent (e.g. `point`) the
 *    `forwardStatementsHorizonDays` fallback applies regardless of direction.
 *
 * @param articleType - Article-type slug (e.g. `month-ahead`,
 *                      `election-cycle`). Legacy / unknown slugs return
 *                      `undefined` so the manifest writer treats them as
 *                      no-ops.
 * @returns The matching {@link HorizonProfile}, or `undefined` when the
 *          slug does not resolve to a registry entry.
 */
export function buildHorizonProfile(articleType: string | undefined): HorizonProfile | undefined {
  if (!articleType) return undefined;
  const cfg = getHorizonConfig(articleType);
  if (!cfg) return undefined;
 
  const { direction, days } = cfg.dataWindow;
  const useFallback = direction === 'span' || direction === 'point' || days === undefined;
  const horizonDays = useFallback ? cfg.forwardStatementsHorizonDays : days;
 
  return Object.freeze({
    horizonDays,
    electoralOverlay: cfg.electoralOverlay,
  });
}
 
/**
 * Return a copy of the manifest with `horizonProfile` populated from the
 * article-horizons registry.
 *
 * Behaviour matrix:
 *   - Slug resolves to a registry entry → `horizonProfile` is set from
 *     {@link buildHorizonProfile}.
 *   - Slug is legacy / unknown (no registry entry) AND `overwrite` is
 *     `true` → any existing `horizonProfile` is **stripped** so the
 *     "absent for unknown slugs" invariant holds even when the registry
 *     evolves (e.g. a slug is removed) or a manifest carries a stale
 *     value from a previous registry version.
 *   - Slug is legacy / unknown AND `overwrite` is `false` → no-op.
 *   - An existing `horizonProfile` is present AND `overwrite` is `false`
 *     → no-op (forward-compat: respect a manifest-supplied value).
 *
 * The function is pure — the input manifest is never mutated.
 *
 * @param manifest - Manifest to enrich.
 * @param options - Behaviour options.
 * @param options.overwrite - When `true`, replaces (or strips) any
 *                            existing `horizonProfile`. Default `false`.
 * @returns A new manifest with `horizonProfile` set or removed, or the
 *          original manifest reference when no change applies.
 */
export function applyHorizonProfile(
  manifest: Manifest,
  options: { readonly overwrite?: boolean } = {}
): Manifest {
  if (manifest.horizonProfile && !options.overwrite) return manifest;
 
  const articleType = resolveArticleType(manifest);
  const profile = buildHorizonProfile(articleType);
 
  if (!profile) {
    // Slug is legacy / unknown. With overwrite=true we must actively
    // strip any stale `horizonProfile` to honour the documented
    // "absent for unknown slugs" invariant.
    if (options.overwrite && manifest.horizonProfile) {
      const { horizonProfile: _stale, ...rest } = manifest;
      return rest as Manifest;
    }
    return manifest;
  }
 
  return { ...manifest, horizonProfile: profile };
}