application-answers.mjs
Rama main · réplica de lectura del repositorio original.
#!/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;
});
}