ats-payload.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* ats-payload.mjs — ATS payload transform: one safe fold, three lints (#3251).
*
* Follows from #3202, whose conclusion was that an ATS variant is not a template
* swap. A template owns the DOM; it does not own what is IN the payload, and some
* of what an ATS parser mishandles is in the payload. By the time a template sees
* `competencies: [...]` the decision that breaks parsing has already been made.
*
* THE FOLD (applied — pure data movement, no text invented, fully reversible)
*
* `build-cv-html.mjs` renders competencies as bare tag spans into a container
* whose separation is entirely visual (`.competencies-grid { gap: 8px }`).
* There is no delimiter CHARACTER between one competency and the next — the gap
* is CSS. Extract the text layer and adjacent competencies run together into a
* single token. On top of that the block ships under `Core Competencies`, which
* is not a section name parsers recognise, so it is frequently dropped whole
* rather than merely mangled.
*
* `Skills` IS recognised, and already renders through `buildSkills()` as
* `<span class="skill-category">Cat:</span> a, b, c` — comma-delimited, under a
* header parsers look for. So the same facts, expressed two ways, survive or
* don't depending on which array they sit in. This script moves them into the
* array that survives. Nothing is rewritten, nothing is invented.
*
* THE THREE LINTS (reported, never applied — each needs a decision only the
* author can make, which is where AGENTS.md's no-fabrication rule draws the line)
*
* employer-in-role An employer name inside `role` yields a phantom
* employer in the parsed record — the trap for
* consulting and agency work. Detecting "this substring
* is an employer" is not something a script should be
* confident about, and silently rewriting a job title is
* worse than the mangling it prevents.
* parenthetical-in-company
* `(Cloud Platform Division)` is kept verbatim as part of
* the employer name, giving a record no search matches.
* Stripping it is easy; deciding where that detail GOES
* instead is authoring.
* multiple-date-ranges Two stints at one employer parse as one. Splitting them
* needs someone to decide which bullets belong to which
* stint. A script can see the second range; it cannot
* allocate the bullets.
*
* A tool that reported four findings and applied one is trustworthy; a tool that
* applied four would be guessing three times per run.
*
* CONTRACT: payload JSON in, payload JSON out on stdout, findings on stderr.
* Zero-LLM, deterministic, offline, and read-only with respect to user-layer
* files — it consumes a payload and emits a new one, and never writes back to
* `cv.md` or `config/profile.yml`. Same family as `jd-skill-gap.mjs` and
* `verify-cv-facts.mjs`. The input object is never mutated.
*
* Usage:
* node ats-payload.mjs cv.json > cv-ats.json
* node ats-payload.mjs cv.json --summary > cv-ats.json
* cat cv.json | node ats-payload.mjs - > cv-ats.json
* node build-cv-html.mjs cv-ats.json out.html templates/ats/cv-template.ats.html
* node ats-payload.mjs --self-test
*
* Exit 0 whenever the transform succeeded, findings or not: the lints are
* advisory by design and the payload on stdout is always usable. Exit 1 only for
* an unreadable or malformed input.
*/
import { readFileSync } from 'fs';
import { validateFlags } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
// Mirrors DEFAULT_SECTION_TITLES.competencies in build-cv-html.mjs. Used only
// when the payload carries no `sections.competencies` of its own — a localized
// payload keeps its own label, because relabelling the user's section title
// would be inventing text rather than moving it.
const DEFAULT_COMPETENCIES_TITLE = 'Core Competencies';
// ── Helpers ─────────────────────────────────────────────────────────────────
/** Trimmed non-empty strings from a payload array (numbers coerced, as the
* builder's own `escapeHtml(String(tag))` does). Objects/null are structural
* noise and are dropped rather than stringified into "[object Object]". */
function toItemList(value) {
const raw = Array.isArray(value)
? value
: typeof value === 'string' ? value.split(',') : [];
return raw
.filter((v) => v !== null && v !== undefined && typeof v !== 'object')
.map((v) => String(v).trim())
.filter(Boolean);
}
/** Category-name identity for the idempotency check: case- and
* whitespace-insensitive, trailing colon ignored (the template renders the
* colon, but a hand-written payload sometimes carries it). */
function categoryKey(name) {
return String(name ?? '')
.replace(/\s+/g, ' ')
.replace(/:\s*$/, '')
.trim()
.toLowerCase();
}
/** The label the fold uses for the category it writes. A payload carrying its
* own localized `sections.competencies` keeps it — relabelling the user's
* section title would be inventing text rather than moving it. */
function resolveCompetenciesCategory(payload) {
return (typeof payload?.sections?.competencies === 'string'
&& payload.sections.competencies.trim())
|| DEFAULT_COMPETENCIES_TITLE;
}
/**
* Members `toItemList()` DROPS rather than coerces, reported with their index.
*
* The line is drawn at what is actually lost, not at what is not a string.
* `toItemList()` passes every scalar through `String()`, so a numeric or boolean
* member survives with its value intact — and that is a deliberate, tested
* position in this repo, not an accident: `build-cv-html.mjs`'s `escapeHtml()`
* used to `return ''` for anything non-string, which silently dropped numeric
* years and dates from shipped CVs (see tests/cv-numeric-scalars.test.mjs), and
* `buildCompetencies()` renders `escapeHtml(String(tag))` for exactly that
* reason. Rejecting a numeric member here would make this script stricter than
* the builder it feeds and refuse a payload that renders correctly today.
*
* Objects, arrays, `null` and `undefined` have no scalar value to carry, are
* dropped outright, and are what this exists to catch.
*
* @param {unknown[]} list - Array whose members would go through toItemList().
* @returns {Array<{index: number, type: string}>} One entry per dropped member.
*/
function lostMembers(list) {
const lost = [];
list.forEach((value, index) => {
if (value === null || value === undefined || typeof value === 'object') {
lost.push({ index, type: Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value });
}
});
return lost;
}
// ── Payload shape validation ────────────────────────────────────────────────
/**
* Check the three fields this script reads, and refuse a payload whose shape
* would make the run quietly wrong (CodeRabbit, PR #3422).
*
* Both failures this catches are silent, and silence is what makes them worse
* than a crash:
*
* `skills` non-array — `foldCompetencies()` starts from `[]`, so a payload
* carrying `"skills": "Python, Go"` has that value DISCARDED and is handed
* back a well-formed-looking artifact with the skills gone. A transform whose
* entire claim is "pure data movement, nothing invented, fully reversible"
* must not be the thing that loses data, and it must not lose it into output
* that looks correct.
*
* `experience` non-array — `lintPayload()` iterates `[]` and reports nothing,
* which prints as "✅ No lint findings" and reads exactly like a clean
* payload. That is the same shape `jd-skill-gap.mjs` added diagnoseExtraction()
* for: an empty result is indistinguishable from a clean bill of health, and
* the caller acts on the wrong one.
*
* Refusing rather than coercing is the point. There is no honest place to put a
* string `skills` value inside `skills[]`, and inventing the structure to hold
* it is exactly the authoring this script refuses to do everywhere else — so
* the malformed payload is the user's to fix, loudly, before anything is
* written.
*
* A `competencies` STRING is accepted rather than refused: `toItemList()` splits
* it on commas, mirroring the string-or-array `items` the builder's `joinItems()`
* already takes for a skills category. Absent and `null` both mean "not
* supplied" and are always fine.
*
* @param {object} payload - Parsed CV payload.
* @returns {string[]} One message per problem; empty when the shape is usable.
*/
export function validatePayloadShape(payload) {
const errors = [];
const shapeOf = (v) => (Array.isArray(v) ? 'array' : typeof v);
const present = (v) => v !== undefined && v !== null;
if (present(payload.skills) && !Array.isArray(payload.skills)) {
errors.push(`\`skills\` must be an array of {category, items} objects, got ${shapeOf(payload.skills)}. `
+ 'Folding into it would silently discard the value you supplied.');
}
if (present(payload.competencies)
&& !Array.isArray(payload.competencies)
&& typeof payload.competencies !== 'string') {
errors.push(`\`competencies\` must be an array of strings (a comma-separated string is also accepted), `
+ `got ${shapeOf(payload.competencies)}.`);
}
if (present(payload.experience) && !Array.isArray(payload.experience)) {
errors.push(`\`experience\` must be an array of entries, got ${shapeOf(payload.experience)}. `
+ 'The lints would report nothing, which is indistinguishable from a clean payload.');
}
// Member level, same defect one layer down (CodeRabbit, PR #3422). The array
// container being right is not enough: `["AWS", {"name":"Kubernetes"}]` passed
// every check above while toItemList() dropped the object on the way through,
// so the fold emitted `items: "AWS"` and the second competency was gone from a
// payload that looked fine.
const describe = (lost) => lost.map((l) => `[${l.index}] (${l.type})`).join(', ');
if (Array.isArray(payload.competencies)) {
const lost = lostMembers(payload.competencies);
if (lost.length > 0) {
errors.push(`\`competencies\` has ${lost.length} member(s) with no scalar value to move: `
+ `${describe(lost)}. They would be dropped rather than folded. `
+ 'Strings are expected; numbers and booleans are coerced.');
}
}
// The same loss in the field the fold WRITES to. When a competency category
// already exists, the fold re-joins its items through toItemList() to union
// them, and a non-scalar member there disappears exactly the same way.
//
// Guarded on a fold actually happening, and on the one category it would touch,
// so a payload this script would otherwise pass through untouched is never
// rejected for a field it was never going to rewrite.
if (toItemList(payload.competencies).length > 0 && Array.isArray(payload.skills)) {
const category = resolveCompetenciesCategory(payload);
const target = payload.skills.find(
(entry) => entry && typeof entry === 'object' && categoryKey(entry.category) === categoryKey(category),
);
if (target && Array.isArray(target.items)) {
const lost = lostMembers(target.items);
if (lost.length > 0) {
errors.push(`the existing \`${category}\` skills category has ${lost.length} member(s) in \`items\` `
+ `with no scalar value: ${describe(lost)}. Merging the competencies into it would drop them.`);
}
} else if (target && present(target.items) && typeof target.items !== 'string') {
// A merge target whose `items` is neither a string nor an array: the merge
// reads it through toItemList(), gets `[]`, and REPLACES it with the folded
// competencies — the supplied value silently overwritten.
//
// Note the asymmetry with the competency members above, which is real and
// not an inconsistency. A member is coerced by `String()` on both sides
// (here and in `buildCompetencies()`'s `escapeHtml(String(tag))`), so a
// number carries its value through. An `items` CONTAINER is not: the
// builder's `joinItems()` returns `''` for anything that is neither string
// nor array, so a numeric `items` has no value-preserving path in either
// tool. With no way to carry it and no honest way to invent one, refusing
// is the only non-destructive answer.
errors.push(`the existing \`${category}\` skills category has \`items\` of type `
+ `${shapeOf(target.items)}; a string or array is expected. Merging the competencies `
+ 'into it would overwrite that value.');
}
}
return errors;
}
// ── The transform: fold competencies[] into skills[] ─────────────────────────
/**
* Move `competencies[]` into `skills[]` as one comma-delimited category and
* empty the source array.
*
* IDEMPOTENT (the edge santifer asked to have pinned by a test): running this on
* an already-folded payload is a no-op, not a duplicate category. Two mechanisms
* carry that, because either alone is incomplete:
*
* 1. An empty/absent `competencies` returns the payload untouched — the second
* run of a plain `transform | transform` pipeline.
* 2. When a skills category with the same name already exists, its items are
* UNIONed in place (order preserved, exact duplicates dropped) instead of a
* second category being prepended — the case where a payload was re-edited
* with some competencies re-added after a first fold.
*
* Callers are expected to have run `validatePayloadShape()` first: this function
* normalizes a non-array `skills` to `[]`, which DISCARDS it. The CLI validates
* at its boundary so that path is unreachable there.
*
* @param {object} payload - Parsed CV payload. Never mutated.
* @returns {{payload: object, transform: object}} New payload + what was done.
*/
export function foldCompetencies(payload) {
const source = payload && typeof payload === 'object' ? payload : {};
const competencies = toItemList(source.competencies);
const category = resolveCompetenciesCategory(source);
if (competencies.length === 0) {
return {
payload: source,
transform: {
name: 'fold-competencies-into-skills',
applied: false,
reason: 'no competencies to fold',
category,
moved: 0,
},
};
}
const skills = Array.isArray(source.skills) ? source.skills : [];
const existingIndex = skills.findIndex(
(s) => s && typeof s === 'object' && categoryKey(s.category) === categoryKey(category),
);
let nextSkills;
let merged;
if (existingIndex === -1) {
// Prepend: competencies are the keyword block a parser should hit first,
// and prepending keeps them ahead of the language/framework categories.
nextSkills = [{ category, items: competencies.join(', ') }, ...skills];
merged = false;
} else {
const existing = skills[existingIndex];
const existingItems = toItemList(existing.items);
const seen = new Set(existingItems.map((i) => i.toLowerCase()));
const added = competencies.filter((i) => {
const key = i.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
nextSkills = skills.slice();
nextSkills[existingIndex] = {
...existing,
items: [...existingItems, ...added].join(', '),
};
merged = true;
}
return {
// `competencies: []` rather than a deleted key: build-cv-html.mjs and
// stripEmptySections() both already treat an empty array as "drop the whole
// block, header included", and keeping the key makes the fold obvious to a
// human diffing the two payloads.
payload: { ...source, competencies: [], skills: nextSkills },
transform: {
name: 'fold-competencies-into-skills',
applied: true,
merged,
category,
moved: competencies.length,
items: competencies,
},
};
}
// ── The three lints ─────────────────────────────────────────────────────────
// Legal-form tokens that mark a substring as an organisation name rather than a
// job title. Deliberately narrow: a suffix is near-conclusive evidence, whereas
// a bare capitalized noun in a title is not.
const CORPORATE_SUFFIX_RE = /\b(?:inc|llc|l\.l\.c|ltd|limited|gmbh|mbh|ag|s\.?a\.?s|sarl|s\.?a|s\.?l|b\.?v|n\.?v|plc|corp|corporation|co|company|pty|oy|ab|a\/s|aps|kk|k\.k|holdings|group)\b\.?/i;
// Prepositions that attach an employer to a title. `at` and `@` only: " for "
// reads just as naturally as a team or product ("Engineer for Payments"), and a
// lint that cries wolf on ordinary titles gets ignored, which costs more than
// the case it would have caught.
const EMPLOYER_PREPOSITION_RE = /(?:\s(?:at|@)\s|\s@|^@)/i;
// Explicit client framing — the consulting/agency shape this lint exists for.
const CLIENT_MARKER_RE = /\b(?:client|customer|on behalf of|seconded to|via|through)\s*[:—–-]?\s*\S/i;
// A parenthetical inside `company`. Both ASCII and CJK full-width brackets: a
// ja/zh payload writes (云平台事业部)and the parser keeps it verbatim just the same.
const PARENTHETICAL_RE = /[((]\s*([^))]*?)\s*[))]/;
// One date range: an endpoint, a separator, an endpoint. Endpoint forms cover
// "June 2022", "Jun. 2022", "06/2022", "2022" and the open end ("Present").
const DATE_POINT = '(?:(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\\.?\\s*,?\\s*\\d{4}'
+ '|\\d{1,2}\\s*[/.]\\s*\\d{4}'
+ '|\\d{4}'
+ '|present|current|now|ongoing|date)';
const DATE_SEPARATOR = '\\s*(?:[-–—]{1,2}|\\bto\\b|\\buntil\\b|\\bthrough\\b)\\s*';
const DATE_RANGE_RE = new RegExp(`${DATE_POINT}${DATE_SEPARATOR}${DATE_POINT}`, 'gi');
/** Non-overlapping date ranges in a `dates` string. */
export function countDateRanges(dates) {
if (typeof dates !== 'string') return 0;
// Fresh lastIndex per call: the regex is module-level and /g is stateful.
DATE_RANGE_RE.lastIndex = 0;
return (dates.match(DATE_RANGE_RE) || []).length;
}
/** True when `needle` appears in `haystack` on non-alphanumeric boundaries, so
* "Acme" matches "Acme Cloud Engineer" but not "Acmerica". */
function containsAsToken(haystack, needle) {
const hay = String(haystack).toLowerCase();
const need = String(needle).trim().toLowerCase();
if (need.length < 3) return false;
let from = 0;
for (;;) {
const at = hay.indexOf(need, from);
if (at === -1) return false;
const before = at === 0 ? '' : hay[at - 1];
const after = hay[at + need.length] ?? '';
if (!/[a-z0-9]/i.test(before) && !/[a-z0-9]/i.test(after)) return true;
from = at + 1;
}
}
/**
* Report the three judgement calls. Never edits anything.
*
* @param {object} payload - Parsed CV payload (pre- or post-fold; the lints read
* only `experience[]`, which the fold does not touch).
* @returns {Array<object>} Findings, in payload order.
*/
export function lintPayload(payload) {
const entries = Array.isArray(payload?.experience) ? payload.experience : [];
const findings = [];
// Every company named anywhere in the payload. A client name that leaked into
// a title is very often another entry's employer, and that cross-entry match
// is the one high-confidence signal available without a company database.
const knownCompanies = entries
.filter((e) => e && typeof e === 'object' && typeof e.company === 'string')
.map((e) => e.company.trim())
.filter((c) => c.length >= 3);
entries.forEach((entry, index) => {
if (!entry || typeof entry !== 'object') return;
// ── 1. employer-in-role ──────────────────────────────────────────────
const role = typeof entry.role === 'string' ? entry.role : '';
if (role) {
const signals = [];
if (CORPORATE_SUFFIX_RE.test(role)) signals.push('corporate-suffix');
if (EMPLOYER_PREPOSITION_RE.test(role)) signals.push('employer-preposition');
if (CLIENT_MARKER_RE.test(role)) signals.push('client-marker');
const matchedCompany = knownCompanies.find((c) => containsAsToken(role, c));
if (matchedCompany) signals.push('known-company-name');
if (signals.length > 0) {
findings.push({
code: 'employer-in-role',
severity: 'review',
path: `experience[${index}].role`,
value: role,
signals,
...(matchedCompany ? { matchedCompany } : {}),
message: `Role reads as if it carries an employer name (${signals.join(', ')}). `
+ 'A parser lifts that into the employer field and the record gains a phantom '
+ 'employer. Rewriting a job title is authoring, not cleanup — decide yourself '
+ 'whether the employer belongs in `company` instead.',
});
}
}
// ── 2. parenthetical-in-company ──────────────────────────────────────
const company = typeof entry.company === 'string' ? entry.company : '';
const paren = company.match(PARENTHETICAL_RE);
if (paren) {
findings.push({
code: 'parenthetical-in-company',
severity: 'review',
path: `experience[${index}].company`,
value: company,
parenthetical: paren[1],
message: `The parenthetical "${paren[1]}" is kept verbatim as part of the employer `
+ 'name, so the record matches no search for the employer. Stripping it is easy; '
+ 'deciding where that detail goes instead (role, location, a bullet) is yours.',
});
}
// ── 3. multiple-date-ranges ──────────────────────────────────────────
const dates = typeof entry.dates === 'string' ? entry.dates : '';
const ranges = countDateRanges(dates);
if (ranges > 1) {
findings.push({
code: 'multiple-date-ranges',
severity: 'review',
path: `experience[${index}].dates`,
value: dates,
ranges,
message: `${ranges} date ranges in one entry parse as one continuous stint. Splitting `
+ 'them into separate entries needs someone to decide which bullets belong to which '
+ 'stint — this script can see the second range, it cannot allocate the bullets.',
});
}
});
return findings;
}
// ── Orchestration ───────────────────────────────────────────────────────────
/**
* The whole contract: one transform applied, three lints reported.
*
* @param {object} payload - Parsed CV payload. Never mutated.
* @returns {{payload: object, transform: object, findings: Array<object>}}
*/
export function transformPayload(payload) {
const { payload: folded, transform } = foldCompetencies(payload);
return { payload: folded, transform, findings: lintPayload(folded) };
}
// ── Human summary ───────────────────────────────────────────────────────────
function formatSummary({ transform, findings }) {
const lines = [];
lines.push('');
lines.push('ATS Payload Transform');
lines.push('─'.repeat(60));
if (transform.applied) {
lines.push(` ✅ Folded ${transform.moved} competenc${transform.moved === 1 ? 'y' : 'ies'} `
+ `into skills[] as "${transform.category}" (${transform.merged ? 'merged into existing category' : 'new category'}).`);
} else {
lines.push(` ⏭️ No fold applied — ${transform.reason}.`);
}
lines.push('');
if (findings.length === 0) {
lines.push(' ✅ No lint findings.');
} else {
lines.push(` ⚠️ ${findings.length} finding${findings.length === 1 ? '' : 's'} — reported, NOT applied:`);
for (const f of findings) {
lines.push('');
lines.push(` [${f.code}] ${f.path}`);
lines.push(` value: ${f.value}`);
lines.push(` ${f.message}`);
}
}
lines.push('');
lines.push(' The transformed payload is on stdout; these findings are yours to decide on.');
lines.push('');
return lines.join('\n');
}
// ── Self-test ───────────────────────────────────────────────────────────────
function runSelfTest() {
let passed = 0;
let failed = 0;
const eq = (label, actual, expected) => {
const ok = JSON.stringify(actual) === JSON.stringify(expected);
if (ok) { passed++; console.log(` ✅ ${label}`); }
else { failed++; console.log(` ❌ ${label}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`); }
};
// ── The fold ──
const base = {
competencies: ['RAG Pipelines', 'LLMOps', 'Kubernetes & Docker'],
skills: [{ category: 'Languages', items: 'Python, Go' }],
};
const once = transformPayload(base);
eq('competencies are emptied', once.payload.competencies, []);
eq('competencies become a comma-delimited skills category',
once.payload.skills[0], { category: 'Core Competencies', items: 'RAG Pipelines, LLMOps, Kubernetes & Docker' });
eq('the existing skills categories survive after it', once.payload.skills[1], base.skills[0]);
eq('the input payload is not mutated', base.competencies.length, 3);
// Idempotency — the edge #3251 asked to have pinned.
const twice = transformPayload(once.payload);
eq('a second run is a no-op on skills', twice.payload.skills, once.payload.skills);
eq('a second run applies no fold', twice.transform.applied, false);
eq('a second run leaves competencies empty', twice.payload.competencies, []);
// Idempotency, harder case: competencies re-added after a fold must merge
// into the existing category, not create a duplicate one.
const reAdded = { ...once.payload, competencies: ['LLMOps', 'Evaluation Harnesses'] };
const remerged = transformPayload(reAdded);
eq('a re-added competency merges instead of duplicating the category',
remerged.payload.skills.filter((s) => s.category === 'Core Competencies').length, 1);
eq('the merge unions items and drops the duplicate',
remerged.payload.skills[0].items, 'RAG Pipelines, LLMOps, Kubernetes & Docker, Evaluation Harnesses');
eq('the merge is reported as a merge', remerged.transform.merged, true);
// A localized payload keeps its own section title as the category label.
const localized = transformPayload({
sections: { competencies: 'Competencias Clave' },
competencies: ['Arquitectura Cloud'],
});
eq('a localized section title becomes the category label',
localized.payload.skills[0].category, 'Competencias Clave');
// Absent/empty inputs.
eq('an absent competencies key applies no fold', foldCompetencies({}).transform.applied, false);
eq('an empty competencies array applies no fold', foldCompetencies({ competencies: [] }).transform.applied, false);
eq('a payload with no skills key still gets the folded category',
foldCompetencies({ competencies: ['A'] }).payload.skills.length, 1);
// ── Shape validation (PR #3422) ──
// The exact input CodeRabbit asked to have covered: a present-but-non-array
// `skills` alongside competencies to fold. Before the guard this silently
// discarded "Python" and returned a clean-looking payload.
eq('a string skills field is refused, naming the field',
validatePayloadShape({ competencies: ['AWS'], skills: 'Python' }).length, 1);
eq('the skills error says the value would be discarded',
/skills.*discard/is.test(validatePayloadShape({ competencies: ['AWS'], skills: 'Python' })[0]), true);
eq('a non-array experience is refused',
validatePayloadShape({ experience: { company: 'Acme' } }).length, 1);
eq('an object competencies field is refused',
validatePayloadShape({ competencies: { a: 1 } }).length, 1);
eq('a valid payload passes validation',
validatePayloadShape({ competencies: ['AWS'], skills: [{ category: 'L', items: 'Go' }], experience: [] }), []);
eq('absent fields pass validation', validatePayloadShape({}), []);
eq('null fields mean "not supplied" and pass validation',
validatePayloadShape({ skills: null, competencies: null, experience: null }), []);
eq('a comma-separated competencies string is accepted, not refused',
validatePayloadShape({ competencies: 'AWS, GCP' }), []);
eq('and that string still folds into a category',
foldCompetencies({ competencies: 'AWS, GCP' }).payload.skills[0].items, 'AWS, GCP');
// Member level (PR #3422, second pass). The reported input: the array
// container is right, but a member has no scalar value and toItemList() drops
// it, so the fold emitted `items: "AWS"` and lost the second competency.
const mixed = { competencies: ['AWS', { name: 'Kubernetes' }] };
eq('a mixed-type competencies array is refused', validatePayloadShape(mixed).length, 1);
eq('the error names the offending index and type',
/\[1\] \(object\)/.test(validatePayloadShape(mixed)[0]), true);
eq('and that member really was being dropped before the guard',
foldCompetencies(mixed).payload.skills[0].items, 'AWS');
eq('a null member is refused too',
validatePayloadShape({ competencies: ['AWS', null] }).length, 1);
eq('a nested-array member is refused too',
validatePayloadShape({ competencies: ['AWS', ['GCP']] }).length, 1);
// The line is at what is LOST, not at what is non-string. A numeric scalar
// survives String() with its value intact, and build-cv-html.mjs renders it
// (tests/cv-numeric-scalars.test.mjs) — rejecting it here would make this
// script stricter than the builder it feeds.
eq('a numeric competency is accepted, not refused',
validatePayloadShape({ competencies: ['AWS', 2024] }), []);
eq('and it keeps its value through the fold',
foldCompetencies({ competencies: ['AWS', 2024] }).payload.skills[0].items, 'AWS, 2024');
eq('a boolean competency is accepted too',
validatePayloadShape({ competencies: [true] }), []);
// The same loss in the field the fold writes to, guarded on a fold happening.
eq('a non-scalar in the merged-into skills category is refused',
validatePayloadShape({
competencies: ['A'],
skills: [{ category: 'Core Competencies', items: ['X', { y: 1 }] }],
}).length, 1);
eq('an untouched skills category is not policed for it',
validatePayloadShape({
competencies: ['A'],
skills: [{ category: 'Languages', items: ['X', { y: 1 }] }],
}), []);
eq('and neither is it when no fold will happen',
validatePayloadShape({
skills: [{ category: 'Core Competencies', items: ['X', { y: 1 }] }],
}), []);
// Merge target whose `items` is a scalar (PR #3422, third pass). toItemList()
// returns [] for it and the merge REPLACES the value — silently overwritten.
const scalarItems = (items) => ({ competencies: ['A'], skills: [{ category: 'Core Competencies', items }] });
eq('a numeric items on the merge target is refused', validatePayloadShape(scalarItems(2024)).length, 1);
eq('a boolean items on the merge target is refused', validatePayloadShape(scalarItems(true)).length, 1);
eq('an object items on the merge target is refused', validatePayloadShape(scalarItems({ a: 1 })).length, 1);
eq('the error names the offending type',
/items` of type number/.test(validatePayloadShape(scalarItems(2024))[0]), true);
eq('and that value really was being overwritten before the guard',
foldCompetencies(scalarItems(2024)).payload.skills[0].items, 'A');
// The container forms the merge can actually carry, and the absent case.
eq('a string items on the merge target is accepted', validatePayloadShape(scalarItems('X')), []);
eq('and it merges rather than being replaced',
foldCompetencies(scalarItems('X')).payload.skills[0].items, 'X, A');
eq('an array items on the merge target is accepted', validatePayloadShape(scalarItems(['X'])), []);
eq('an absent items on the merge target is accepted',
validatePayloadShape({ competencies: ['A'], skills: [{ category: 'Core Competencies' }] }), []);
// Same rule as before: only the category the fold touches is policed.
eq('a scalar items on an untouched category is not policed',
validatePayloadShape({ competencies: ['A'], skills: [{ category: 'Languages', items: 2024 }] }), []);
// ── The lints ──
const codes = (p) => lintPayload(p).map((f) => f.code);
eq('a clean entry produces no findings', codes({
experience: [{ company: 'Acme', role: 'Senior Engineer', dates: 'June 2022 - Present' }],
}), []);
eq('an employer preposition in the role is reported', codes({
experience: [{ company: 'Consultancy', role: 'Lead Engineer at Globex', dates: '2020 - 2022' }],
}), ['employer-in-role']);
eq('a corporate suffix in the role is reported', codes({
experience: [{ company: 'Consultancy', role: 'Platform Lead, Initech Inc.', dates: '2020 - 2022' }],
}), ['employer-in-role']);
eq("another entry's employer appearing in a role is reported", codes({
experience: [
{ company: 'Globex', role: 'Staff Engineer', dates: '2018 - 2020' },
{ company: 'Consultancy', role: 'Globex Platform Lead', dates: '2020 - 2022' },
],
}), ['employer-in-role']);
eq('a company substring inside a longer word is not reported', codes({
experience: [
{ company: 'Acme', role: 'Staff Engineer', dates: '2018 - 2020' },
{ company: 'Consultancy', role: 'Acmerican Studies Lead', dates: '2020 - 2022' },
],
}), []);
eq('a team named with "for" is not mistaken for an employer', codes({
experience: [{ company: 'Acme', role: 'Engineer for Payments Platform', dates: '2020 - 2022' }],
}), []);
eq('a parenthetical in the company is reported', codes({
experience: [{ company: 'Globex (Cloud Platform Division)', role: 'Engineer', dates: '2020 - 2022' }],
}), ['parenthetical-in-company']);
eq('a full-width parenthetical is reported too', codes({
experience: [{ company: '日立製作所(クラウド事業部)', role: 'エンジニア', dates: '2020 - 2022' }],
}), ['parenthetical-in-company']);
eq('one date range is not reported', countDateRanges('June 2022 - Present'), 1);
eq('a numeric single range is not reported', countDateRanges('06/2018 - 09/2020'), 1);
eq('two comma-separated ranges are counted', countDateRanges('2016 - 2019, 2021 - Present'), 2);
eq('two ranges joined by "and" are counted', countDateRanges('Jan 2016 to Dec 2018 and Mar 2021 until Present'), 2);
eq('two date ranges in one entry are reported', codes({
experience: [{ company: 'Acme', role: 'Engineer', dates: '2016 - 2019, 2021 - Present' }],
}), ['multiple-date-ranges']);
// The whole contract in one run: exactly one transform, three findings.
const full = transformPayload({
competencies: ['RAG Pipelines'],
experience: [{
company: 'Globex (Cloud Platform Division)',
role: 'Lead Engineer at Initech',
dates: '2016 - 2019, 2021 - Present',
}],
});
eq('one transform applied', full.transform.applied, true);
eq('three lints reported, none applied', full.findings.map((f) => f.code),
['employer-in-role', 'parenthetical-in-company', 'multiple-date-ranges']);
eq('every finding is advisory', full.findings.every((f) => f.severity === 'review'), true);
eq('the lints changed nothing in the payload',
full.payload.experience[0], {
company: 'Globex (Cloud Platform Division)',
role: 'Lead Engineer at Initech',
dates: '2016 - 2019, 2021 - Present',
});
console.log(`\nats-payload self-test: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
// ── CLI ─────────────────────────────────────────────────────────────────────
const USAGE = `Usage: node ats-payload.mjs <payload.json|-> [--summary] > payload-ats.json
node ats-payload.mjs --self-test
Applies ONE safe transform and reports THREE lints on a build-cv-html.mjs payload.
transform fold competencies[] into skills[] as one comma-delimited category
under a header ATS parsers recognise (idempotent, reversible)
lints employer-in-role · parenthetical-in-company · multiple-date-ranges
— reported only, because each needs a decision only you can make
The transformed payload goes to STDOUT; findings go to STDERR (JSON by default,
a human summary with --summary). Read-only: nothing is written back to cv.md or
config/profile.yml. Exits 0 whenever the transform succeeded, findings or not.`;
if (isMainModule(import.meta.url)) {
const args = process.argv.slice(2);
// `-` is the stdin OPERAND, not a flag. validateFlags() rejects any token
// starting with `-` that it does not know, so it has to be withheld here —
// listing it as a known flag instead would print it in the "Valid flags:"
// line of every typo message, which is where a reader looks for flags.
validateFlags(args.filter((a) => a !== '-'), ['--summary', '--self-test', '--help', '-h'], USAGE);
if (args.includes('--self-test')) {
runSelfTest();
} else {
const summaryMode = args.includes('--summary');
const positional = args.filter((a) => !a.startsWith('-') || a === '-');
if (positional.length > 1) {
console.error(`Error: unexpected extra positional argument: ${positional[1]}`);
console.error(USAGE);
process.exit(1);
}
const source = positional[0] ?? '-';
let raw;
try {
raw = readFileSync(source === '-' ? 0 : source, 'utf-8');
} catch (err) {
console.error(`Error: cannot read payload: ${source} (${err.code || err.message})`);
process.exit(1);
}
let payload;
try {
payload = JSON.parse(raw);
} catch (err) {
console.error(`Error: ${source === '-' ? 'stdin' : source} is not valid JSON (${err.message})`);
process.exit(1);
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
console.error(`Error: ${source === '-' ? 'stdin' : source} is not a CV payload object`);
process.exit(1);
}
// Before anything is transformed or reported: a payload whose shape would
// make the run quietly wrong is refused, not accommodated. Nothing reaches
// stdout, so a shell redirect cannot capture a half-right artifact.
const shapeErrors = validatePayloadShape(payload);
if (shapeErrors.length > 0) {
console.error(`Error: ${source === '-' ? 'stdin' : source} has an unusable payload shape:`);
for (const message of shapeErrors) console.error(` - ${message}`);
process.exit(1);
}
const result = transformPayload(payload);
// stdout is the payload and ONLY the payload, so `> cv-ats.json` is safe and
// the script composes into a pipe. Everything human goes to stderr.
console.log(JSON.stringify(result.payload, null, 2));
if (summaryMode) {
console.error(formatSummary(result));
} else {
console.error(JSON.stringify({ transform: result.transform, findings: result.findings }, null, 2));
}
}
}