#!/usr/bin/env node /** * cv-title-check.mjs — Zero-LLM job-title consistency checker (#2677). * * `pdf.md`'s tailoring pipeline legitimately reformulates bullets, the summary, * and framing per JD (Steps 10-15) — "keywords get reformulated, never * fabricated" per the Data Contract. But nothing checked whether a job TITLE * survives that reformulation unchanged from cv.md's own canonical title for * the same job. A tailored CV stating a more senior-sounding title than * cv.md's for the exact same {company, dates} is a narrower, checkable failure * mode, distinct from legitimate duty/keyword reformulation — and a real risk * if ever cross-checked (LinkedIn, a prior CV, a background check, or an * interviewer asking directly). * * Input format (IMPORTANT — why this and not HTML or the rendered PDF): * `modes/pdf.md` Step 17 already builds a compact JSON payload before any HTML * or PDF exists — `{ experience: [{ company, role, dates, ... }] }` — and * writes it to `/tmp/cv-{candidate}-{company}.json`. That payload is the * earliest point in the pipeline with company/title/dates as clean structured * fields, straight from the tailoring step, before `build-cv-html.mjs` ever * touches markup. Checking there means: no HTML/PDF text-scraping regressions * to maintain, and the check runs (and can stop bad output) BEFORE the fact * gate and PDF render, matching this script's sibling `jd-skill-gap.mjs` * (checks before drafting, not after rendering). See modes/pdf.md Step 17a. * * Matching rule (deliberately NOT fuzzy, per the issue): pair a tailored-CV * entry with a cv.md entry by {company, dates} — normalized on case/whitespace * only, via the same normalizeCompany() the rest of the tracker ecosystem uses * for company-name grouping (tracker-utils.mjs). Title is the field being * checked, so it can never also be the matching key. An exact string match * (case/whitespace-normalized, no fuzzy similarity scoring) is a silent pass; * any other difference is a warning printed with both strings side by side so * the user can judge intentional framing vs. unintended drift. Entries that * cannot be paired (a JD-only role, a cv.md entry never carried into this * particular tailored CV, or a date phrasing that doesn't literally line up) * are reported separately as unmatched — never a mismatch. A {company, dates} * key that resolves to more than one entry on either side is reported as * ambiguous instead of comparing anything: silently keeping "the first" entry * would hide the rest of the group and risk pairing the wrong role. * * Warn-only, matching this project's human-in-the-loop philosophy, for * COMPLETED comparisons: never blocks, never edits cv.md or the tailored CV, * never exits non-zero for a title mismatch or an ambiguous group. Exit 1 is * reserved for the check not actually running to completion — a missing or * unreadable cv.md/payload, unparseable JSON, or an experience entry missing * company/role/dates (which would otherwise silently narrow the comparison * to only the complete entries and report a false clean bill of health) — * plus --self-test failures. * * Usage: * node cv-title-check.mjs /tmp/cv-jane-doe-acme.json * node cv-title-check.mjs /tmp/cv-jane-doe-acme.json --summary * node cv-title-check.mjs --self-test */ import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync } from 'fs'; import { fileURLToPath } from 'url'; import { join } from 'path'; import { tmpdir } from 'os'; import { spawnSync } from 'child_process'; import { normalizeCompany } from './tracker-utils.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; import { getCareerOpsRoot } from './path-resolver.mjs'; import { boldLineText, findExperienceSections, headingText, toPlainText, EXPERIENCE_HEADING_NAMES, } from './lib/cv-markdown.mjs'; const CV_PATH = join(getCareerOpsRoot(), 'cv.md'); // ── cv.md experience parsing ──────────────────────────────────────── // // cv.md's canonical shape (see examples/cv-example.md): // // ### Company Name -- Location // // **Job Title** // 2020-2024 // // - bullet // // Company/location are split on the first " -- " (or " – " / " — ", the // dashes some editors and Pandoc substitute); location is optional. The title // is the next bolded line; dates are the next non-empty line after that. Any // entry missing a bolded title or a dates line is skipped rather than // guessed at — a checker that invents a comparison side is worse than one // that reports nothing for that entry. // // Lines go through lib/cv-markdown.mjs first, so the same entry written as // Pandoc Markdown (`### **Acme** --- City`, `**Title**\`, `**2020 -- 2024**`, // `## **[Professional Experience]{.smallcaps}**`) parses identically (#4879). export function parseCvExperience(cvText) { const entries = []; // Only scan inside a recognized Experience section (lib/cv-markdown.mjs's // EXPERIENCE_HEADING_NAMES), so an unrelated "### " heading elsewhere in // the CV (e.g. under Projects) never gets misread as a job. for (const lines of findExperienceSections(cvText)) { for (let i = 0; i < lines.length; i++) { const heading = headingText(lines[i], 3); if (heading === null) continue; const [companyPart] = heading.split(/\s+[-–—]{1,2}\s+/); const company = companyPart.trim(); if (!company) continue; // Walk forward for the bolded title line, then the dates line, skipping // blank lines. Stop at the next heading so a malformed entry never eats // the following one. let title = null; let dates = null; for (let j = i + 1; j < lines.length; j++) { const next = lines[j]; if (/^#{2,3}\s+/.test(next)) break; if (next.trim() === '') continue; if (title === null) { const bold = boldLineText(next); if (bold) { title = bold; continue; } break; // first non-blank line after the heading must be the title } if (dates === null) { dates = toPlainText(next); } break; } if (title && dates) entries.push({ company, title, dates }); } } return entries; } // ── Tailored-CV payload parsing (modes/pdf.md Step 17 JSON schema) ── /** * Parse `experience[]` out of the tailored-CV render payload. * * An entry missing `company`, `role`, or `dates` is a malformed payload, not * a job the check can skip: silently dropping it would compare only the * complete entries, exit 0, and report a clean bill of health for a CV that * was never fully checked. Same failure category as a payload that fails to * parse at all — throws so the CLI can exit non-zero and name exactly which * entry/field needs fixing, instead of quietly narrowing the comparison set. * * @throws {Error} if `experience` is absent/non-array, or if any entry is * missing company/role/dates. */ export function parseTailoredExperience(payload) { const experience = payload?.experience; if (!Array.isArray(experience)) { throw new Error('tailored CV payload must contain an experience array'); } const parsed = []; const incomplete = []; experience.forEach((e, index) => { const company = String(e?.company ?? '').trim(); const title = String(e?.role ?? '').trim(); const dates = String(e?.dates ?? '').trim(); const missingFields = []; if (!company) missingFields.push('company'); if (!title) missingFields.push('role'); if (!dates) missingFields.push('dates'); if (missingFields.length > 0) { incomplete.push(`experience[${index}] is missing ${missingFields.join(', ')}`); } else { parsed.push({ company, title, dates }); } }); if (incomplete.length > 0) { throw new Error( `tailored CV payload has ${incomplete.length} incomplete experience entr${incomplete.length === 1 ? 'y' : 'ies'} — ` + `title check cannot run on a partial comparison set: ${incomplete.join('; ')}` ); } return parsed; } // ── Normalization / matching ──────────────────────────────────────── // Case/whitespace/dash normalization only — no fuzzy similarity. Any run of // hyphens, en dashes and em dashes is one separator, so Pandoc's // "09/2021 -- Present" pairs with a payload's "09/2021 - Present". A date // phrasing that differs in more than punctuation/spacing (e.g. "2020-2024" // on the cv.md side vs "June 2020 - Present" on the tailored side) will not // match, and the pair is reported as unmatched rather than forced together. export function normalizeDates(s) { return String(s ?? '') .toLowerCase() .replace(/[–—]/g, '-') .replace(/\s*-+\s*/g, '-') .replace(/\s+/g, ' ') .trim(); } export function normalizeTitle(s) { return String(s ?? '').replace(/\s+/g, ' ').trim().toLowerCase(); } function matchKey(company, dates) { return `${normalizeCompany(company)}||${normalizeDates(dates)}`; } function groupByKey(entries) { const groups = new Map(); for (const entry of entries) { const key = matchKey(entry.company, entry.dates); if (!groups.has(key)) groups.set(key, []); groups.get(key).push(entry); } return groups; } /** * Compare a tailored-CV payload's experience entries against cv.md's. * * A {company, dates} key is only ever compared when it is unique on BOTH * sides — cv.md never legitimately carries two different jobs at the same * company over the exact same date range, but a malformed cv.md or a * tailored CV that merged two roles could produce that shape, and silently * picking one entry (first-entry-wins) would hide the rest of the group and * risk comparing the wrong role. A key with more than one entry on either * side is reported as `ambiguous` instead — never silently resolved into a * clean match/mismatch, and never dropped. * * @param {Array<{company,title,dates}>} cvEntries * @param {Array<{company,title,dates}>} tailoredEntries * @returns {{matched: Array, mismatches: Array, ambiguous: Array, unmatchedTailored: Array, unmatchedCv: Array}} */ export function checkTitles(cvEntries, tailoredEntries) { const cvGroups = groupByKey(cvEntries); const tailoredGroups = groupByKey(tailoredEntries); const matched = []; const mismatches = []; const ambiguous = []; const unmatchedTailored = []; const unmatchedCv = []; const seenKeys = new Set(); for (const [key, tailoredGroup] of tailoredGroups) { seenKeys.add(key); const cvGroup = cvGroups.get(key); if (!cvGroup) { unmatchedTailored.push(...tailoredGroup); continue; } if (cvGroup.length > 1 || tailoredGroup.length > 1) { ambiguous.push({ company: cvGroup[0].company, dates: cvGroup[0].dates, cvTitles: cvGroup.map(e => e.title), tailoredTitles: tailoredGroup.map(e => e.title), }); continue; } const cvEntry = cvGroup[0]; const t = tailoredGroup[0]; const pair = { company: cvEntry.company, dates: cvEntry.dates, cvTitle: cvEntry.title, tailoredTitle: t.title, }; if (normalizeTitle(cvEntry.title) === normalizeTitle(t.title)) { matched.push(pair); } else { mismatches.push(pair); } } for (const [key, cvGroup] of cvGroups) { if (!seenKeys.has(key)) unmatchedCv.push(...cvGroup); } return { matched, mismatches, ambiguous, unmatchedTailored, unmatchedCv }; } /** * Say out loud when a run compared nothing it was given (#4879). * * "No mismatches" is only a clean result when something was compared. A * cv.md the parser cannot read yields zero entries, every tailored entry * lands in unmatchedTailored, and the summary used to print ✅ anyway — a * planted title inflation went out under a green check. Mirrors * verify-cv-structure.mjs's UNVERIFIED and jd-skill-gap.mjs's LOW CONFIDENCE: * still warn-only (exit 0), but impossible to read as a pass. * * @param {Array} cvEntries parsed cv.md entries * @param {Array} tailoredEntries parsed payload entries * @param {ReturnType} result * @returns {{reason: string, message: string}|null} null when the run was conclusive */ export function diagnoseUnverified(cvEntries, tailoredEntries, result) { if (tailoredEntries.length === 0) return null; if (result.matched.length + result.mismatches.length + result.ambiguous.length > 0) return null; if (cvEntries.length === 0) { return { reason: 'cv-unparsed', message: 'cv.md entries could not be parsed — title check did not run. ' + `Expected a "## ${EXPERIENCE_HEADING_NAMES.join('" / "## ')}" section whose entries are ` + '"### Company — Location", then a bold **Title** line, then a dates line (see examples/cv-example.md).', }; } return { reason: 'no-pairs', message: `cv.md has ${cvEntries.length} parsed entr${cvEntries.length === 1 ? 'y' : 'ies'}, but no tailored entry ` + 'matched one by {company, dates} — title check did not run. Compare the company names and date ' + 'phrasing in the payload against cv.md.', }; } // ── Self-test ──────────────────────────────────────────────────────── function runSelfTest() { let passed = 0, failed = 0; const eq = (label, actual, expected) => { const a = JSON.stringify(actual), e = JSON.stringify(expected); if (a === e) { passed++; } else { failed++; console.log(` FAIL: ${label}\n expected: ${e}\n actual: ${a}`); } }; const cvText = ` # CV -- Jane Doe ## Work Experience ### Acme Corp -- Remote **Senior Software Engineer** 2020-2024 - Did things ### Globex Inc. -- Austin, TX **Engineer** 2018-2020 - Did other things ## Projects ### Not A Job -- should be ignored `; const cvEntries = parseCvExperience(cvText); eq('parses two experience entries from cv.md', cvEntries.length, 2); eq('does not parse ### headings outside Work Experience', cvEntries.some(e => e.company === 'Not A Job'), false); // Case 1: title unchanged -> no warning. const unchangedPayload = { experience: [{ company: 'Acme Corp', role: 'Senior Software Engineer', dates: '2020-2024' }] }; const unchangedResult = checkTitles(cvEntries, parseTailoredExperience(unchangedPayload)); eq('unchanged title produces zero mismatches', unchangedResult.mismatches.length, 0); eq('unchanged title is reported as matched', unchangedResult.matched.length, 1); // Case 2: same company/dates, drifted title -> warning with both strings. const driftedPayload = { experience: [{ company: 'Acme Corp', role: 'Engineering Lead', dates: '2020-2024' }] }; const driftedResult = checkTitles(cvEntries, parseTailoredExperience(driftedPayload)); eq('drifted title produces one mismatch', driftedResult.mismatches.length, 1); eq('mismatch carries the cv.md canonical title', driftedResult.mismatches[0]?.cvTitle, 'Senior Software Engineer'); eq('mismatch carries the tailored CV title', driftedResult.mismatches[0]?.tailoredTitle, 'Engineering Lead'); // Case 3: company-name formatting differences (trailing "Inc.", extra // whitespace) still pair up via normalizeCompany, and an unchanged title // under that formatting drift is still a clean match, not a false mismatch. const formattingPayload = { experience: [{ company: ' Globex Inc ', role: 'Engineer', dates: '2018 - 2020' }] }; const formattingResult = checkTitles(cvEntries, parseTailoredExperience(formattingPayload)); eq('company formatting differences still pair the entry', formattingResult.mismatches.length + formattingResult.matched.length, 1); eq('formatting-only differences do not produce a false mismatch', formattingResult.mismatches.length, 0); eq('formatting-only differences still match', formattingResult.matched.length, 1); // Unmatched: a company/dates pair with no cv.md counterpart is reported // separately, never forced into a mismatch. const unmatchedPayload = { experience: [{ company: 'Brand New Co', role: 'Founder', dates: '2024-Present' }] }; const unmatchedResult = checkTitles(cvEntries, parseTailoredExperience(unmatchedPayload)); eq('an entry with no cv.md counterpart is unmatched, not a mismatch', unmatchedResult.unmatchedTailored.length, 1); eq('an unmatched entry never appears as a mismatch', unmatchedResult.mismatches.length, 0); // Case sensitivity / whitespace-only title differences are not a mismatch. const caseOnlyPayload = { experience: [{ company: 'Acme Corp', role: ' senior software engineer ', dates: '2020-2024' }] }; const caseOnlyResult = checkTitles(cvEntries, parseTailoredExperience(caseOnlyPayload)); eq('case/whitespace-only title differences are not a mismatch', caseOnlyResult.mismatches.length, 0); // An incomplete experience entry (missing a required field) must fail // loudly, not be silently filtered out of the comparison set — a payload // mixing one valid entry with one incomplete entry must NOT quietly // compare only the valid one and report a clean result. const throwsFor = (label, payload) => { try { parseTailoredExperience(payload); failed++; console.log(` FAIL: ${label}\n expected: throw\n actual: no throw`); } catch { passed++; } }; throwsFor('an entry missing dates throws instead of being dropped', { experience: [{ company: 'Acme Corp', role: 'Senior Software Engineer' }], }); throwsFor('an entry missing role throws instead of being dropped', { experience: [{ company: 'Acme Corp', dates: '2020-2024' }], }); throwsFor('an entry missing company throws instead of being dropped', { experience: [{ role: 'Senior Software Engineer', dates: '2020-2024' }], }); throwsFor('one incomplete entry throws even alongside an otherwise-valid entry (no partial comparison)', { experience: [ { company: 'Acme Corp', role: 'Senior Software Engineer', dates: '2020-2024' }, { company: 'Globex Inc.', role: 'Engineer' }, // missing dates ], }); try { parseTailoredExperience({ experience: [{ company: 'Acme Corp', role: 'Senior Software Engineer' }], }); failed++; console.log(' FAIL: incomplete-entry error names the missing field\n expected: throw mentioning "dates"\n actual: no throw'); } catch (err) { eq('incomplete-entry error names the missing field', err.message.includes('dates'), true); eq('incomplete-entry error names the entry index', err.message.includes('experience[0]'), true); } // A missing or non-array `experience` must throw, not silently become an // empty comparison set — {"experience": {}} must not exit 0 having checked // nothing. throwsFor('a payload with no experience key throws instead of defaulting to []', {}); throwsFor('a non-array experience value (object) throws', { experience: {} }); throwsFor('a non-array experience value (string) throws', { experience: 'none' }); eq('an explicit empty experience array is still valid (no experience entries)', parseTailoredExperience({ experience: [] }).length, 0); // Duplicate {company, dates} key IN cv.md (two different jobs, same range — // malformed but possible): must be reported as ambiguous, never resolved by // silently picking one cv.md entry and hiding the other. const dupCvText = ` # CV -- Jane Doe ## Work Experience ### Acme Corp -- Remote **Senior Software Engineer** 2020-2024 - Did things ### Acme Corp -- Remote **Contractor** 2020-2024 - Did other things `; const dupCvEntries = parseCvExperience(dupCvText); eq('parses both duplicate-key cv.md entries (neither is dropped)', dupCvEntries.length, 2); const dupCvPayload = { experience: [{ company: 'Acme Corp', role: 'Senior Software Engineer', dates: '2020-2024' }] }; const dupCvResult = checkTitles(dupCvEntries, parseTailoredExperience(dupCvPayload)); eq('a cv.md-side duplicate key is reported as ambiguous', dupCvResult.ambiguous.length, 1); eq('an ambiguous cv.md-side key is not silently matched', dupCvResult.matched.length, 0); eq('an ambiguous cv.md-side key is not silently reported as a mismatch either', dupCvResult.mismatches.length, 0); eq('the ambiguous group carries both cv.md titles', dupCvResult.ambiguous[0]?.cvTitles.sort(), ['Contractor', 'Senior Software Engineer']); // Duplicate {company, dates} key on the TAILORED-CV side (e.g. the payload // accidentally lists the same job twice, or two distinct roles collapsed // onto the same dates): also ambiguous, never resolved by picking one. const dupTailoredPayload = { experience: [ { company: 'Acme Corp', role: 'Senior Software Engineer', dates: '2020-2024' }, { company: 'Acme Corp', role: 'Engineering Lead', dates: '2020-2024' }, ], }; const dupTailoredResult = checkTitles(cvEntries, parseTailoredExperience(dupTailoredPayload)); eq('a tailored-CV-side duplicate key is reported as ambiguous', dupTailoredResult.ambiguous.length, 1); eq('an ambiguous tailored-CV-side key is not silently matched', dupTailoredResult.matched.length, 0); eq('an ambiguous tailored-CV-side key is not silently reported as a mismatch either', dupTailoredResult.mismatches.length, 0); eq('the ambiguous group carries both tailored titles', dupTailoredResult.ambiguous[0]?.tailoredTitles.sort(), ['Engineering Lead', 'Senior Software Engineer']); // Pandoc-flavored cv.md (#4879): the same content as a plain cv.md, in the // shape a .docx → Markdown conversion produces. Must parse to the same // entries, so a planted title inflation is caught here exactly as it is on // the plain file. const pandocCv = [ '# Jane Example', '', '## **[Technical Platforms & Tools]{.smallcaps}**', '', '- Cloud: AWS, Kubernetes, Terraform', '', '## **[Professional Experience]{.smallcaps}**', '', '### **Acme Health, Inc.** --- New York, NY (Remote)', '', '**Senior Director, Platform Engineering**\\', '**09/2021 -- Present**', '', '- Owned the cloud platform.', '', '### **Globex Corp.** --- Stamford, CT', '', '**Principal Infrastructure Engineer**\\', '**03/2015 -- 08/2021**', '', '- Built the data platform.', ].join('\n'); const pandocEntries = parseCvExperience(pandocCv); eq('Pandoc cv.md: parses both experience entries', pandocEntries, [ { company: 'Acme Health, Inc.', title: 'Senior Director, Platform Engineering', dates: '09/2021 – Present' }, { company: 'Globex Corp.', title: 'Principal Infrastructure Engineer', dates: '03/2015 – 08/2021' }, ]); const inflatedPayload = { experience: [ { company: 'Acme Health, Inc.', role: 'VP, Platform Engineering', dates: '09/2021 - Present' }, { company: 'Globex Corp.', role: 'Principal Infrastructure Engineer', dates: '03/2015 - 08/2021' }, ] }; const pandocResult = checkTitles(pandocEntries, parseTailoredExperience(inflatedPayload)); eq('Pandoc cv.md: the planted title inflation is caught', pandocResult.mismatches.map(m => m.tailoredTitle), ['VP, Platform Engineering']); eq('Pandoc cv.md: the unchanged title still matches', pandocResult.matched.length, 1); eq('Pandoc cv.md: a conclusive run is not flagged unverified', diagnoseUnverified(pandocEntries, parseTailoredExperience(inflatedPayload), pandocResult), null); // One Pandoc feature at a time, each on an otherwise-plain entry — the // issue's table, row by row. const oneEntry = (h2, h3, title, dates) => parseCvExperience(`${h2}\n\n${h3}\n\n${title}\n${dates}\n`); const plainEntry = [{ company: 'Co', title: 'Engineer', dates: '2020-2024' }]; for (const heading of ['Experience', 'Work Experience', 'Professional Experience', 'Employment History', 'Work History']) { eq(`"## ${heading}" is a recognized Experience section`, oneEntry(`## ${heading}`, '### Co -- City', '**Engineer**', '2020-2024'), plainEntry); } eq('a bold + span h2 is recognized', oneEntry('## **[Experience]{.smallcaps}**', '### Co -- City', '**Engineer**', '2020-2024'), plainEntry); eq('a Pandoc {#id} attribute on the h2 is ignored', oneEntry('## Experience {#experience}', '### Co -- City', '**Engineer**', '2020-2024'), plainEntry); eq('"---" between company and location splits like "--"', oneEntry('## Experience', '### Co --- City', '**Engineer**', '2020-2024'), plainEntry); eq('a bold company heading is unwrapped', oneEntry('## Experience', '### **Co** -- City', '**Engineer**', '2020-2024'), plainEntry); eq('a trailing hard-break backslash on the title line is dropped', oneEntry('## Experience', '### Co -- City', '**Engineer**\\', '2020-2024'), plainEntry); eq('escaped characters in a title are unescaped', oneEntry('## Experience', '### Co -- City', '**C\\# \\| Engineer**', '2020-2024')[0]?.title, 'C# | Engineer'); eq('an unrelated "## Skills" section is still not Experience', oneEntry('## Skills', '### Co -- City', '**Engineer**', '2020-2024'), []); // Dates: any run of hyphens / en dashes / em dashes is one separator. eq('"--" dates equal "-" dates', normalizeDates('09/2021 -- Present'), normalizeDates('09/2021 - Present')); eq('"–" dates equal "-" dates', normalizeDates('09/2021 – Present'), normalizeDates('09/2021 - Present')); eq('"—" dates equal "-" dates', normalizeDates('09/2021 — Present'), normalizeDates('09/2021-Present')); // Nothing compared is never a pass (#4879). const someTailored = parseTailoredExperience(inflatedPayload); eq('zero parsed cv.md entries is unverified (cv-unparsed)', diagnoseUnverified([], someTailored, checkTitles([], someTailored))?.reason, 'cv-unparsed'); const strangers = parseTailoredExperience({ experience: [{ company: 'Initech', role: 'Engineer', dates: '1999' }] }); eq('parsed cv.md entries with no pairs is unverified (no-pairs)', diagnoseUnverified(cvEntries, strangers, checkTitles(cvEntries, strangers))?.reason, 'no-pairs'); eq('an empty payload is not flagged (nothing to check)', diagnoseUnverified(cvEntries, [], checkTitles(cvEntries, []))?.reason ?? null, null); const ambiguousOnly = checkTitles(dupCvEntries, parseTailoredExperience(dupCvPayload)); eq('an ambiguous-only run is reported as ambiguous, not as unverified', diagnoseUnverified(dupCvEntries, parseTailoredExperience(dupCvPayload), ambiguousOnly), null); // CLI exit-code contract (modes/pdf.md Step 17a relies on this distinction): // a genuine input failure (missing/malformed cv.md or payload) must exit // non-zero so the pipeline stops and repairs the input, while a completed // comparison — even one that FOUND mismatches or ambiguous groups — is // warn-only and always exits 0. Drives the real CLI as a subprocess against // fixture files, the only honest way to test process.exit() codes. const scriptPath = fileURLToPath(import.meta.url); const cliTmp = mkdtempSync(join(tmpdir(), 'cv-title-check-cli-')); try { const validCv = ` # CV -- Jane Doe ## Work Experience ### Acme Corp -- Remote **Senior Software Engineer** 2020-2024 - Did things `; writeFileSync(join(cliTmp, 'cv.md'), validCv); // cv.md resolves through getCareerOpsRoot(), not the cwd, so each run // points CAREER_OPS_ROOT at its fixture directory explicitly. const runCli = (args, cwd = cliTmp, root = cwd) => spawnSync(process.execPath, [scriptPath, ...args], { cwd, encoding: 'utf-8', env: { ...process.env, CAREER_OPS_ROOT: root }, }); // Missing payload argument entirely -> usage error, exit 1. eq('CLI exits 1 with no payload argument', runCli([]).status, 1); // Payload path that does not exist -> exit 1, never treated as "no mismatches". eq('CLI exits 1 when the payload file does not exist', runCli(['does-not-exist.json']).status, 1); // cv.md missing entirely -> exit 1 (this is the workspace-not-set-up case, // distinct from the check having run and found no mismatches). const noCvDir = mkdtempSync(join(tmpdir(), 'cv-title-check-nocv-')); try { writeFileSync(join(noCvDir, 'payload.json'), JSON.stringify({ experience: [] })); eq('CLI exits 1 when cv.md does not exist', runCli(['payload.json'], noCvDir).status, 1); } finally { rmSync(noCvDir, { recursive: true, force: true }); } // Malformed JSON payload -> exit 1, not silently treated as zero entries. writeFileSync(join(cliTmp, 'malformed.json'), '{ this is not valid json'); eq('CLI exits 1 on a malformed JSON payload', runCli(['malformed.json']).status, 1); // A payload mixing one valid entry with one incomplete entry (missing // dates) must fail loudly — never silently compare only the valid entry // and report success as though the whole CV had been checked. writeFileSync(join(cliTmp, 'partial.json'), JSON.stringify({ experience: [ { company: 'Acme Corp', role: 'Senior Software Engineer', dates: '2020-2024' }, { company: 'Globex Inc.', role: 'Engineer' }, // missing dates ], })); const partialRun = runCli(['partial.json', '--summary']); eq('CLI exits 1 for a payload with one incomplete experience entry (no silent partial comparison)', partialRun.status, 1); eq('the exit-1 partial-payload run does not print a clean-result summary', /No title drift found/i.test(partialRun.stdout), false); // A completed comparison that FINDS a title mismatch is still warn-only: // exit 0, distinguishing "the check ran and found something to warn // about" from "the check could not run at all". writeFileSync(join(cliTmp, 'mismatch.json'), JSON.stringify({ experience: [{ company: 'Acme Corp', role: 'Engineering Lead', dates: '2020-2024' }], })); const mismatchRun = runCli(['mismatch.json', '--summary']); eq('CLI exits 0 for a completed comparison that finds a title mismatch (warn-only)', mismatchRun.status, 0); eq('the exit-0 mismatch run still prints the warning', /title mismatch/i.test(mismatchRun.stdout), true); // A cv.md the parser cannot read must not produce a green check (#4879). const unparsedDir = mkdtempSync(join(tmpdir(), 'cv-title-check-unparsed-')); try { writeFileSync(join(unparsedDir, 'cv.md'), '# Jane Doe\n\n## Career\n\n### Acme Corp -- Remote\n\n**Senior Software Engineer**\n2020-2024\n'); writeFileSync(join(unparsedDir, 'p.json'), JSON.stringify({ experience: [{ company: 'Acme Corp', role: 'Engineering Lead', dates: '2020-2024' }], })); const unparsedRun = runCli(['p.json', '--summary'], unparsedDir); eq('CLI exits 0 when cv.md cannot be parsed (warn-only)', unparsedRun.status, 0); eq('an unparsed cv.md never prints the green check', /✅/.test(unparsedRun.stdout), false); eq('an unparsed cv.md says the title check did not run', /title check did not run/.test(unparsedRun.stdout), true); const unparsedJson = JSON.parse(runCli(['p.json'], unparsedDir).stdout); eq('JSON output carries the unverified reason', unparsedJson.unverified?.reason, 'cv-unparsed'); } finally { rmSync(unparsedDir, { recursive: true, force: true }); } // cv.md comes from the data root, not the cwd: run from a directory with a // decoy cv.md and point CAREER_OPS_ROOT at the real one. const rootDir = mkdtempSync(join(tmpdir(), 'cv-title-check-root-')); const cwdDir = mkdtempSync(join(tmpdir(), 'cv-title-check-cwd-')); try { writeFileSync(join(rootDir, 'cv.md'), validCv); writeFileSync(join(cwdDir, 'cv.md'), '# Decoy\n'); writeFileSync(join(cwdDir, 'p.json'), JSON.stringify({ experience: [{ company: 'Acme Corp', role: 'Engineering Lead', dates: '2020-2024' }], })); const rootRun = runCli(['p.json', '--summary'], cwdDir, rootDir); eq('CLI reads cv.md from CAREER_OPS_ROOT, not the cwd', /title mismatch/i.test(rootRun.stdout), true); } finally { rmSync(rootDir, { recursive: true, force: true }); rmSync(cwdDir, { recursive: true, force: true }); } // A completed comparison that finds an ambiguous {company, dates} group // is likewise warn-only, not a failure. const ambiguousCv = validCv + ` ### Acme Corp -- Remote **Contractor** 2020-2024 - Did other things `; const ambiguousDir = mkdtempSync(join(tmpdir(), 'cv-title-check-ambiguous-')); try { writeFileSync(join(ambiguousDir, 'cv.md'), ambiguousCv); writeFileSync(join(ambiguousDir, 'mismatch.json'), JSON.stringify({ experience: [{ company: 'Acme Corp', role: 'Senior Software Engineer', dates: '2020-2024' }], })); const ambiguousRun = runCli(['mismatch.json', '--summary'], ambiguousDir); eq('CLI exits 0 for a completed comparison that finds an ambiguous group (warn-only)', ambiguousRun.status, 0); eq('the exit-0 ambiguous run still prints the warning', /ambiguous/i.test(ambiguousRun.stdout), true); } finally { rmSync(ambiguousDir, { recursive: true, force: true }); } } finally { rmSync(cliTmp, { recursive: true, force: true }); } console.log(`\ncv-title-check self-test: ${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); } // ── CLI ────────────────────────────────────────────────────────────── const args = process.argv.slice(2); const USAGE = `Usage: node cv-title-check.mjs [--summary] node cv-title-check.mjs --self-test Pairs each tailored-CV {company, dates} entry against cv.md's canonical entry and flags an exact-string job-title mismatch (case and whitespace normalized, never fuzzy). Warn-only: it edits neither file. --summary human-readable table instead of JSON --self-test run the built-in cases against no payload --help, -h show this message `; const summaryMode = args.includes('--summary'); const selfTestMode = args.includes('--self-test'); const payloadPathArg = args.find(a => !a.startsWith('--')); if (isMainModule(import.meta.url)) { // Checked before the operand, which --self-test also legitimately lacks: // without this, `--help` fell through to the "Usage:" error path and exited 1 // while the real work was skipped for the wrong reason. if (args.includes('--help') || args.includes('-h')) { console.log(USAGE); } else if (selfTestMode) { runSelfTest(); } else { if (!payloadPathArg || !existsSync(payloadPathArg)) { console.error('Usage: node cv-title-check.mjs [--summary]'); console.error(' node cv-title-check.mjs --self-test'); process.exit(1); } if (!existsSync(CV_PATH)) { console.error(`Error: ${CV_PATH} not found — this is a user-layer file, create it first.`); process.exit(1); } let payload; try { payload = JSON.parse(readFileSync(payloadPathArg, 'utf-8')); } catch (err) { console.error(`Error: could not parse ${payloadPathArg} as JSON: ${err.message}`); process.exit(1); } const cvEntries = parseCvExperience(readFileSync(CV_PATH, 'utf-8')); let tailoredEntries; try { tailoredEntries = parseTailoredExperience(payload); } catch (err) { console.error(`Error: ${err.message}`); process.exit(1); } const result = checkTitles(cvEntries, tailoredEntries); const unverified = diagnoseUnverified(cvEntries, tailoredEntries, result); const compared = result.matched.length + result.mismatches.length; if (summaryMode) { console.log('\nCV Title Consistency Check'); console.log('─'.repeat(40)); console.log(`Entries compared: ${compared}`); if (unverified) { console.log(`⚠️ ${unverified.message}`); } else if (result.mismatches.length > 0) { console.log(`⚠️ ${result.mismatches.length} title mismatch(es) found:`); for (const m of result.mismatches) { console.log(`\n ${m.company} (${m.dates})`); console.log(` cv.md: "${m.cvTitle}"`); console.log(` tailored CV: "${m.tailoredTitle}"`); } } else if (compared > 0 || tailoredEntries.length === 0) { // compared === 0 with only ambiguous groups is not a pass either: // the ambiguous block below is the whole result. console.log('✅ No title drift found — every matched entry uses cv.md\'s canonical title.'); } if (result.ambiguous.length > 0) { console.log(`\n⚠️ ${result.ambiguous.length} ambiguous {company, dates} key(s) — multiple entries on one or both sides, not auto-resolved:`); for (const a of result.ambiguous) { console.log(`\n ${a.company} (${a.dates})`); console.log(` cv.md titles: ${a.cvTitles.map(t => `"${t}"`).join(', ')}`); console.log(` tailored CV titles: ${a.tailoredTitles.map(t => `"${t}"`).join(', ')}`); } } if (result.unmatchedTailored.length > 0) { console.log(`\n (${result.unmatchedTailored.length} tailored entr${result.unmatchedTailored.length === 1 ? 'y has' : 'ies have'} no matching {company, dates} in cv.md — not checked)`); } } else { // Additive key: null when the run was conclusive, {reason, message} // when nothing was compared. console.log(JSON.stringify({ ...result, unverified }, null, 2)); } // Warn-only: a title mismatch is never a failing exit code. process.exit(0); } }