All files / scripts generate-article-types-doc.ts

69.38% Statements 34/49
73.17% Branches 30/41
80% Functions 4/5
69.38% Lines 34/49

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                                          1x 1x 1x   1x 1x           6x 6x   6x     6x 1x   5x 53x     53x 1x   52x     52x       4x             3x 3x   3x 39x 39x     3x               4x 4x   4x 1x       3x       3x 3x   3x   3x                                   1x 1x                  
/**
 * @module scripts/generate-article-types-doc
 * @description Reads `analysis/article-types.json` and replaces the content
 *              between `<!-- ARTICLE-TYPES:BEGIN -->` / `<!-- ARTICLE-TYPES:END -->`
 *              sentinels in `Article-Generation.md` with an auto-generated
 *              Markdown table.
 *
 *              Invoked as part of `prebuild` in `package.json`.
 *              Idempotent — running twice produces the same file.
 *              Exits non-zero if the registry fails structural validation.
 *
 * @author Hack23 AB
 * @license Apache-2.0
 */
 
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
 
import type { ArticleTypesRegistry, ArticleTypeEntry } from './horizon-context.js';
 
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const repoRoot = resolve(__dirname, '..');
 
const SENTINEL_BEGIN = '<!-- ARTICLE-TYPES:BEGIN -->';
const SENTINEL_END = '<!-- ARTICLE-TYPES:END -->';
 
/**
 * Load and validate the article-types registry.
 */
export function loadAndValidateRegistry(registryPath: string): ArticleTypesRegistry {
  const raw = readFileSync(registryPath, 'utf8');
  const registry: ArticleTypesRegistry = JSON.parse(raw);
 
  Iif (!registry.version || !registry.types || !Array.isArray(registry.types)) {
    throw new Error('Invalid registry: missing version or types array');
  }
  if (registry.types.length === 0) {
    throw new Error('Invalid registry: types array is empty');
  }
  for (const t of registry.types) {
    Iif (!t.id || !t.family || t.horizonDays == null || t.tierCMultiplier == null) {
      throw new Error(`Invalid registry entry: missing required fields in type "${t.id ?? '(unknown)'}"`);
    }
    if (t.articleWordFloor == null || typeof t.articleWordFloor !== 'number') {
      throw new Error(`Invalid registry entry "${t.id}": articleWordFloor must be a number`);
    }
    Iif (!t.electionCycleAnchor) {
      throw new Error(`Invalid registry entry "${t.id}": electionCycleAnchor is required`);
    }
    Iif (!t.dispatchOnly && !t.cronExpression) {
      throw new Error(`Invalid registry entry "${t.id}": cronExpression required when dispatchOnly is not true`);
    }
  }
  return registry;
}
 
/**
 * Render the article-types Markdown table from the registry.
 */
export function renderTable(types: readonly ArticleTypeEntry[]): string {
  const header = '| id | family | horizonDays | tierCMultiplier | articleWordFloor | electionCycleAnchor | cronExpression |';
  const separator = '|---|---|---|---|---|---|---|';
 
  const rows = types.map((t) => {
    const cron = t.dispatchOnly ? '_dispatch-only_' : `\`${t.cronExpression ?? '—'}\``;
    return `| ${t.id} | ${t.family} | ${t.horizonDays} | ${t.tierCMultiplier} | ${t.articleWordFloor} | ${t.electionCycleAnchor} | ${cron} |`;
  });
 
  return [header, separator, ...rows].join('\n');
}
 
/**
 * Replace content between sentinels in the target document.
 * Returns the updated document content.
 */
export function replaceBetweenSentinels(doc: string, table: string): string {
  const beginIdx = doc.indexOf(SENTINEL_BEGIN);
  const endIdx = doc.indexOf(SENTINEL_END);
 
  if (beginIdx === -1 || endIdx === -1) {
    throw new Error(
      `Sentinels not found in document. Expected "${SENTINEL_BEGIN}" and "${SENTINEL_END}"`,
    );
  }
  Iif (endIdx <= beginIdx) {
    throw new Error('ARTICLE-TYPES:END sentinel appears before ARTICLE-TYPES:BEGIN');
  }
 
  const before = doc.slice(0, beginIdx + SENTINEL_BEGIN.length);
  const after = doc.slice(endIdx);
 
  const warning = '<!-- ⚠️ AUTO-GENERATED from analysis/article-types.json — do NOT edit manually -->';
 
  return `${before}\n${warning}\n\n${table}\n\n${after}`;
}
 
/**
 * Main entry point — generates the table and writes the doc.
 */
export function generate(
  registryPath: string = resolve(repoRoot, 'analysis/article-types.json'),
  docPath: string = resolve(repoRoot, 'Article-Generation.md'),
): void {
  const registry = loadAndValidateRegistry(registryPath);
  const table = renderTable(registry.types);
  const doc = readFileSync(docPath, 'utf8');
  const updated = replaceBetweenSentinels(doc, table);
  writeFileSync(docPath, updated, 'utf8');
}
 
// Run when executed directly
const isMain = process.argv[1] && resolve(process.argv[1]) === __filename;
Iif (isMain) {
  try {
    generate();
    console.log('✅ Article-Generation.md article-types table regenerated.');
  } catch (err: unknown) {
    console.error('❌', (err as Error).message);
    process.exit(1);
  }
}