Ctrl K
ComunidadGuías…

cv-experience-order.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// cv-experience-order.mjs — reverse-chronological guard for rendered CVs.
//
// Split out of generate-pdf.mjs (which already sits near the repo's practical
// file-size ceiling) following the existing *-core.mjs convention used by
// liveness-core.mjs and cv-sections-core.mjs.

const MONTHS = new Map([
  ['jan', 1], ['feb', 2], ['mar', 3], ['apr', 4], ['may', 5], ['jun', 6],
  ['jul', 7], ['aug', 8], ['sep', 9], ['oct', 10], ['nov', 11], ['dec', 12],
]);
// Any element whose class list includes job-period: template packs may change
// tag names inside the ENTRY zone of their experience partial (the ATS pack
// renders the period in a <div>, the default pack in a <span>). A start tag is
// read the way HTML parses it: a quoted attribute value runs to its closing
// quote, so a `>` inside one (title="a > b") does not end the tag. Its class
// attribute is then read the ways HTML allows it to be written: either quote
// style or none, and whitespace around `=` or inside the quotes.
const START_TAG_RE = /<([a-z][a-z0-9]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)>/gi;
const ATTRIBUTE_RE = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
const DISPLAY_PERIOD_MAX = 60;

/**
 * Parse the START of a rendered job-period string.
 *
 * Only the start date matters: reverse-chronological ordering is defined by
 * when each role began. Returns null when no year is present, and a null month
 * when the period has none or names it in a language MONTHS does not know
 * ("Ago 2021", "Dez 2022"), so unparseable parts are skipped rather than
 * guessed at — the same "don't penalize missing data" discipline the location
 * and country filters use.
 *
 * @param {string} period - e.g. "Jan 2021 - Apr 2022", "09/2021 - 04/2022", "2005 - 2008".
 * @returns {{ year: number, month: number|null }|null} null when no year is found.
 */
export function parseExperienceStart(period) {
  if (typeof period !== 'string') return null;
  // A dash stays a dash: blanked with the other entities, "Jun &ndash; Aug 2022"
  // stops being a range.
  const text = period
    .replace(/&(?:[nm]dash|#821[12]|#x201[34]);/gi, '-')
    .replace(/&(?:[a-z]+|#\d+|#x[0-9a-f]+);/gi, ' ')
    .trim();
  const year = text.match(/\b(19|20)\d{2}\b/);
  if (!year) return null;
  const before = text.slice(0, year.index);
  // "Jun - Aug 2022" names both months ahead of the one year, and the role
  // started in the first of them.
  const range = before.match(/\b([a-z]{3})[a-z]*\.?\s*(?:[-\u2013\u2014]|\bto\b)\s*([a-z]+)\.?,?\s*$/i);
  const month = range ?? before.match(/\b([a-z]{3})[a-z]*\.?,?\s*$/i);
  const first = month ? (MONTHS.get(month[1].toLowerCase()) ?? null) : null;
  const last = range ? (MONTHS.get(range[2].slice(0, 3).toLowerCase()) ?? null) : null;
  // "Nov - Feb 2023" ran past New Year, so the year it names is the end's.
  const crossed = first !== null && last !== null && first > last;
  return {
    year: Number(year[0]) - (crossed ? 1 : 0),
    month: first ?? numericMonth(before, text.slice(year.index + year[0].length)),
  };
}

/**
 * The month of "09/2024", "09.2024" or "2024-09", from the text either side of
 * the year. A full date written day or month first ("03/04/2024") stays unread,
 * because which number is the month depends on the country.
 *
 * @param {string} before - The period's text ahead of the year.
 * @param {string} after - The period's text after the year.
 * @returns {number|null}
 */
function numericMonth(before, after) {
  const monthFirst = before.match(/(?<![\d/.-])(0?[1-9]|1[0-2])[/.-]$/);
  if (monthFirst) return Number(monthFirst[1]);
  // A number ahead of the year makes this a full date, so what follows the year
  // is the end of the range ("09/15/2024-10/20/2024"), not this date's month.
  if (/\d[/.-]$/.test(before)) return null;
  const yearFirst = after.match(/^[/.-](0?[1-9]|1[0-2])(?!\d)/);
  return yearFirst ? Number(yearFirst[1]) : null;
}

/**
 * A period made safe to quote back in an error or warning. It comes out of the
 * CV and the message goes to a terminal or a log, so C0/C1 controls and bidi
 * overrides are stripped (either can repaint a terminal line) and the length
 * is capped: the treatment generate-pdf.mjs's displayTitle() gives section
 * titles, repeated here because importing it would be circular. The date is
 * still parsed from the original text.
 *
 * @param {string} text - Period text with its tags already stripped.
 * @returns {string}
 */
function displayPeriod(text) {
  const clean = text
    .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
    .replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '')
    .replace(/\s+/g, ' ')
    .trim();
  const points = [...clean];
  if (points.length <= DISPLAY_PERIOD_MAX) return clean;
  const kept = points.slice(0, DISPLAY_PERIOD_MAX - 1);
  while (kept.length > 0 && /\p{M}/u.test(kept[kept.length - 1])) kept.pop();
  return `${kept.join('')}…`;
}

/**
 * Whether role `a` provably starts later than role `b`: a later year, or the
 * same year with both months known. An unknown month never decides.
 */
function startsLater(a, b) {
  if (a.year !== b.year) return a.year > b.year;
  return a.month !== null && b.month !== null && a.month > b.month;
}

/** Whether a start tag's attribute text gives it the job-period class. */
function hasJobPeriodClass(attributes) {
  for (const [, name, doubleQuoted, singleQuoted, unquoted] of attributes.matchAll(ATTRIBUTE_RE)) {
    const value = doubleQuoted ?? singleQuoted ?? unquoted ?? '';
    if (name.toLowerCase() === 'class' && value.toLowerCase().split(/\s+/).includes('job-period')) return true;
  }
  return false;
}

/**
 * The inner HTML of every job-period element, in document order. The scan
 * resumes after each element's closing tag, and an element that is never
 * closed is skipped.
 *
 * @param {string} html - Rendered CV HTML.
 * @returns {string[]}
 */
function jobPeriodContents(html) {
  const contents = [];
  const tags = new RegExp(START_TAG_RE); // a fresh lastIndex for every call
  let tag;
  while ((tag = tags.exec(html)) !== null) {
    if (!hasJobPeriodClass(tag[2])) continue;
    // Count nested elements with the same tag name, so a child's closing tag
    // does not end the job-period.
    const sameTag = new RegExp(`<(/?)${tag[1]}\\b((?:[^>"']|"[^"]*"|'[^']*')*)>`, 'gi');
    sameTag.lastIndex = tags.lastIndex;
    let depth = 1;
    let end;
    while (depth > 0 && (end = sameTag.exec(html)) !== null) {
      if (end[1]) depth--;
      else if (!end[2].trimEnd().endsWith('/')) depth++;
    }
    if (depth > 0) continue;
    contents.push(html.slice(tags.lastIndex, end.index));
    tags.lastIndex = sameTag.lastIndex;
  }
  return contents;
}

/**
 * Enforce reverse-chronological ordering of Work Experience entries.
 *
 * Agents tailoring a CV toward a JD are tempted to promote "the most relevant
 * role" to the top. That is a functional-resume technique and it backfires:
 * it buries the candidate's most recent senior title, and both ATS parsers and
 * human recruiters expect newest-first, so deviation reads as concealment.
 * Tailor via the summary, the competencies block, and bullet selection within
 * each role instead — never via ordering.
 *
 * @param {string} html - Rendered CV HTML.
 * @param {{ allowNonChronological?: boolean }} [options] - When set, downgrades
 *   a detected inversion from a thrown error to a console warning, for the rare
 *   deliberately non-chronological CV.
 * @returns {void}
 */
export function validateCvExperienceOrder(html, { allowNonChronological = false } = {}) {
  if (typeof html !== 'string') return;

  const entries = [];
  for (const content of jobPeriodContents(html)) {
    // Strip tags to a fixed point: one pass can leave text that re-forms a
    // tag (`<scr<b>ipt>` -> `<script>`).
    let text = content;
    let prev;
    do { prev = text; text = text.replace(/<[^>]*>/g, ''); } while (text !== prev);
    const raw = text.trim();
    const start = parseExperienceStart(raw);
    if (start !== null) entries.push({ shown: displayPeriod(raw), start });
  }
  if (entries.length < 2) return;

  for (let i = 1; i < entries.length; i++) {
    if (startsLater(entries[i].start, entries[i - 1].start)) {
      const order = entries.map(e => e.shown).join(' -> ');
      const message =
        `CV work experience is not in reverse-chronological order: "${entries[i].shown}" ` +
        `appears after "${entries[i - 1].shown}" but starts later. Rendered order: ${order}. ` +
        `Tailor via the summary, competencies, and bullet selection — not by reordering roles.`;
      if (allowNonChronological) {
        console.warn(`⚠️  ${message} (proceeding — --allow-nonchronological set)`);
        return;
      }
      throw new Error(
        `${message} List the roles newest-first and regenerate, or pass ` +
        `--allow-nonchronological if the candidate asked for this order.`);
    }
  }
}