#!/usr/bin/env node /** * normalize-statuses.mjs — Clean non-canonical states in applications.md * * Maps all non-canonical statuses to canonical ones per states.yml: * Evaluated, Applied, Responded, Assessment, Interview, Offer, Hired, Rejected, Discarded, SKIP * * Also strips markdown bold (**) and dates from the status field, * moving DUPLICADO info to the notes column. * * Run: node career-ops/normalize-statuses.mjs [--dry-run] */ import { readFileSync, copyFileSync, existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { getCareerOpsRoot, resolveTrackerPath } from './path-resolver.mjs'; import { openTrackerTransaction, rebuildRow, loadCanonicalStates, resolveCanonicalState, resolveCanonicalStateId, statusTextAfterDate, prependNote, hasNoteEntry, } from './tracker-utils.mjs'; import { resolveColumns, parseTrackerRow, splitTrackerCells, trackerRowSeparator } from './tracker-parse.mjs'; import { validateFlags } from './lib/cli-flags.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; // System Layer files (templates/, modes/, scripts) live in the codebase and are // resolved from this module's own directory; only User Layer data follows the // configurable data root. Binding both to getCareerOpsRoot() made states.yml // unreadable under CAREER_OPS_ROOT / .career-ops-data, and the catch below // turned that into a silent "everything is unknown" (#3500). const CODEBASE_ROOT = dirname(fileURLToPath(import.meta.url)); const CAREER_OPS = getCareerOpsRoot(); const APPS_FILE = resolveTrackerPath(CAREER_OPS); // Ensure required directories exist (fresh setup) mkdirSync(join(CAREER_OPS, 'data'), { recursive: true }); // Canonical status mapping let statesCache = null; /** Canonical states from templates/states.yml, read once per CLI run. */ function canonicalStates() { if (statesCache) return statesCache; const statesFile = join(CODEBASE_ROOT, 'templates', 'states.yml'); try { statesCache = loadCanonicalStates(statesFile); } catch (err) { // Broken install: fall through to "unknown", never a stale copy — but say // so. The silent [] is what made #3500 invisible: a states.yml looked up in // the wrong tree is indistinguishable from one that is genuinely missing, // and the whole tracker normalized to unknown without a word. console.error(`[normalize-statuses] cannot read canonical states from ${statesFile}: ${err.message}`); console.error('[normalize-statuses] every status will be reported as unknown until this is fixed.'); statesCache = []; } return statesCache; } function normalizeStatus(raw) { // A dated cell's state. Text after the date is no part of it, so the whole // cell moves to Notes rather than losing those words. const dated = (status) => (statusTextAfterDate(raw) ? { status, moveToNotes: raw.trim() } : { status }); // Strip markdown bold let s = raw.replace(/\*\*/g, '').trim(); const lower = s.toLowerCase(); // DUPLICADO variants → Discarded if (/^duplicado/i.test(s) || /^dup\b/i.test(s)) { return { status: 'Discarded', moveToNotes: raw.trim() }; } // CERRADA / Cancelada / Descartada → Discarded if (/^cerrada$/i.test(s)) return { status: 'Discarded' }; if (/^cancelada/i.test(s)) return { status: 'Discarded' }; if (/^descartada$/i.test(s)) return { status: 'Discarded' }; if (/^descartado$/i.test(s)) return { status: 'Discarded' }; // Rechazada / Rechazado → Rejected // `rechazada?` reads as "rechazad" + an OPTIONAL trailing "a", so it accepted // "rechazada" and the bare stem "rechazad" but never "rechazado" — the masculine // form this comment claims to handle, that states.yml lists as an alias, and that // the header of this file names. A bare "Rechazado" fell through to unknown. if (/^rechazad[oa]$/i.test(s)) return { status: 'Rejected' }; if (/^rechazado\s+\d{4}/i.test(s)) return dated('Rejected'); // Aplicado with date → Applied (strip date) if (/^aplicado\s+\d{4}/i.test(s)) return dated('Applied'); // CONDICIONAL / HOLD / EVALUAR / Verificar → Evaluated if (/^(condicional|hold|evaluar|verificar)$/i.test(s)) return { status: 'Evaluated' }; // MONITOR → SKIP if (/^monitor$/i.test(s)) return { status: 'SKIP' }; // GEO BLOCKER → SKIP if (/geo.?blocker/i.test(s)) return { status: 'SKIP' }; // Repost #NNN → Discarded if (/^repost/i.test(s)) return { status: 'Discarded', moveToNotes: raw.trim() }; // "—" (em dash, no status) → Discarded if (s === '—' || s === '-' || s === '') return { status: 'Discarded' }; // Already canonical (English, per states.yml) — just fix casing/bold const canonical = [ 'Evaluated', 'Applied', 'Responded', 'Assessment', 'Interview', 'Offer', 'Hired', 'Rejected', 'Discarded', 'SKIP', ]; for (const c of canonical) { if (lower === c.toLowerCase()) return { status: c }; } // Every remaining alias comes from templates/states.yml, not a list here. // The hand-written list this replaces had drifted: it carried the Spanish // aliases and none of the Turkish ones, so a `Mülakat` row was reported as // an unknown status by the very tool whose job is normalizing statuses // (#2704). test-all already asserted states.yml ⊆ this function; deriving // makes that hold by construction instead of by remembering. const fromStates = resolveCanonicalState(lower, canonicalStates()); if (fromStates) return { status: fromStates }; // A legacy cell with a date appended: `Applied 2026-01-02`, // `**Interview** 2026-02-10`. Every reader resolves one through // resolveCanonicalStateId(), which strips the bold and the date, and // verify-pipeline's fix for "Status contains date" is to run this script. // Only the two Spanish special cases above stripped a date, so every other // dated cell was reported as unknown and left in place. Resolve it the way // the readers do: the date goes, the state stays. Text after the date // (`… 2026-01-02 via referral`) is not part of any state, so the cell is // kept in Notes rather than dropped with it. const datedId = resolveCanonicalStateId(raw, canonicalStates()); if (datedId) { const status = canonicalStates().find(st => st.id.toLowerCase() === datedId).label; return dated(status); } // Unknown — flag it return { status: null, unknown: true }; } export { normalizeStatus }; const KNOWN_FLAGS = ['--help', '-h', '--dry-run']; const USAGE = `Usage: node normalize-statuses.mjs [options] Options: --dry-run Report what would change without writing applications.md -h, --help Show this help and exit `; // Everything below is the CLI. It is guarded because importing this module used to // run it: the import alone opened a tracker transaction and rewrote applications.md. // That is why tests could only scrape this file's source with regexes instead of // calling the function, and why the rechazado gap went unnoticed. const IS_CLI = isMainModule(import.meta.url); if (IS_CLI) { const args = process.argv.slice(2); // Unrecognized flags exit 1 naming the flag; --help/-h print USAGE and exit 0. validateFlags(args, KNOWN_FLAGS, USAGE); // Read applications.md if (!existsSync(APPS_FILE)) { console.log('No applications.md found. Nothing to normalize.'); process.exit(0); } const DRY_RUN = process.argv.includes('--dry-run'); let trackerTransaction = null; if (!DRY_RUN) { try { trackerTransaction = await openTrackerTransaction(APPS_FILE); } catch (err) { console.error(`Cannot acquire tracker lock: ${err.message}`); process.exit(1); } process.once('exit', () => { try { trackerTransaction.close(); } catch {} }); } try { const content = trackerTransaction ? trackerTransaction.read() : readFileSync(APPS_FILE, 'utf-8'); const lines = content.split('\n'); let changes = 0; let unknowns = []; let noNotes = []; // Map columns by header name (tracker-parse.mjs, #954/#1596). Fixed indices // assumed the original 9-column layout, so a customized tracker — an inserted // Location or Via column — shifted every field one to the left: the Score cell // was normalized as if it were the status and overwritten, while the real // status was left alone and reported as unknown (#1955). // Legacy tab-layout rows are readable here the same way set-status.mjs and // mark-pdf-ready.mjs already read them: opt in explicitly, keep the layout on // the way out (a pipe-only writer must not migrate the table as a side effect). const PARSE_OPTS = { allowTabs: true }; const COLS = resolveColumns(lines, PARSE_OPTS); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const row = parseTrackerRow(line, COLS, PARSE_OPTS); if (!row) continue; // header, separator, non-row, or a row missing cells const separator = trackerRowSeparator(line); const parts = splitTrackerCells(line).map(s => s.trim()); const num = row.num; const rawStatus = row.status; const result = normalizeStatus(rawStatus); if (result.unknown) { unknowns.push({ num, rawStatus, line: i + 1 }); continue; } if (result.status === rawStatus) continue; // Already canonical // Words after a date (`Applied 2026-01-02 via referral`) move to Notes. A // layout without a Notes column has nowhere to put them, and the bare state // would drop them, so the row stays as it is. if (COLS.notes == null && statusTextAfterDate(rawStatus)) { noNotes.push({ num, rawStatus, line: i + 1 }); continue; } // Apply change const oldStatus = rawStatus; parts[COLS.status] = result.status; // Move DUPLICADO info to notes if needed. A layout without a Notes column // has nowhere to put it — dropping the provenance beats appending a cell the // table has no header for. if (result.moveToNotes && COLS.notes != null) { const existing = parts[COLS.notes] || ''; if (!hasNoteEntry(existing, result.moveToNotes)) { parts[COLS.notes] = prependNote(existing, result.moveToNotes); } } // Also strip bold from score field if (parts[COLS.score]) { parts[COLS.score] = parts[COLS.score].replace(/\*\*/g, ''); } // Reconstruct line, preserving the row's own layout: rebuildRow is the // pipe writer; a tab row goes back out tab-delimited, same canonical shape // set-status.mjs writes (`|a\tb\tc|`). let cells = parts.slice(1); if (cells.length > 0 && cells[cells.length - 1] === '') cells = cells.slice(0, -1); const newLine = separator === '\t' ? '|' + cells.join('\t') + '|' : rebuildRow(parts); lines[i] = newLine; changes++; console.log(`#${num}: "${oldStatus}" → "${result.status}"`); } if (unknowns.length > 0) { console.log(`\n⚠️ ${unknowns.length} unknown statuses:`); for (const u of unknowns) { console.log(` #${u.num} (line ${u.line}): "${u.rawStatus}"`); } } if (noNotes.length > 0) { console.log(`\n⚠️ ${noNotes.length} dated statuses left as they are: the tracker has no Notes column for the words after the date:`); for (const u of noNotes) { console.log(` #${u.num} (line ${u.line}): "${u.rawStatus}"`); } } console.log(`\n📊 ${changes} statuses normalized`); if (!DRY_RUN && changes > 0) { // Backup first const backupPath = `${APPS_FILE}.bak`; copyFileSync(APPS_FILE, backupPath); trackerTransaction.replace(lines.join('\n')); console.log(`✅ Written to ${APPS_FILE} (backup: ${backupPath})`); } else if (DRY_RUN) { console.log('(dry-run — no changes written)'); } else { console.log('✅ No changes needed'); } } finally { trackerTransaction?.close(); } } // end IS_CLI