#!/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
* `Cat: 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