cv-title-check.mjs
Rama main · réplica de lectura del repositorio original.
#!/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<typeof checkTitles>} 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 <tailored-cv-payload.json> [--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 <tailored-cv-payload.json> [--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);
}
}