Ctrl K
ComunidadGuías…

application-answers.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node

import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
import { isMainModule } from './lib/is-main-module.mjs';
import { localToday } from './lib/local-today.mjs';

export const APPLICATION_ANSWERS_HEADING = '## Application Answers';

const VALID_STATES = new Set(['filled', 'submitted']);
// A level-1/2 Markdown heading closes this section, including tabs, up to
// three leading spaces, and empty headings. Level-3 groups stay inside it.
const ANSWERS_SECTION_END_RE = /^ {0,3}#{1,2}(?:[ \t]+|$)/;

function findUnfencedHeading(text, pattern) {
  let fence = null;
  for (const line of text.matchAll(/^.*$/gm)) {
    if (fence) {
      const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line[0]);
      if (close && close[1][0] === fence[0] && close[1].length >= fence.length) fence = null;
      continue;
    }
    const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line[0]);
    if (open && !(open[1][0] === '`' && open[2].includes('`'))) {
      fence = open[1];
      continue;
    }
    if (pattern.test(line[0])) return line;
  }
  return null;
}

function inline(value) {
  return String(value ?? '').replace(/\s+/g, ' ').trim();
}

function valueText(value) {
  if (Array.isArray(value)) return value.map(inline).filter(Boolean).join(', ');
  return String(value ?? '').trim();
}

function pick(object, keys) {
  for (const key of keys) {
    const value = object?.[key];
    if (Array.isArray(value)) {
      if (value.length > 0) return value;
      continue;
    }
    if (value !== undefined && value !== null && String(value).trim()) return value;
  }
  return '';
}

function list(value) {
  return Array.isArray(value) ? value : [];
}

function normalizeState(state) {
  const normalized = inline(state || 'filled').toLowerCase();
  if (!VALID_STATES.has(normalized)) {
    throw new Error(`Application answer state must be one of: ${[...VALID_STATES].join(', ')}`);
  }
  return normalized;
}

function normalizeDate(date) {
  // The LOCAL calendar day when the caller supplies none. This date is written
  // into the report's Application Answers section as the day the user answered
  // the form, and it sits beside tracker rows dated with localToday() by
  // set-status.mjs -- a UTC day put an evening answer a day ahead of the
  // application it belongs to.
  return inline(date || localToday());
}

function quoteBlock(value) {
  const text = String(value ?? '').replace(/\r\n/g, '\n').trim();
  if (!text) return '> Not recorded.';
  return text.split('\n').map((line) => `> ${line}`).join('\n');
}

function qaLines(entries, { labelKeys, valueKeys, fallback }) {
  if (entries.length === 0) return ['- None captured.'];

  return entries.flatMap((entry, index) => {
    const label = inline(pick(entry, labelKeys)) || `${fallback} ${index + 1}`;
    const answer = pick(entry, valueKeys);
    return [
      `${index + 1}. **${label}**`,
      '',
      quoteBlock(answer),
      '',
    ];
  }).slice(0, -1);
}

function compactLines(entries, { labelKeys, valueKeys, fallback }) {
  if (entries.length === 0) return ['- None captured.'];

  return entries.map((entry, index) => {
    const label = inline(pick(entry, labelKeys)) || `${fallback} ${index + 1}`;
    const value = valueText(pick(entry, valueKeys)) || 'Not recorded';
    return `${index + 1}. **${label}:** ${value}`;
  });
}

function fileLines(entries) {
  if (entries.length === 0) return ['- None captured.'];

  return entries.map((entry, index) => {
    const label = inline(pick(entry, ['field', 'name', 'label', 'type'])) || `File ${index + 1}`;
    const file = inline(pick(entry, ['path', 'file', 'filename', 'url'])) || 'Not recorded';
    const version = inline(pick(entry, ['version', 'variant']));
    return `${index + 1}. **${label}:** ${version ? `${file} (${version})` : file}`;
  });
}

export function normalizeApplicationAnswersSnapshot(snapshot = {}) {
  return {
    date: normalizeDate(snapshot.date),
    state: normalizeState(snapshot.state),
    freeText: list(snapshot.freeText ?? snapshot.freeTextAnswers ?? snapshot.answers),
    selections: list(snapshot.selections ?? snapshot.selectedOptions),
    fieldValues: list(snapshot.fieldValues ?? snapshot.otherFields ?? snapshot.fields),
    files: list(snapshot.files ?? snapshot.uploads ?? snapshot.filesUsed),
  };
}

export function formatApplicationAnswersSection(snapshot = {}) {
  const normalized = normalizeApplicationAnswersSnapshot(snapshot);
  const lines = [
    APPLICATION_ANSWERS_HEADING,
    '',
    `**Date:** ${normalized.date}`,
    `**State:** ${normalized.state}`,
    '',
    '### Free-text answers',
    '',
    ...qaLines(normalized.freeText, {
      labelKeys: ['question', 'field', 'label', 'prompt'],
      valueKeys: ['answer', 'response', 'value', 'text'],
      fallback: 'Answer',
    }),
    '',
    '### Selections made',
    '',
    ...compactLines(normalized.selections, {
      labelKeys: ['question', 'field', 'label', 'prompt'],
      valueKeys: ['selection', 'selected', 'answer', 'value', 'options'],
      fallback: 'Selection',
    }),
    '',
    '### Other field values',
    '',
    ...compactLines(normalized.fieldValues, {
      labelKeys: ['question', 'field', 'label', 'prompt'],
      valueKeys: ['answer', 'response', 'value', 'text'],
      fallback: 'Field',
    }),
    '',
    '### Files used',
    '',
    ...fileLines(normalized.files),
  ];

  return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
}

// ---------------------------------------------------------------------------
// Reader. The formatter above has been write-only since it shipped: nothing in
// the tree could read a rendered section back, so `modes/apply.md` recovers
// previous answers by grepping reports for a company name. This closes that
// asymmetry so answers become addressable data, the way `contacts.mjs` and
// `assessment-log.mjs` already own both directions of their own formats.
//
// One property is deliberately NOT claimed: byte-equality with the input
// snapshot. The formatter is lossy by design -- `inline()` collapses
// whitespace in labels, `valueText()` joins arrays with ', ', `pick()` discards
// which of the four accepted key spellings was used, and empty values become
// the sentinels 'Not recorded' / '> Not recorded.'. What IS guaranteed, and
// what the tests pin, is that rendering is a fixed point after one pass:
//   parse(format(x)) === parse(format(parse(format(x))))
// ---------------------------------------------------------------------------

const FREE_TEXT_HEADING = '### Free-text answers';
const SELECTIONS_HEADING = '### Selections made';
const FIELD_VALUES_HEADING = '### Other field values';
const FILES_HEADING = '### Files used';

const NONE_CAPTURED = '- None captured.';
const NOT_RECORDED_BLOCK = 'Not recorded.';
const NOT_RECORDED_INLINE = 'Not recorded';

/** Split a rendered section body into its four `###` groups. */
function sliceGroups(body) {
  const order = [
    ['freeText', FREE_TEXT_HEADING],
    ['selections', SELECTIONS_HEADING],
    ['fieldValues', FIELD_VALUES_HEADING],
    ['files', FILES_HEADING],
  ];
  const groups = { freeText: '', selections: '', fieldValues: '', files: '' };

  const marks = order
    .map(([key, heading]) => {
      const re = new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm');
      const hit = re.exec(body);
      return hit ? { key, start: hit.index, end: hit.index + hit[0].length } : null;
    })
    .filter(Boolean)
    .sort((a, b) => a.start - b.start);

  marks.forEach((mark, i) => {
    const stop = i + 1 < marks.length ? marks[i + 1].start : body.length;
    groups[mark.key] = body.slice(mark.end, stop).trim();
  });

  return groups;
}

/** `1. **Label**` followed by a `>` quote block. */
function parseQaEntries(block, labelKey, valueKey, onSkip) {
  if (!block || block === NONE_CAPTURED) return [];
  const lines = block.split('\n');
  const entries = [];
  let current = null;
  let quoted = [];

  const flush = () => {
    if (!current) return;
    const text = quoted.join('\n').trim();
    entries.push({
      [labelKey]: current,
      [valueKey]: text === NOT_RECORDED_BLOCK ? '' : text,
    });
    current = null;
    quoted = [];
  };

  for (const line of lines) {
    const head = /^\d+\.\s+\*\*(.*)\*\*\s*$/.exec(line);
    if (head) {
      flush();
      current = head[1].trim();
      continue;
    }
    if (current !== null && /^>/.test(line)) {
      quoted.push(line.replace(/^>\s?/, ''));
      continue;
    }
    // Anything else is unreadable: a heading that lost its numbering, or a
    // quote line with no heading to own it. The second case is the dangerous
    // one — those lines are either dropped (no current entry) or absorbed into
    // the PREVIOUS answer, which corrupts an answer the user really did give.
    if (line.trim()) onSkip?.(line);
  }
  flush();
  return entries;
}

/** `1. **Label:** value` on one line. */
function parseCompactEntries(block, labelKey, valueKey, onSkip) {
  if (!block || block === NONE_CAPTURED) return [];
  const entries = [];
  for (const line of block.split('\n')) {
    const hit = /^\d+\.\s+\*\*(.+):\*\*\s*(.*)$/.exec(line);
    if (!hit) { if (line.trim()) onSkip?.(line); continue; }
    const value = hit[2].trim();
    entries.push({
      [labelKey]: hit[1].trim(),
      [valueKey]: value === NOT_RECORDED_INLINE ? '' : value,
    });
  }
  return entries;
}

/** `1. **Label:** path` or `1. **Label:** path (version)`. */
function parseFileEntries(block, onSkip) {
  if (!block || block === NONE_CAPTURED) return [];
  const entries = [];
  for (const line of block.split('\n')) {
    const hit = /^\d+\.\s+\*\*(.+):\*\*\s*(.*)$/.exec(line);
    if (!hit) { if (line.trim()) onSkip?.(line); continue; }
    const raw = hit[2].trim();
    const versioned = /^(.*\S)\s+\(([^()]*)\)$/.exec(raw);
    const file = versioned ? versioned[1].trim() : raw;
    const entry = {
      field: hit[1].trim(),
      path: file === NOT_RECORDED_INLINE ? '' : file,
    };
    if (versioned && versioned[2].trim()) entry.version = versioned[2].trim();
    entries.push(entry);
  }
  return entries;
}

/**
 * Read a rendered `## Application Answers` section back into a snapshot.
 *
 * Returns the same shape `normalizeApplicationAnswersSnapshot` produces, so the
 * result can be handed straight back to `formatApplicationAnswersSection` or
 * merged with a fresh snapshot. Entry keys are the primary spelling accepted by
 * the formatter (`question`/`answer`, `question`/`selection`, `field`/`path`),
 * which is what makes re-rendering a fixed point.
 *
 * @param {string} reportText Full report markdown.
 * @returns {{date: string, state: string, freeText: object[], selections: object[],
 *            fieldValues: object[], files: object[]} | null}
 *          `null` when the report has no Application Answers section.
 */
export function parseApplicationAnswersSection(reportText, { strict = false } = {}) {
  const skipped = [];
  const onSkip = strict ? (line) => skipped.push(line.trim()) : undefined;
  const report = String(reportText ?? '').replace(/\r\n/g, '\n');
  const heading = findUnfencedHeading(report, /^## Application Answers[ \t]*$/);
  if (!heading) return null;

  const afterHeading = heading.index + heading[0].length;
  const nextHeading = findUnfencedHeading(report.slice(afterHeading), ANSWERS_SECTION_END_RE);
  const body = report.slice(
    afterHeading,
    nextHeading ? afterHeading + nextHeading.index : report.length,
  );

  const dateHit = /^\*\*Date:\*\*\s*(.*)$/m.exec(body);
  const stateHit = /^\*\*State:\*\*\s*(.*)$/m.exec(body);
  const groups = sliceGroups(body);

  const snapshot = {
    date: dateHit ? dateHit[1].trim() : '',
    state: stateHit ? stateHit[1].trim().toLowerCase() : '',
    freeText: parseQaEntries(groups.freeText, 'question', 'answer', onSkip),
    selections: parseCompactEntries(groups.selections, 'question', 'selection', onSkip),
    fieldValues: parseCompactEntries(groups.fieldValues, 'question', 'answer', onSkip),
    files: parseFileEntries(groups.files, onSkip),
  };

  if (strict && skipped.length) {
    throw new Error(
      `Application Answers section has ${skipped.length} unreadable ` +
      `${skipped.length === 1 ? 'entry' : 'entries'}: ${skipped.join(' | ')}`,
    );
  }
  return snapshot;
}

/**
 * The `(draft)` authoring marker, and the discriminator that replaced
 * `/^##\s+H\)\s*Draft Application Answers\s*$/m`.
 *
 * NEITHER the letter NOR the name identifies this block. Both move with the
 * locale: of the 19 evaluation modes, 3 write the English name under `H)`
 * (canonical, `ar`, `ja`), 5 write a translated name under `H)`
 * (`es ru tr zh zh-TW`), and 11 wrote a translated name under `G)`
 * (`da de fr hi id it ko nl pl pt ua`) until #5843 moved it to `H)`. Matching
 * one exact heading found the block in 3 of 19, so in sixteen languages
 * `modes/apply.md` silently regenerated every answer the evaluation had
 * already drafted and paid for.
 *
 * A trailing parenthetical marker is the signal instead, which is the
 * convention `web/src/lib/report-sections.mjs` already established for the
 * verdict callout: `(lead)` / `(verdict)` are stripped for display precisely
 * because they are deliberate authoring signals that read the same in every
 * language. `(draft)` is the same shape for the same reason; the evaluation
 * modes write it on this block (#4272).
 *
 * The English name is still accepted on its own, under any letter, for the
 * reports users already have on disk: those were written before any mode
 * emitted the marker, and they are the corpus `apply` reads from today.
 * A translated heading in an OLD report stays unreadable: no discriminator
 * can recover it without guessing, and guessing here re-submits a mispaired
 * answer to an employer. New evaluations in every language carry the marker.
 */
const DRAFT_ANSWERS_MARKER_RE = /\(draft\)\s*$/i;
const DRAFT_ANSWERS_NAME_RE = /^draft application answers$/i;
// Same grammar as report-sections.mjs' HEADING_PREFIX: a bare letter needs a
// real delimiter, or ordinary prose loses its first word.
const HEADING_PREFIX_RE = /^\s*(?:Block\s+([A-Z])(?:[).:]\s*|\s+(?:[—–-]+\s*)?)|([A-Z])[).:]\s*)/i;
// The draft-answers block's own marker. Deliberately tighter than
// HEADING_PREFIX_RE, which also accepts `H:` and `Block H`: every mode that
// defines this block writes `## H)`, so the wider grammar would only let an
// unrelated `H:` section be read as draft answers (#4400 review).
const DRAFT_ANSWERS_LETTER_RE = /^H\)\s*(.*)$/i;

/**
 * Locate the draft-answers heading: the first `## ` heading carrying the
 * `(draft)` marker, or failing that the first one whose name, with the author
 * letter and any marker stripped, is the canonical English one.
 * @param {string} report Report markdown, newlines already normalized.
 * @returns {{index:number, 0:string} | null} A match-like object, or null.
 */
function findDraftAnswersHeading(report) {
  /** @type {RegExpMatchArray[]} */
  // Horizontal whitespace only. `\s+` also matches the newline, so a bare `##`
  // line consumed it and captured the NEXT line as the heading text. With the
  // letter rule below, `##` followed by `H) Internal Notes` then read that
  // section's bold text as draft answers (#4400 review).
  const headings = [...report.matchAll(/^##[ \t]+(.+?)\s*$/gm)];
  const marked = headings.find(h => DRAFT_ANSWERS_MARKER_RE.test(h[1]));
  if (marked) return marked;
  const named = headings.find(h => DRAFT_ANSWERS_NAME_RE.test(
    h[1].replace(DRAFT_ANSWERS_MARKER_RE, '').replace(HEADING_PREFIX_RE, '').trim(),
  ));
  if (named) return named;
  // Last resort, and the only path that reads a TRANSLATED heading (#4400).
  // Neither rule above fires on one today: no mode emits the `(draft)` marker
  // (`git grep '(draft)' -- 'modes/*'` is empty), and the name rule is the
  // English words, which sixteen shipped modes translate (modes/es, modes/ru,
  // modes/tr, modes/zh, modes/zh-TW, and since #5843 the eleven that used to
  // write it under `G)`). For those the block silently returned null, which is
  // indistinguishable from a report that has no Block H.
  //
  // The letter is the structural part every mode keeps: `modes/oferta.md`
  // defines `H)` as the draft-answers block and each translation renders the
  // title only. It is deliberately LAST so the marker and the English name stay
  // authoritative where they apply, and it requires a non-empty title so a bare
  // `## H)` does not qualify.
  return headings.find(h => Boolean(DRAFT_ANSWERS_LETTER_RE.exec(h[1])?.[1]?.trim())) ?? null;
}

/**
 * Read the evaluation mode's `## H) Draft Application Answers (draft)` block.
 *
 * A DIFFERENT producer and a different format from the section above.
 * `parseApplicationAnswersSection` reads a format this module also writes, so
 * the two halves are pinned to each other. Nothing writes Block H from code:
 * `modes/oferta.md:622` specifies its heading and nothing about its body, so
 * the bold-question-then-paragraph shape below is a CONVENTION the evaluation
 * happens to emit, not a contract. This reads the convention and degrades to an
 * empty list when it does not hold, rather than guessing: a mispaired
 * question/answer here would be re-submitted to an employer later.
 *
 * Worth reading despite that, because `modes/apply.md` already treats Block H
 * as a legitimate base for a real application ("If there is a Section H or
 * `## Application Answers` -> load previous answers as a base"), and until now
 * nothing in the tree could load it. An evaluated report is the one case where
 * answers exist before any form has been seen.
 *
 * Returns the primary key spelling (`question`/`answer`) and omits the keys
 * Block H cannot carry, so the result is a partial snapshot that
 * `normalizeApplicationAnswersSnapshot` accepts as-is.
 *
 * @param {string} reportText Full report markdown.
 * The heading's title may be in any language; only the `## H)` marker is
 * required. A heading with a marker and no title is not Block H.
 *
 * @returns {{freeText: object[]} | null} `null` when the report has no Block H.
 */
export function parseDraftAnswersBlockH(reportText) {
  const report = String(reportText ?? '').replace(/\r\n/g, '\n');
  const heading = findDraftAnswersHeading(report);
  if (!heading) return null;

  const afterHeading = heading.index + heading[0].length;
  // Same grammar as the opener above. That one accepts `##` plus a tab, so a
  // terminator matching only `## ` let a later `##\tI) ...` section stay inside
  // Block H and its bold text come back as draft answers (#4400 review).
  const nextHeading = /^##[ \t]+.+$/m.exec(report.slice(afterHeading));
  const body = report.slice(
    afterHeading,
    nextHeading ? afterHeading + nextHeading.index : report.length,
  );

  // A question is a line that is ENTIRELY bold. Bold used mid-sentence inside an
  // answer therefore cannot be mistaken for the start of the next question, and
  // the italic parenthetical the mode emits under the heading is not a question.
  const questionLine = /^\*\*(.+?)\*\*\s*$/gm;
  const marks = [...body.matchAll(questionLine)];
  const freeText = [];
  for (const [index, mark] of marks.entries()) {
    const from = mark.index + mark[0].length;
    const to = index + 1 < marks.length ? marks[index + 1].index : body.length;
    const question = mark[1].trim();
    if (!question) continue;
    const answer = body
      .slice(from, to)
      // A trailing horizontal rule closes the report block, it is not an answer.
      .replace(/^\s*-{3,}\s*$/gm, '')
      .trim();
    freeText.push({ question, answer });
  }
  return { freeText };
}

export function upsertApplicationAnswersSection(reportText, snapshot = {}) {
  const report = String(reportText ?? '').replace(/\r\n/g, '\n');
  const section = formatApplicationAnswersSection(snapshot).trimEnd();
  const heading = findUnfencedHeading(report, /^## Application Answers[ \t]*$/);

  if (!heading) {
    return `${report.trimEnd()}\n\n${section}\n`;
  }

  const start = heading.index;
  const afterHeading = start + heading[0].length;
  const nextHeading = findUnfencedHeading(report.slice(afterHeading), ANSWERS_SECTION_END_RE);
  const end = nextHeading ? afterHeading + nextHeading.index : report.length;
  const before = report.slice(0, start).trimEnd();
  const after = report.slice(end);

  const updated = [before, section, after].filter(Boolean).join('\n\n');
  return updated.endsWith('\n') ? updated : `${updated}\n`;
}

function parseArgs(argv) {
  const args = {};
  for (let i = 0; i < argv.length; i += 1) {
    const arg = argv[i];
    if (arg === '--help' || arg === '-h') args.help = true;
    else if (arg === '--read') args.read = true;
    else if (arg === '--read-draft') args.readDraft = true;
    else if (arg === '--strict') args.strict = true;
    else if (arg.startsWith('--')) {
      const value = argv[i + 1];
      if (!value || value.startsWith('--')) {
        throw new Error(`Missing value for ${arg}`);
      }
      args[arg.slice(2)] = value;
      i += 1;
    }
  }
  return args;
}

function usage() {
  return [
    'Usage: node application-answers.mjs --report <report.md> --input <answers.json> [--state filled|submitted] [--date YYYY-MM-DD]',
    '       node application-answers.mjs --report <report.md> --read [--strict]',
    '       node application-answers.mjs --report <report.md> --read-draft',
    '',
    'The input JSON may contain: freeText, selections, fieldValues, files, date, state.',
    '--read prints the parsed ## Application Answers snapshot as JSON (null when the section is absent).',
    '--strict makes --read refuse a partially unreadable section, naming every line it could not parse,',
    'instead of skipping it. Recovery callers (modes/apply.md) want the refusal; the default stays total.',
    '--read-draft prints the evaluation mode\'s ## H) Draft Application Answers block instead, as a partial',
    'snapshot ({"freeText": [...]}), or null when the report has no Block H. Best-effort by construction:',
    'modes/oferta.md fixes the heading and not the body, so an empty freeText means "drafted, unreadable",',
    'which is why --strict does not apply to it.',
  ].join('\n');
}

async function main() {
  let args;
  try {
    args = parseArgs(process.argv.slice(2));
  } catch (err) {
    console.error(`${err.message}\n\n${usage()}`);
    process.exitCode = 1;
    return;
  }
  if (args.help) {
    console.log(usage());
    return;
  }
  if (args.strict && !args.read) {
    console.error(`--strict only applies to --read.\n\n${usage()}`);
    process.exitCode = 1;
    return;
  }
  if (args.read && args.readDraft) {
    console.error(`--read and --read-draft print different sections; pass one.\n\n${usage()}`);
    process.exitCode = 1;
    return;
  }
  if (args.readDraft) {
    if (args.input || args.state || args.date) {
      console.error(`--read-draft is read-only and takes no --input, --state or --date.\n\n${usage()}`);
      process.exitCode = 1;
      return;
    }
    if (!args.report) {
      console.error(usage());
      process.exitCode = 1;
      return;
    }
    // No strict counterpart on purpose. Block H's body is a convention, not a
    // format this module writes, so "I could not read a line" is an expected
    // outcome rather than a corrupted report worth refusing over.
    const reportText = readFileSync(resolve(args.report), 'utf-8');
    console.log(JSON.stringify(parseDraftAnswersBlockH(reportText), null, 2));
    return;
  }
  if (args.read) {
    if (args.input || args.state || args.date) {
      console.error(`--read is read-only and takes no --input, --state or --date.\n\n${usage()}`);
      process.exitCode = 1;
      return;
    }
    if (!args.report) {
      console.error(usage());
      process.exitCode = 1;
      return;
    }
    // strict throws with a message naming every unreadable line; main().catch
    // prints it to stderr and sets a non-zero exit code, which is the contract
    // modes/apply.md keys on. A report without the section prints null.
    const reportText = readFileSync(resolve(args.report), 'utf-8');
    const snapshot = parseApplicationAnswersSection(reportText, { strict: args.strict === true });
    console.log(JSON.stringify(snapshot, null, 2));
    return;
  }
  if (!args.report || !args.input) {
    console.error(usage());
    process.exitCode = 1;
    return;
  }

  const inputText = args.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(resolve(args.input), 'utf-8');
  const input = JSON.parse(inputText);
  const snapshot = {
    ...input,
    date: args.date || input.date,
    state: args.state || input.state,
  };
  const reportPath = resolve(args.report);
  const updated = upsertApplicationAnswersSection(readFileSync(reportPath, 'utf-8'), snapshot);
  writeFileSync(reportPath, updated, 'utf-8');

  const normalized = normalizeApplicationAnswersSnapshot(snapshot);
  console.log(JSON.stringify({ report: reportPath, date: normalized.date, state: normalized.state }, null, 2));
}

if (isMainModule(import.meta.url)) {
  main().catch((err) => {
    console.error(err.message);
    process.exitCode = 1;
  });
}