Ctrl K
ComunidadGuías…

jd-skill-gap.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node

/**
 * jd-skill-gap.mjs — Zero-LLM JD skill-gap checker.
 *
 * Extracts an explicit skill/requirement list from a JD (regex-based, no LLM
 * call — see extractJdSkills()), then classifies each one against cv.md into
 * three buckets so a CV can be tailored honestly instead of guessed at:
 *
 *   existing            — already a named skill in cv.md's Skills section
 *   supportedByResume    — not a named skill, but appears in prose elsewhere in cv.md
 *   gap                  — JD requires it, cv.md has no trace of it at all
 *   (nothing is ever auto-added — this tool only classifies and reports)
 *
 * Design note: the three-way classification (existing / supportedByResume / gap)
 * is inspired by the skill-verification pattern in srbhr/Resume-Matcher
 * (Apache-2.0) — specifically their four-way verify_skill_target_plan() split.
 * This is an independent reimplementation, not a code port: different language,
 * zero LLM calls, and folded down to three buckets because career-ops never
 * auto-adds a claim to cv.md either way (their jd_added/unsupported distinction
 * only matters if a tool is allowed to add something automatically).
 *
 * Usage:
 *   node jd-skill-gap.mjs jds/acme.md
 *   node jd-skill-gap.mjs jds/acme.md --summary
 *   node jd-skill-gap.mjs --self-test
 */

import { readFileSync, existsSync } from 'fs';
import { canonicalize, extractSkills, SKILL_PATTERN } from './skill-extract.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { join } from 'path';
import { getCareerOpsRoot } from './path-resolver.mjs';
import { normalizePandocLine, toPlainText } from './lib/cv-markdown.mjs';
import { collapseMarkdownLinks } from './verify-cv-facts.mjs';

// ── Config ──────────────────────────────────────────────────────────

// From the data root, not the cwd. cv.md is a Source-of-Truth Boundary primary
// file and lives wherever CAREER_OPS_ROOT / CAREER_OPS_DATA_DIR / the
// .career-ops-data marker points; a bare relative path resolves against
// whatever directory the process was started in.
//
// Everything this script reports is a comparison against that file, so without
// it there is nothing to say at all. The error below therefore has to serve two
// different users at once: one who never wrote a cv.md, and one who has one
// sitting outside the data root this resolver just looked in. Naming only one
// of them sends the other to the wrong fix.
const CV_PATH = join(getCareerOpsRoot(), 'cv.md');

// ── JD skill extraction (regex, no LLM) ─────────────────────────────
//
// Looks for lines/phrases under common JD requirement headers and comma/
// bullet-separated skill lists. Deliberately conservative: under-extracting
// (missing a skill) is recoverable by the user reading the JD themselves;
// over-extracting noise into "required skills" is not — it would misreport
// gaps that aren't real.

// Qualified requirement headings (#6255), kept apart so scanJd() can tell a
// line matched by one of them from the older requirement headings.
const QUALIFIED_REQUIREMENT_ARMS = [
  // The ^ anchor means the keyword has to be the heading's first word, so
  // a qualified noun heading — Amazon's "Basic Qualifications", Google's
  // "Minimum Qualifications", "Role Qualifications", "Key Requirements" —
  // matched nothing and the JD reported no-requirements-section (#6255).
  // The qualifier is a closed list, not "any words": an open prefix would
  // turn a prose line such as "Gather and document requirements" into a
  // heading and open a block in the middle of the responsibilities.
  // The lookahead also requires the keyword to END the heading (or be
  // followed by ":" / "("): without it, a bare requirement line such as
  // "Technical requirements include Python and Kubernetes" read as a
  // heading and its skills were dropped. Closing heading markers
  // ("## Basic Qualifications ##", "【Basic Qualifications】") still end it.
  '(?:(?:basic|minimum|key|core|job|role|position|technical|essential|desired|desirable|additional|mandatory|general|specific|candidate|professional|education(?:al)?)\\s+){1,2}(?:requirement|qualification)(?=s?[\\s#\u3011]*(?:[:\uFF1A(\uFF08]|$))',
  // US public-sector postings title the block "Knowledge, Skills, and
  // Abilities" (KSAs), "Experience, Skills, and Abilities", or both joined
  // by a slash: "Knowledge/Experience, Skills, and Abilities" (#6255).
  // Same end-of-heading lookahead, for the same prose-line reason.
  '(?:(?:knowledge|experience|education)(?:\\s*\\/\\s*(?:knowledge|experience|education))?,?\\s+)?skills,?\\s+(?:and|&)\\s+abilit(?:y|ies)(?=[\\s#\u3011]*(?:[:\uFF1A(\uFF08]|$))',
];
const QUALIFIED_REQUIREMENT_HEADER_RE = new RegExp(
  '^#{0,6}\\s*\u3010?\\s*(?:' + QUALIFIED_REQUIREMENT_ARMS.join('|') + ')',
  'im'
);

// Real postings rarely use the word "Requirements". The literal-only list
// missed the phrasings most modern ATS boards actually ship ("What we're
// looking for", "Who you are", "You may be a good fit if", "You have"), so a
// JD could yield zero skills - which reads identically to "no gaps found" and
// is the more dangerous of the two failure modes this file warns about.
//
// CJK characters are \W (non-word), so the s?\b suffix that works for ASCII
// terms would always fail after a Chinese heading: \b asserts between \w and
// \W, and after a CJK char the following whitespace / colon / newline is also
// \W, so no boundary fires. The fix is a separate alternation arm for CJK
// terms that drops s?\b; both arms share the same ^#{0,6}\s* prefix.
//
// The prefix itself stays #{0,6} — it is NOT widened to also swallow `*`/`_`.
// A bolded heading with no markdown hash ("**What We're Looking For**") is
// handled instead by stripping `**`/`__` pairs from the line before testing
// it against this pattern (see stripBoldMarkers() and its call site in
// scanJd(), #4273). Widening the prefix character class was the first thing
// tried here and it regressed a real, common shape: an asterisk-BULLET whose
// text happens to start with a keyword — "* Required: Python and
// Kubernetes" — would have its leading `*` consumed as a heading marker,
// misclassifying the bullet itself as a new heading (and dropping the
// skills on that exact line, since a heading match short-circuits before
// bullet extraction runs). Stripping only doubled `**`/`__` markers — never
// a single `*`/`_` — leaves a literal bullet character untouched, since a
// real bullet is one asterisk, not two.
const REQUIREMENT_HEADER_RE = new RegExp(
  '^#{0,6}\\s*\u3010?\\s*(?:(?:' + [
    'required', 'requirements', 'qualifications', 'must[- ]have', 'preferred', 'nice[- ]to[- ]have',
    // (?:'|’)?\s*re|\s+are (not just (?:'|’)?\s*re): the contracted-only form
    // matched "we're"/"we re" but not the equally common uncontracted "we are"
    // (#4273 — the Netflix posting that surfaced this used the uncontracted,
    // bolded form of this exact heading).
    "what\\s+we(?:(?:'|’)?\\s*re|\\s+are)\\s+looking\\s+for",
    "what\\s+you(?:(?:'|’)ll|\\s+will)?\\s+bring",
    'who\\s+you\\s+are',
    'about\\s+you',
    'your\\s+(?:background|experience|profile)',
    'you\\s+(?:may|might|could)\\s+be\\s+a\\s+good\\s+fit',
    // Ashby's default template ships a bare "YOU HAVE:" heading (no markdown
    // hashes - already allowed by the ^#{0,6} prefix). Without this the whole
    // requirements block is invisible even though the bullets under it are
    // perfectly well formed.
    "you(?:(?:'|’)ll|\\s+will)?\\s+have",
    // Postings that phrase must-have/nice-to-have as full sentences rather
    // than noun headings.
    "it(?:'|’)?s\\s+important\\s+to\\s+us\\s+that\\s+you\\s+have",
    'it\\s+would\\s+be\\s+great\\s+if\\s+you\\s+ha(?:ve|d)',
    'ideal\\s+candidate',
    'skills\\s+(?:and|&)\\s+experience',
    ...QUALIFIED_REQUIREMENT_ARMS,
  ].join('|') + ')s?\\b|(?:' + [
    // Spanish (es-419 / es-ES) requirement headers. A Spanish-language
    // posting's requirements block is invisible to the English list above, so
    // every Spanish JD read as "no-requirements-section" — the same gap the
    // zh-TW / zh-CN entries below close for Chinese postings. `modes/es/`
    // makes the Spanish market a supported one.
    'requisitos',
    'requisitos?\\s+(?:del\\s+puesto|de\\s+la\\s+empresa|del\\s+equipo|principales|m\\u00ednimos)',
    'requerimientos',
    'requerimientos?\\s+(?:del\\s+puesto|espec\\u00edficos|generales)',
    'calificaciones',
    'calificaciones?\\s+(?:requeridas|necesarias)',
    'qu[eé]\\s+buscamos',
    'lo\\s+que\\s+buscamos',
    'lo\\s+que\\s+necesitas',
    'lo\\s+que\\s+esperamos',
    'perfil\\s+(?:del\\s+cargo|de\\s+la\\s+empresa|requerido|ideal)',
    'conocimientos',
    'conocimientos?\\s+(?:requeridos|necesarios)',
    'habilidades',
    'habilidades?\\s+(?:requeridas|necesarias)',
    'competencias',
    'competencias?\\s+(?:requeridas|necesarias)',
    'para\\s+el\\s+puesto',
  ].join('|') + ')\\b|(?:' + [
    // zh-TW / zh-CN requirement headers.
    // 104 / 1111 / CakeResume / Yourator all use variants of these headings.
    '\u61C9\u5FB5\u689D\u4EF6',   // 應徵條件
    '\u8CC7\u683C\u689D\u4EF6',   // 資格條件
    '\u8077\u52D9\u9700\u6C42',   // 職務需求
    '\u689D\u4EF6\u8981\u6C42',   // 條件要求
    '\u4EFB\u8077\u8CC7\u683C',   // 任職資格
    '\u5FC5\u8981\u689D\u4EF6',   // 必要條件
    '\u57FA\u672C\u8981\u6C42',   // 基本要求 (same code points in zh-TW and zh-CN)
    '\u8077\u4F4D\u8981\u6C42',   // 職位要求
    // zh-CN (Simplified). The entries above use Traditional code points
    // (職 U+8077, 資 U+8CC7, 條 U+689D, 項 U+9805), so a Simplified posting
    // (职 U+804C, 资 U+8D44, 条 U+6761, 项 U+9879) never matched them.
    '\u804C\u4F4D\u8981\u6C42',   // 职位要求
    '\u5C97\u4F4D\u8981\u6C42',   // 岗位要求
    '\u5C97\u4F4D\u57FA\u672C\u8981\u6C42', // 岗位基本要求
    '\u4EFB\u804C\u8981\u6C42',   // 任职要求
    '\u4EFB\u804C\u8D44\u683C',   // 任职资格
    '\u6761\u4EF6\u8981\u6C42',   // 条件要求
    // preferred / nice-to-have
    '\u52A0\u5206\u9805\u76EE',   // 加分項目
    '\u52A0\u5206\u689D\u4EF6',   // 加分條件
    '\u52A0\u5206\u9879',         // 加分项 (zh-CN)
  ].join('|') + ')).*$',
  'im'
);

// Headers that end a requirements block even when the posting uses no markdown
// heading levels. Without this the block stayed open to end-of-file and swept
// the benefits list into "required skills" - turning perks like "401k",
// "Equity" and "Carrot" into reported skill gaps.
//
// Same #{0,6} prefix, same reason for leaving it unwidened, as
// REQUIREMENT_HEADER_RE above: a bolded "**Benefits**" with no markdown hash
// is handled by stripBoldMarkers() before this pattern ever sees the line,
// not by letting the prefix itself swallow `*`/`_` (#4273).
const NON_REQUIREMENT_HEADER_RE = new RegExp(
  '^#{0,6}\\s*\u3010?\\s*(?:(?:' + [
    // Responsibilities. The negative lookahead keeps "You will have" on the
    // requirements side — this list is tested BEFORE REQUIREMENT_HEADER_RE in
    // scanJd(), so without it a "You will have:" heading would close a block
    // instead of opening one. Bare "YOU WILL" must close: the fallback that
    // ends a block on a new heading only fires for markdown headings (#{1,6}),
    // so an unhashed responsibilities heading left the block open and scored
    // its duties as required skills.
    'you\\s+will(?!\\s+have)',
    'benefits?', 'perks?', 'benefits\\s+and\\s+perks', 'compensation', 'salary', 'pay\\s+range',
    'what\\s+we\\s+offer', 'why\\s+(?:join|work|this\\s+role)',
    'about\\s+(?:us|the\\s+company|the\\s+team|the\\s+role)',
    'how\\s+(?:and\\s+where\\s+)?we\\s+work',
    'equal\\s+opportunity', 'eeo', 'diversity',
    'interview\\s+process', 'how\\s+to\\s+apply', 'to\\s+apply',
    'our\\s+(?:stack|process|values|mission)',
    // Spanish (es-419 / es-ES) closing headers. Without them a Spanish
    // posting's "Beneficios" block stays open and its perks are scored as
    // required skills — the same failure the English closers prevent.
    'beneficios?', 'funciones?', 'responsabilidades?',
    'lo\\s+que\\s+ofrecemos', 'qu[eé]\\s+ofrecemos', 'nuestro\\s+ofrecimiento',
    'acerca\\s+de\\s+nosotros', 'acerca\\s+de\\s+la\\s+empresa',
    'c[oó]mo\\s+postular', 'postulaci[oó]n', 'proceso\\s+de\\s+selecci[oó]n',
    'etapas\\s+del\\s+proceso',
  ].join('|') + ')\\b|(?:' + [
    // zh-TW / zh-CN closing headers — drops \b for the same CJK reason.
    '\u5DE5\u4F5C\u5167\u5BB9',   // 工作內容
    '\u5DE5\u4F5C\u8077\u8CAC',   // 工作職責
    '\u8077\u8CAC\u7BC4\u758A',   // 職責範疇
    '\u798F\u5229',               // 福利
    '\u85AA\u8CC7',               // 薪資
    '\u85AA\u916C',               // 薪酬 (zh-CN)
    '\u516C\u53F8\u4ECB\u7D39',   // 公司介紹
    '\u95DC\u65BC\u6211\u5011',   // 關於我們
    '\u61C9\u5FB5\u65B9\u5F0F',   // 應徵方式
    '\u5982\u4F55\u61C9\u5FB5',   // 如何應徵
    // zh-CN (Simplified) closing headers.
    '\u5DE5\u4F5C\u5185\u5BB9',   // 工作内容
    '\u5DE5\u4F5C\u804C\u8D23',   // 工作职责
    '\u5C97\u4F4D\u804C\u8D23',   // 岗位职责
    '\u804C\u4F4D\u63CF\u8FF0',   // 职位描述
    '\u85AA\u8D44',               // 薪资
    '\u516C\u53F8\u4ECB\u7ECD',   // 公司介绍
    '\u5173\u4E8E\u6211\u4EEC',   // 关于我们
  ].join('|') + ')).*$',
  'im'
);

// `\r?$` is required, not cosmetic: JS treats \r as a line terminator, so `.`
// cannot consume it and a bare `$` never matches on a CRLF-split line.
//
// Numbered items count as list items too. Chinese postings (and many others)
// number their requirements instead of bulleting them: "1、", "1.", "1)",
// "(1)", "①". An ASCII "1." or "1)" needs whitespace after it, so a figure
// such as "3.5+ years" is not read as item 3.
const BULLET_LINE_RE = /^\s*(?:[-*•]|\d{1,2}(?:[.)]\s|[、.)])|[((]\d{1,2}[))]|[\u2460-\u2473])\s*(.+)\r?$/;

// Strip markdown STRONG-emphasis markers (`**text**` / `__text__`) so a
// bolded heading with no `#` at all — "**What We're Looking For**" — still
// reaches REQUIREMENT_HEADER_RE / NON_REQUIREMENT_HEADER_RE's own `#{0,6}`
// prefix as if the bold wrapper were never there (#4273).
//
// Only doubled markers: a single `*`/`_` is left completely alone, on
// purpose. `*` is also how a plain markdown bullet starts (BULLET_LINE_RE),
// and a bullet whose text happens to start with a keyword — "* Required:
// Python and Kubernetes" — must stay a bullet, not become a misdetected
// heading that swallows its own line's skills before bullet extraction ever
// runs. `**` (two characters) can never be a single-asterisk bullet marker,
// so this global-replace has no bullet-collision case to worry about.
function stripBoldMarkers(line) {
  return line.replace(/\*\*|__/g, '');
}

// A conservative skill-token extractor: pulls comma/slash/and-separated
// technical-looking tokens out of a requirement bullet, rather than treating
// the whole bullet as one skill string (JD bullets are often full sentences).
//
// Trailing boundary: \b fails at symbol edges (\bC\+\+\b needs a word char
// AFTER the +), so C++/C#/F# would never match standalone — same bug and fix
// as upskill.mjs's SKILL_PATTERN, where (?!\w) is equivalent to \b for
// word-char edges and correct for symbol edges. Because the char class here
// is greedy and contains ".", the last token char is additionally pinned to
// a word char / # / + so a sentence-ending period is never swallowed into
// the token ("Docker." still extracts as "Docker"). The leading \b stays:
// tokens start with [A-Z], a word char, where \b and (?<!\w) are equivalent.
const SKILL_TOKEN_RE = /\b([A-Z][A-Za-z0-9+.#]{0,29}[A-Za-z0-9+#](?:\.[a-z]{2,4})?)(?!\w)/g;

// Deliberately broad: this list exists specifically to stop generic
// capitalized nouns/adjectives from JD bullets (e.g. "Bachelor's degree
// required", "3+ years of experience") from being misreported as missing
// "skills" — the exact failure mode this file's design note warns against.
const STOPWORDS = new Set([
  'the', 'and', 'for', 'with', 'you', 'your', 'our', 'this', 'that', 'these', 'those',
  'must', 'able', 'ability', 'strong', 'excellent', 'proven', 'a', 'an', 'or', 'in', 'of', 'to', 'as', 'is', 'are',
  // degree / education boilerplate
  'bachelor', 'bachelors', 'master', 'masters', 'degree', 'diploma', 'certification', 'certificate',
  // experience / seniority boilerplate
  'experience', 'years', 'year', 'senior', 'junior', 'entry', 'level', 'minimum', 'preferred', 'required',
  // generic sentence-starters that show up capitalized at the start of a bullet
  'candidates', 'candidate', 'applicants', 'applicant', 'ideal', 'successful',
  'knowledge', 'understanding', 'familiarity', 'exposure', 'background',
  'skills', 'skill', 'communication', 'team', 'teams', 'work', 'working',
  // Capitalized bullet-openers that read as skills but describe the candidate's
  // disposition, not a technology ("Deep fluency in…", "Interest in…").
  'deep', 'interest', 'genuine', 'solid', 'comfortable', 'passion', 'passionate',
  'track', 'record', 'real', 'bonus', 'plus', 'hands', 'proficiency', 'fluency',
  'expertise', 'demonstrated', 'extensive', 'practical', 'good', 'great', 'clear',
  // Capitalized words that name a field or an artifact, not a skill a CV can
  // list: "AI tools", "build a Demo" (#5292). Specific AI skills (RAG, LLMs,
  // Prompt Engineering, ...) are still read through the shared vocabulary.
  'ai', 'demo',
]);

// A known skill that is more than one word (#5292). SKILL_TOKEN_RE reads one
// word at a time, so "Prompt Engineering" came out as two gaps, "Prompt" and
// "Engineering", neither of which canonicalize() knows, and a CV that lists
// "Prompt Engineering" could never satisfy them. The shared vocabulary already
// has these phrases, so they are matched from SKILL_PATTERN before the
// single-word pass rather than through a second tokenizer here.
//
// Only phrases that start with a capital letter, the same conservative rule as
// SKILL_TOKEN_RE: a lowercase bullet still extracts nothing (see
// diagnoseExtraction()). The JD's own spelling is kept, as for single words;
// classifySkillGaps() canonicalizes it.
function isPhraseMatch(match) {
  return /\s/.test(match) && /^[A-Z]/.test(match);
}

// Requirement text written in Chinese is never a candidate: SKILL_TOKEN_RE and
// the shared vocabulary are both Latin-script. A requirement line that still
// has this many Han characters once its skill tokens are taken out is recorded
// as not read, so the output can say the check covered it only partly
// (#5292). Four, not one: short connectives such as 熟悉, 精通 and 优先 sit
// around Latin skill names in fully read lines, and are two characters each.
const UNREAD_HAN_MIN = 4;
const HAN_RE = /\p{Script=Han}/gu;

// The Set scanJd() collects skills into, plus the requirement lines it could
// not read. A Set subclass, so addSkillTokens() keeps its (text, skills)
// signature at every call site.
class SkillCollector extends Set {
  constructor() {
    super();
    this.unreadLines = [];
  }
}

/**
 * Scan a JD once, returning both the extracted skills and whether a
 * requirement-style section was ever opened.
 *
 * `sawRequirementSection` is what lets the caller tell apart the two very
 * different reasons an extraction can come back empty: "I never found a
 * requirements section, so I checked nothing" versus "I checked one and
 * recognized none of its terms". Both print as zero skills, and the first is
 * the one that silently skips the gate in modes/pdf.md Step 4.
 *
 * Kept as one state machine with extractJdSkills deliberately: a second copy of
 * the block-open/block-close logic would drift from this one, which is the exact
 * failure the shared skill-extract.mjs module was introduced to end.
 *
 * @param {string} jdText
 * @returns {{skills: string[], sawRequirementSection: boolean}}
 */
function scanJd(jdText) {
  const lines = jdText.split('\n');
  const skills = new SkillCollector();
  let inRequirementsBlock = false;
  let sawRequirementSection = false;

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    // Header-classification only, never bullet extraction below: a bolded
    // heading ("**Benefits**") must match these two regexes as if the bold
    // wrapper weren't there, but a bolded SKILL inside a bullet
    // ("- **Docker** and **Kubernetes**") already extracts fine as-is via
    // SKILL_TOKEN_RE, which skips right over `*` since it isn't in the
    // token's character class — stripping there would be a no-op at best
    // (#4273).
    const headerLine = stripBoldMarkers(line);
    // Checked before the requirement test so a heading that satisfies both
    // (e.g. "Why this role") closes the block rather than reopening it.
    if (NON_REQUIREMENT_HEADER_RE.test(headerLine)) {
      inRequirementsBlock = false;
      continue;
    }
    if (REQUIREMENT_HEADER_RE.test(headerLine)) {
      inRequirementsBlock = true;
      sawRequirementSection = true;
      // "Requirements: SQL; dbt; Tableau" puts the whole list on the header
      // line, with no bullets under it. A separator is required so a sentence
      // such as "About you: Curious and kind" is not read as a list.
      // The full-width colon and the CJK separators (、,;) count the same as
      // their ASCII forms: "任职要求:Python、Docker".
      const colon = headerLine.search(/[::]/);
      let inline = colon === -1 ? '' : headerLine.slice(colon + 1);
      // A qualified heading may carry its list in parentheses instead:
      // "Basic Qualifications (Python and Kubernetes)", full-width included.
      if (!inline && QUALIFIED_REQUIREMENT_HEADER_RE.test(headerLine)) {
        const paren = headerLine.search(/[((]/);
        if (paren !== -1) inline = headerLine.slice(paren + 1);
      }
      // A qualified heading with no separator ("Technical requirements:
      // Python and Kubernetes") was a requirement line before #6255 made it a
      // heading, so its known skills are kept — vocabulary names only. Every
      // other heading keeps the #5173 rule: a sentence after the colon ("About
      // you: Curious about Kubernetes.") is not a list.
      if (/[;,、,;]/.test(inline)) addSkillTokens(inline, skills);
      else if (QUALIFIED_REQUIREMENT_HEADER_RE.test(headerLine)) {
        for (const skill of extractSkills(inline)) skills.add(skill);
      }
      continue;
    }
    if (inRequirementsBlock && line.trim() === '') continue;
    if (inRequirementsBlock && /^#{1,6}\s/.test(line) && !REQUIREMENT_HEADER_RE.test(headerLine)) {
      inRequirementsBlock = false;
    }

    if (!inRequirementsBlock) continue;
    const bulletMatch = BULLET_LINE_RE.exec(line);
    if (bulletMatch) {
      addSkillTokens(bulletMatch[1], skills);
      continue;
    }
    // Text copied from a rendered posting page loses its list markers, so each
    // requirement arrives as a bare line. A bare line with blank lines on both
    // sides is a heading neither regex knows ("Core values"), and it closes the
    // block. A bare line next to other lines is a requirement.
    const isolated = (lines[i - 1] ?? '').trim() === '' && (lines[i + 1] ?? '').trim() === '';
    if (isolated) {
      inRequirementsBlock = false;
      continue;
    }
    // A bare line is a sentence, so its first word is capitalized as grammar
    // ("Possess a curiosity"). Drop it unless the vocabulary knows it as a skill.
    const opener = /^\s*([A-Z][a-z]+)\s+[a-z]/.exec(line);
    const unknownOpener = opener && extractSkills(opener[1]).size === 0;
    addSkillTokens(unknownOpener ? line.slice(opener[0].length - 1) : line, skills);
  }
  return { skills: [...skills], sawRequirementSection, unreadLines: skills.unreadLines };
}

function addSkillTokens(text, skills) {
  // Known multi-word skills first, each blanked out so the single-word pass
  // below cannot also split it into fragments. Both passes are then added in
  // the order the JD wrote them.
  const found = [];
  let rest = text;
  for (const m of text.matchAll(SKILL_PATTERN)) {
    if (!isPhraseMatch(m[0])) continue;
    found.push({ index: m.index, token: m[0] });
    rest = rest.slice(0, m.index) + ' '.repeat(m[0].length) + rest.slice(m.index + m[0].length);
  }

  let m;
  SKILL_TOKEN_RE.lastIndex = 0;
  while ((m = SKILL_TOKEN_RE.exec(rest)) !== null) {
    const token = m[1].trim();
    if (!STOPWORDS.has(token.toLowerCase()) && token.length > 1) {
      found.push({ index: m.index, token });
    }
  }
  for (const { token } of found.sort((a, b) => a.index - b.index)) skills.add(token);

  if (skills.unreadLines) {
    const hanLeft = (rest.replace(SKILL_TOKEN_RE, ' ').match(HAN_RE) || []).length;
    if (hanLeft >= UNREAD_HAN_MIN) skills.unreadLines.push(text.trim());
  }
}

/**
 * Extract candidate skill tokens from a JD's requirement-style sections.
 * @param {string} jdText
 * @returns {string[]}
 */
function extractJdSkills(jdText) {
  return scanJd(jdText).skills;
}

/**
 * Explain an empty extraction, so "found nothing to check" stops reading as
 * "checked and found no gaps".
 *
 * Returns null when the run is conclusive (at least one skill was classified).
 * Otherwise returns a reason code and a message for the user.
 *
 * The second reason deliberately says "no skill candidates were extracted"
 * rather than blaming the vocabulary. Extraction can come back empty for
 * reasons that have nothing to do with dictionary coverage: SKILL_TOKEN_RE only
 * captures uppercase-leading tokens, so a lowercase bullet like
 * "- python and kubernetes experience" yields nothing even though
 * skill-extract.mjs knows both names. STOPWORDS can also empty a bullet. All the
 * caller can honestly be told is that nothing was classified.
 *
 * There is deliberately no minimum-length test here. That would need a
 * character cutoff, and this repo has no JD corpus to calibrate one against:
 * `jds/` is user-layer and gitignored, and the only JD-shaped files in the tree
 * (`evals/fixtures/*.txt`) are recorded score-summary stubs that all extract
 * zero skills already. A made-up cutoff would fire on those and still not
 * describe why a real JD came back empty. The structural question — was there a
 * requirements section to scan at all — separates the causes without a constant.
 *
 * @param {string} jdText
 * @param {string[]} jdSkills
 * @returns {{reason: string, message: string}|null}
 */
function diagnoseExtraction(jdText, jdSkills) {
  if (jdSkills.length > 0) return null;

  if (jdText.trim() === '') {
    return {
      reason: 'empty-jd',
      message: 'The JD file is empty, so nothing was checked.',
    };
  }

  if (!scanJd(jdText).sawRequirementSection) {
    return {
      reason: 'no-requirements-section',
      message:
        'No requirements section was recognized in this JD, so no text was scanned for skills. ' +
        'This is not the same as "no gaps": the check did not run. Read the JD yourself before drafting.',
    };
  }

  return {
    reason: 'no-skill-candidates',
    message:
      'A requirements section was found and scanned, but no skill candidates were extracted from it. ' +
      'This is not the same as "no gaps": nothing was classified. Read the JD yourself before drafting.',
  };
}

/**
 * Say when a run classified skills but could not read part of the
 * requirements. diagnoseExtraction() covers the empty run; this covers the
 * partial one, which otherwise prints as complete: a Chinese requirements
 * block whose bullets mention two Latin tool names yields those two and
 * silently drops every requirement written in Chinese (#5292).
 *
 * Non-blocking, like lowConfidence: the classified buckets are still right
 * for what they contain.
 *
 * @param {string} jdText
 * @returns {{reason: string, unreadLines: string[], message: string}|null}
 */
function diagnoseCoverage(jdText) {
  const { unreadLines } = scanJd(jdText);
  if (unreadLines.length === 0) return null;
  return {
    reason: 'untokenized-cjk',
    unreadLines,
    message:
      `${unreadLines.length} requirement line(s) contain Chinese text that this check cannot read, ` +
      'so requirements written in Chinese were not classified. The buckets cover only the Latin-script ' +
      'skill names. Read those lines yourself before drafting.',
  };
}

// ── Word-boundary text matching (same technique as Resume-Matcher's
//    _skill_mentioned_in_text — prevents "Java" matching inside "JavaScript") ──

/**
 * Word-boundary, case-insensitive check for whether a skill token appears in text.
 * @param {string} skill
 * @param {string} text
 * @returns {boolean}
 */
function skillMentionedInText(skill, text) {
  const escaped = skill.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const re = new RegExp(`(?<![\\w])${escaped}(?![\\w])`, 'i');
  return re.test(text);
}

// ── Skills-section split ─────────────────────────────────────────────
//
// Line-scan instead of a single regex: JS regex has no `\Z`-style
// end-of-string anchor (unlike Python, where this pattern was designed),
// so a lookahead built on `\Z` either fails to match a trailing Skills
// section at all or matches a literal "Z" character later in the text.
// Scanning line-by-line for the next heading avoids the anchor entirely.

const ANY_HEADING_RE = /^#{1,6}\s/;

// Which headings open a named skills section (#4879). The heading is read
// through lib/cv-markdown.mjs first, so `## **[Skills]{.smallcaps}**` counts.
// "Skills" or "Competencies" anywhere in the heading counts at any level
// ("Technical Skills", "Core Competencies", the title build-cv-html.mjs
// renders for the competency grid). "Tools" / "Technologies" / "Tech Stack" only count on a
// level-1 or level-2 heading: at level 3 they would also match an employer
// such as "### Acme Technologies — Remote" and promote that job's prose into
// named skills.
//
// The Spanish words are the ones a Spanish cv.md actually uses: "Aptitudes y
// Habilidades" and "Competencias Técnicas". Without them a Spanish cv.md had
// no named-skills section at all, so every skill it listed was reported as a
// gap — the check's own "already in Skills section" bucket stayed empty.
const SKILLS_WORD_RE = /\b(?:skills|competenc(?:y|ies)|aptitudes|habilidades|competencias)\b/i;
const TOOLS_WORD_RE = /\b(?:tools|technologies|tech\s+stack)\b/i;

function isSkillsHeading(line) {
  // `\s*` rather than `\s+` keeps the historical `#Skills` (no space) form.
  const m = String(line).match(/^(#{1,6})(?!#)\s*(.+?)\s*$/);
  if (!m) return false;
  const text = toPlainText(normalizePandocLine(m[2]).replace(/\s*\{[^}]*\}\s*$/, ''));
  return SKILLS_WORD_RE.test(text) || (m[1].length <= 2 && TOOLS_WORD_RE.test(text));
}

/**
 * Split cv.md into its named skills sections (if any) and the remaining
 * prose, without relying on a Python-style end-of-string regex anchor. Every
 * skills heading contributes its section, up to the next heading of any
 * level, so a CV with both "## Core Skills" and "## Technical Platforms &
 * Tools" counts both.
 * @param {string} cvText
 * @returns {{namedSkillsText: string, proseText: string}}
 */
function splitSkillsSection(cvText) {
  const named = [];
  const prose = [];
  let inSkills = false;
  for (const line of cvText.split('\n')) {
    const skillsHeading = isSkillsHeading(line);
    if (skillsHeading || ANY_HEADING_RE.test(line)) {
      inSkills = skillsHeading;
      // A skills heading itself belongs to neither side.
      if (!skillsHeading) prose.push(line);
      continue;
    }
    (inSkills ? named : prose).push(line);
  }
  return { namedSkillsText: named.join('\n'), proseText: prose.join('\n') };
}

// ── Classification ───────────────────────────────────────────────────

/**
 * Classify each JD skill against cv.md into existing / supportedByResume / gap.
 * @param {string[]} jdSkills
 * @param {string} cvText
 * @returns {{existing: string[], supportedByResume: string[], gap: string[]}}
 */
function classifySkillGaps(jdSkills, cvText) {
  // Only what a reader of the CV sees is evidence: a link's text, not its target,
  // the rule verify-cv-facts.mjs applies (#6606). "[infrastructure modules](
  // https://github.com/jane/terraform-modules)" says nothing about Terraform, but
  // its URL put Terraform in supportedByResume, which modes/pdf.md offers to the
  // tailored CV's Skills section.
  const { namedSkillsText, proseText } = splitSkillsSection(collapseMarkdownLinks(cvText));

  // Canonical skill sets present in each CV region. Folding BOTH the JD token
  // and the CV text through skill-extract.mjs's canonicalize() is what closes
  // the alias gap this file was reported for (#1896): a CV that writes "k8s"
  // and a JD that says "Kubernetes" resolve to the same canonical name instead
  // of being reported as a false gap. This is the shared tokenizer upskill.mjs
  // already promised — three parallel copies used to disagree.
  const namedCanon = extractSkills(namedSkillsText);
  const proseCanon = extractSkills(proseText);

  const existing = [];
  const supportedByResume = [];
  const gap = [];

  for (const skill of jdSkills) {
    const canon = canonicalize(skill);
    // "Known" = skill-extract reads this token as its own canonical skill. For
    // known skills the canonical-set lookup is authoritative and alias-safe.
    // Unknown/free tokens canonicalize to themselves and fall through to the
    // word-boundary heuristic below, which is byte-for-byte the prior behavior
    // — jd-skill-gap keeps its own heuristics for free tokens (#1896 answer 2).
    // A known skill must not reach that heuristic: it matches the JD's spelling
    // anywhere, including the prose skill-extract declines to count ("go the
    // extra mile", "a safe environment", "Fine-tuning the funnel"). Containing
    // a skill is not being one: "React.js" extracts as React, never as itself,
    // so it stays a free token and the heuristic still finds it in a CV.
    const known = extractSkills(skill).has(canon);

    if (known && namedCanon.has(canon)) {
      existing.push(skill);
    } else if (known && proseCanon.has(canon)) {
      supportedByResume.push(skill);
    } else if (!known && skillMentionedInText(skill, namedSkillsText)) {
      existing.push(skill);
    } else if (!known && skillMentionedInText(skill, proseText)) {
      supportedByResume.push(skill);
    } else {
      gap.push(skill);
    }
  }

  return { existing, supportedByResume, gap };
}

// ── Exports (for test-all.mjs and other consumers) ───────────────────
export { extractJdSkills, skillMentionedInText, classifySkillGaps, diagnoseExtraction, diagnoseCoverage };

// ── CLI ──────────────────────────────────────────────────────────────

const args = process.argv.slice(2);
const summaryMode = args.includes('--summary');
const selfTestMode = args.includes('--self-test');
const jdPathArg = args.find(a => !a.startsWith('--'));

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 fakeJd = `
# Senior Engineer — Fabrikam Inc.

## Requirements
- Python, FastAPI, PostgreSQL
- Experience with Kubernetes
- Strong communication skills
`;
  const fakeCv = `
# Skills
Python, PostgreSQL, Docker

# Experience
Deployed services onto Kubernetes clusters and wrote FastAPI endpoints for internal tools.
`;

  const jdSkills = extractJdSkills(fakeJd);
  eq('extracts Python from requirements bullet', jdSkills.includes('Python'), true);
  eq('extracts Kubernetes from a separate bullet', jdSkills.includes('Kubernetes'), true);
  eq('does not extract stopword "Strong"', jdSkills.includes('Strong'), false);

  const result = classifySkillGaps(['Python', 'PostgreSQL', 'Kubernetes', 'FastAPI', 'Rust'], fakeCv);
  eq('Python classified as existing (named skill)', result.existing.includes('Python'), true);
  eq('Kubernetes classified as supportedByResume (prose only)', result.supportedByResume.includes('Kubernetes'), true);
  eq('FastAPI classified as supportedByResume (prose only)', result.supportedByResume.includes('FastAPI'), true);
  eq('Rust classified as a real gap', result.gap.includes('Rust'), true);

  // Regression: a Skills section that is the LAST section in cv.md, with no
  // trailing heading after it. The original draft used a Python-style `\Z`
  // end-of-string anchor, which JS regex has no equivalent for — it either
  // failed to match this case at all, or matched a literal "Z" character
  // later in the text. This fixture is that exact shape (Skills is last,
  // and the text contains a "Z"-adjacent word to catch the literal-match
  // failure mode too).
  const trailingSkillsCv = `
# Experience
Worked with Rust in a prior role.

# Skills
Python, Docker, Zookeeper
`;
  const trailingResult = classifySkillGaps(['Python', 'Zookeeper'], trailingSkillsCv);
  eq(
    'trailing Skills section (last in doc, contains a "Z" word) is still captured as named skills',
    trailingResult.existing.includes('Zookeeper'),
    true
  );

  // The cv.md side of the six-level widening: SKILLS_HEADING_RE and
  // ANY_HEADING_RE also stopped at four. An h5 "Skills" heading meant no
  // named section was found at all (everything downgraded to
  // supportedByResume), and once it IS found, the h6 heading after it must
  // still close the section - otherwise Experience prose leaks into the
  // named skills and upgrades to existing. One assert per regex: reverting
  // SKILLS_HEADING_RE to #{1,4} turns the first red, reverting
  // ANY_HEADING_RE alone turns the second red.
  const deepHeadingCv = [
    '# Resume', '',
    '##### Skills', 'Python, Docker, PostgreSQL', '',
    '###### Experience', 'Deployed Kubernetes clusters for internal tools.',
  ].join('\n');
  const deepCvResult = classifySkillGaps(['Python', 'Docker', 'PostgreSQL', 'Kubernetes'], deepHeadingCv);
  eq('an h5 Skills heading is recognized as the named section', deepCvResult.existing.includes('Python'), true);
  eq('the named section stops at the h6 heading (Kubernetes stays prose)', deepCvResult.existing.includes('Kubernetes'), false);
  eq('prose under the h6 still classifies as supportedByResume', deepCvResult.supportedByResume.includes('Kubernetes'), true);

  // Pandoc-flavored and differently named skills headings (#4879). The
  // issue's repro: skills under "## **[Technical Platforms & Tools]{.smallcaps}**"
  // were reported as resume prose, so the existing bucket was always empty.
  const pandocSkillsCv = [
    '# Jane Example', '',
    '## **[Technical Platforms & Tools]{.smallcaps}**', '',
    '- Cloud: AWS, Kubernetes, Terraform', '',
    '## **[Professional Experience]{.smallcaps}**', '',
    '### **Acme Technologies** --- New York, NY', '',
    'Migrated the PostgreSQL fleet.',
  ].join('\n');
  const pandocSkillsResult = classifySkillGaps(['AWS', 'Kubernetes', 'Terraform', 'PostgreSQL'], pandocSkillsCv);
  eq('a bold + span "Technical Platforms & Tools" heading is a named skills section',
    pandocSkillsResult.existing.sort(), ['AWS', 'Kubernetes', 'Terraform']);
  eq('a level-3 employer named "... Technologies" is not a skills section',
    pandocSkillsResult.supportedByResume, ['PostgreSQL']);
  for (const heading of ['## Technical Skills', '## **[Skills]{.smallcaps}**', '### Core Skills', '## Technologies', '# Tech Stack', '## Skills {#skills}']) {
    eq(`"${heading}" opens a named skills section`,
      classifySkillGaps(['Python'], `${heading}\nPython\n`).existing, ['Python']);
  }
  for (const heading of ['## Core Competencies', '## Competencies', '## Skills & Competencies', '### Key Competency Areas']) {
    eq(`"${heading}" opens a named skills section`,
      classifySkillGaps(['Python'], `${heading}\nPython\n`).existing, ['Python']);
  }
  eq('a "Competition Wins" heading is not a skills section',
    classifySkillGaps(['Python'], '## Competition Wins\nPython\n').existing, []);
  const twoSectionsCv = '## Skills\nPython\n\n## Experience\nUsed Docker daily.\n\n## Tools\nTerraform\n';
  const twoSectionsResult = classifySkillGaps(['Python', 'Terraform', 'Docker'], twoSectionsCv);
  eq('every skills section counts, not just the first', twoSectionsResult.existing.sort(), ['Python', 'Terraform']);
  eq('prose between two skills sections stays prose', twoSectionsResult.supportedByResume, ['Docker']);

  // Regression: requirement headings that are full sentences or bare
  // uppercase rather than the noun forms ("Requirements", "Qualifications").
  // Each of these silently yielded ZERO skills, which is indistinguishable
  // from "no gaps found" — the failure mode this file's header warns about.
  const headerVariants = [
    ['bare uppercase "YOU HAVE:" (Ashby default template)', 'YOU HAVE:'],
    ['"You\'ll have"', "You'll have:"],
    ['"You will have"', 'You Will Have:'],
    ['"You might be a good fit if you:"', 'You Might Be a Good Fit If You:'],
    ['"You could be a good fit if you:"', 'You Could Be a Good Fit If You:'],
    ['"It\'s Important To Us That You Have"', "## It's Important To Us That You Have"],
    ['"It Would Be Great if You Had"', '## It Would Be Great if You Had'],
  ];
  for (const [label, heading] of headerVariants) {
    const jd = `# Role\n\n${heading}\n- Hands-on experience with React, TypeScript and AWS\n`;
    const skills = extractJdSkills(jd);
    eq(`${label} opens a requirements block`, skills.includes('React'), true);
  }

  // Guard the other direction: the responsibilities heading "You will" must NOT
  // open a requirements block. It reads as a near-miss for "you will have", and
  // treating duties as requirements is exactly the over-extraction this file
  // calls unrecoverable.
  const responsibilitiesJd = `# Role\n\nYOU WILL\n- Ship Kubernetes manifests for the platform team\n`;
  eq(
    '"YOU WILL" (responsibilities) does not open a requirements block',
    extractJdSkills(responsibilitiesJd).includes('Kubernetes'),
    false
  );

  // The assertion above only proves YOU WILL cannot OPEN a block. Closing is
  // the case that actually bites: postings routinely list requirements first
  // and duties second, and the block-ending fallback in scanJd() fires only on
  // markdown headings (#{1,6}) — so a bare responsibilities heading left the
  // block open and reported every duty as a missing skill.
  const dutiesAfterRequirementsJd = [
    '# Role', '', 'YOU HAVE:', '- Experience with Python', '',
    'YOU WILL', '- Ship Kubernetes manifests', '- Operate Terraform modules', '',
  ].join('\n');
  const dutiesSkills = extractJdSkills(dutiesAfterRequirementsJd);
  eq('requirements before a bare YOU WILL are still extracted', dutiesSkills.includes('Python'), true);
  eq('bare "YOU WILL" closes an open requirements block (Kubernetes)', dutiesSkills.includes('Kubernetes'), false);
  eq('bare "YOU WILL" closes an open requirements block (Terraform)', dutiesSkills.includes('Terraform'), false);

  // Guard the lookahead: "You will have" must still reach REQUIREMENT_HEADER_RE
  // even though NON_REQUIREMENT_HEADER_RE is tested first in scanJd().
  eq(
    '"You will have" opens a block despite the YOU WILL exclusion',
    extractJdSkills('# Role\n\nYou Will Have:\n- Experience with Kubernetes\n').includes('Kubernetes'),
    true
  );

  // Markdown defines six heading levels, and the block-ending fallback only
  // recognized four (CodeRabbit, reviewing #2176). A posting pasted out of a
  // deeply nested doc - or converted from HTML, where an ATS wraps sections in
  // h5/h6 - kept the requirements block open across its own next heading, so
  // everything below it scored as a required skill.
  const deepHeadingJd = [
    '##### Requirements', '- Experience with Python', '',
    '##### Benefits', '- Equity and a Carrot subscription', '',
    '###### About Us', '- We use Kubernetes internally for our own platform',
  ].join('\n');
  const deepSkills = extractJdSkills(deepHeadingJd);
  eq('an h5 requirements heading is recognized', deepSkills.includes('Python'), true);
  eq('an h5 heading closes the block (Equity)', deepSkills.includes('Equity'), false);
  eq('an h6 heading stays closed (Kubernetes)', deepSkills.includes('Kubernetes'), false);

  // Regression: generic JD boilerplate must not be misreported as a skill gap.
  const boilerplateJd = `
# Role

## Requirements
- Bachelor's degree required
- Experience with cross-functional teams (5+ years)
- Communication skills and Ability to self-organize
`;
  // The asserted words are capitalized in the fixture on purpose:
  // SKILL_TOKEN_RE only captures uppercase-leading tokens, so a lowercase
  // "experience" would never become a candidate and the assertion would
  // pass without exercising the STOPWORDS filter at all.
  const boilerplateSkills = extractJdSkills(boilerplateJd);
  eq('does not extract "Bachelor" as a skill', boilerplateSkills.includes('Bachelor'), false);
  eq('does not extract "Experience" as a skill', boilerplateSkills.includes('Experience'), false);
  eq('does not extract "Communication" as a skill', boilerplateSkills.includes('Communication'), false);
  eq('does not extract "Ability" as a skill', boilerplateSkills.includes('Ability'), false);

  // Regression: tokens ending in a symbol (C#, C++, F#). The original trailing
  // \b needs a word char AFTER the symbol, so these never matched standalone —
  // same bug upskill.mjs's SKILL_PATTERN fixed with a (?!\w) lookahead. The
  // sentence-ending "Docker." assertion guards the other edge of the fix: the
  // greedy char class contains ".", so the lookahead alone would swallow the
  // trailing period into the token.
  const symbolEdgeJd = `
# Role

## Requirements
- C#, C++ or F# for backend services
- Familiarity with Docker.
`;
  const symbolEdgeSkills = extractJdSkills(symbolEdgeJd);
  eq('extracts C# standalone (symbol-edge boundary)', symbolEdgeSkills.includes('C#'), true);
  eq('extracts C++ standalone (symbol-edge boundary)', symbolEdgeSkills.includes('C++'), true);
  eq('extracts F# standalone (symbol-edge boundary)', symbolEdgeSkills.includes('F#'), true);
  eq('sentence-ending token stays clean ("Docker", not "Docker.")', symbolEdgeSkills.includes('Docker'), true);
  eq('trailing period is not swallowed into the token', symbolEdgeSkills.includes('Docker.'), false);

  // Regression: most real postings never write the word "Requirements". The
  // literal-only header list matched none of these, so extraction returned an
  // empty list - indistinguishable from "this JD has no skill gaps", which is
  // the failure mode that silently skips the whole check.
  for (const header of [
    "What we're looking for",
    'What you will bring',
    'Who you are',
    'About you',
    'You may be a good fit if',
  ]) {
    const jd = `# Role\n\n${header}\n- Experience with Kubernetes and Terraform\n`;
    const found = extractJdSkills(jd);
    eq(`header "${header}" opens a requirements block`, found.includes('Kubernetes'), true);
  }

  // Regression: the block only closed on a markdown heading, so a posting whose
  // benefits section is plain text kept the block open to end-of-file and
  // reported perks as missing skills.
  const perksJd = `
# Role

## Requirements
- Experience with Kubernetes

Benefits and Perks (US Only)
- Competitive Equity and Healthcare
- Carrot fertility benefits
`;
  const perksSkills = extractJdSkills(perksJd);
  eq('requirement skill still extracted before the perks block', perksSkills.includes('Kubernetes'), true);
  eq('benefit "Equity" is not reported as a skill', perksSkills.includes('Equity'), false);
  eq('benefit "Healthcare" is not reported as a skill', perksSkills.includes('Healthcare'), false);
  eq('benefit "Carrot" is not reported as a skill', perksSkills.includes('Carrot'), false);

  // Regression: capitalized bullet-openers that describe disposition rather
  // than technology were surfacing as gaps ("Deep fluency in…", "Interest in…").
  const dispositionJd = `
# Role

## Requirements
- Deep fluency in TypeScript
- Interest in documentation as infrastructure
`;
  const dispositionSkills = extractJdSkills(dispositionJd);
  eq('real skill still extracted alongside a disposition opener', dispositionSkills.includes('TypeScript'), true);
  eq('does not extract "Deep" as a skill', dispositionSkills.includes('Deep'), false);
  eq('does not extract "Interest" as a skill', dispositionSkills.includes('Interest'), false);

  // Regression (#2540): a JD saved with CRLF line endings extracted zero skills.
  // JS treats \r as a line terminator, so `.` cannot consume it and the bare `$`
  // in BULLET_LINE_RE (no `m` flag) never matched — every requirement bullet
  // failed the test and the run returned an empty list, which reads exactly like
  // "no gaps" instead of erroring. Parity between the two line endings is the
  // property that was broken, so both variants are built explicitly: with
  // core.autocrlf the template literal below is already CRLF in a Windows
  // checkout, and an unnormalized fixture would compare CRLF against CRLF and
  // pass on the broken regex.
  const lineEndingJd = `
# Role

## Requirements
- Python, FastAPI, PostgreSQL
- Experience with Kubernetes
`;
  const lfSkills = extractJdSkills(lineEndingJd.replace(/\r\n/g, '\n'));
  const crlfSkills = extractJdSkills(lineEndingJd.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'));
  eq('CRLF JD extracts the same skills as the LF JD', crlfSkills, lfSkills);
  eq('CRLF JD extracts a non-zero number of skills', crlfSkills.length > 0, true);
  const lfClassification = classifySkillGaps(lfSkills, fakeCv);
  const crlfClassification = classifySkillGaps(crlfSkills, fakeCv);
  eq(
    'CRLF JD produces the same classification as the LF JD',
    JSON.stringify(crlfClassification),
    JSON.stringify(lfClassification)
  );

  // Regression (#1896): the reported bug. A CV alias and a JD's canonical name
  // must not read as a gap. Before the shared skill-extract canonicalization,
  // classify compared the two literally — "k8s" in the CV vs "Kubernetes" in
  // the JD — and reported Kubernetes as a false gap. canonicalize() now folds
  // both to "Kubernetes" so it resolves to the region the CV actually uses it.
  const aliasCv = `
# Skills
Python, k8s, Postgres

# Experience
Built data pipelines with golang microservices.
`;
  const aliasResult = classifySkillGaps(['Kubernetes', 'PostgreSQL', 'Go', 'Rust'], aliasCv);
  eq('CV "k8s" satisfies JD "Kubernetes" (named section, not a false gap)', aliasResult.existing.includes('Kubernetes'), true);
  eq('CV "Postgres" satisfies JD "PostgreSQL" (named section)', aliasResult.existing.includes('PostgreSQL'), true);
  eq('CV prose "golang" satisfies JD "Go" (supportedByResume)', aliasResult.supportedByResume.includes('Go'), true);
  eq('genuinely-absent Rust is still a real gap', aliasResult.gap.includes('Rust'), true);

  // Regression (#1896, answer 2): unknown/free tokens keep the prior
  // word-boundary behavior — canonicalize passes them through unchanged, so a
  // JD token the shared module does not know still matches (or not) exactly as
  // before, byte-for-byte.
  const freeTokenCv = `
# Skills
Python, Fabrikam-SDK

# Experience
Maintained the internal Fabrikam-SDK build.
`;
  const freeResult = classifySkillGaps(['Fabrikam-SDK', 'Contoso-Cloud'], freeTokenCv);
  eq('unknown token present in CV still matches (word-boundary fallback preserved)', freeResult.existing.includes('Fabrikam-SDK'), true);
  eq('unknown token absent from CV is still a real gap', freeResult.gap.includes('Contoso-Cloud'), true);

  // Regression: a known skill is decided by the canonical sets alone. Falling
  // through to the word-boundary search matched the JD's spelling in prose that
  // skill-extract declines to count, so a marketing CV "fine-tuning the funnel"
  // read as support for an ML requirement, and Go and SAFe had the same hole.
  const everydayCv = `
# Skills
HubSpot, Google Ads

# Experience
Fine-tuning the funnel to lift ROAS. Willing to go the extra mile; a safe pair of hands.
`;
  const everydayResult = classifySkillGaps(['Fine-tuning', 'Go', 'SAFe'], everydayCv);
  eq('CV prose "Fine-tuning the funnel" leaves JD "Fine-tuning" a gap', everydayResult.gap.includes('Fine-tuning'), true);
  eq('CV prose "go the extra mile" leaves JD "Go" a gap', everydayResult.gap.includes('Go'), true);
  eq('CV prose "a safe pair of hands" leaves JD "SAFe" a gap', everydayResult.gap.includes('SAFe'), true);

  const mlCv = `
# Skills
PyTorch, Fine-tuning, RAG

# Experience
Shipped a support assistant on Llama 3.
`;
  eq('a Skills entry "Fine-tuning" still satisfies JD "Fine-tuning"', classifySkillGaps(['Fine-tuning'], mlCv).existing, ['Fine-tuning']);

  // The edge of "known": a token that only contains a skill is not that skill.
  // "React.js" extracts as React, so the canonical sets never hold "React.js";
  // counting it as known left a CV that lists it with a false gap.
  const reactJdSkills = extractJdSkills('## Requirements\n- React.js and TypeScript\n');
  eq('the JD tokenizer yields "React.js" as one token', reactJdSkills, ['React.js', 'TypeScript']);
  eq(
    'JD "React.js" is satisfied by a Skills entry "React.js"',
    classifySkillGaps(reactJdSkills, '# Skills\nReact.js, TypeScript\n').existing,
    ['React.js', 'TypeScript']
  );

  // Regression (#2278): a JD the extractor cannot read returns zero skills, and
  // the three buckets then print exactly like "checked, no gaps found".
  // modes/pdf.md Step 4 uses this output as a gate, so the two cases have to be
  // distinguishable. diagnoseExtraction() is what makes the empty case loud.

  // Conclusive run: at least one skill classified, so no warning.
  eq(
    'a JD that yields skills is diagnosed as conclusive (no warning)',
    diagnoseExtraction(fakeJd, extractJdSkills(fakeJd)),
    null
  );

  // The reported shape: a real, substantial JD whose sections use none of the
  // recognized requirement headers. Nothing is ever scanned, so zero skills come
  // back — not because the candidate has no gaps, but because the check no-opped.
  const unreadableJd = `
# Enablement Content Manager

## What you'll do
- Own the ADDIE and SAM design lifecycle for field-facing curriculum
- Build role-based learning paths, certifications and accreditation programs
- Produce e-learning modules, talk tracks, playbooks and demo flows
- Administer the LMS and design assessments and validation programs
`;
  eq('unreadable JD extracts zero skills', extractJdSkills(unreadableJd).length, 0);
  eq(
    'zero skills with no requirements section is reported as no-requirements-section',
    diagnoseExtraction(unreadableJd, extractJdSkills(unreadableJd)).reason,
    'no-requirements-section'
  );

  // The other zero shape: the requirements section IS found and scanned, but no
  // skill candidates come out of it. Different cause, so a different reason code
  // — this one points at extraction, not at header matching.
  const parserEmptyJd = `
# Enablement Content Manager

## Requirements
- adult learning principles and instructional design
- blended and scenario-based learning
`;
  eq('parser-empty JD extracts zero skills', extractJdSkills(parserEmptyJd).length, 0);
  eq(
    'zero skills inside a scanned requirements section is reported as no-skill-candidates',
    diagnoseExtraction(parserEmptyJd, extractJdSkills(parserEmptyJd)).reason,
    'no-skill-candidates'
  );

  // The reason code must not be read as "the vocabulary is missing these".
  // SKILL_TOKEN_RE only captures uppercase-leading tokens, so a lowercase bullet
  // extracts nothing even when skill-extract.mjs knows every name in it. Same
  // reason code, and the message must stay true here too.
  const lowercaseKnownJd = `
# Role

## Requirements
- python and kubernetes experience
`;
  eq(
    'a lowercase bullet of KNOWN skills still extracts zero candidates',
    extractJdSkills(lowercaseKnownJd).length,
    0
  );
  eq(
    'the vocabulary does know those names, so the empty result is not a coverage gap',
    [...extractSkills('python and kubernetes')].sort().join(','),
    'Kubernetes,Python'
  );
  eq(
    'a parser-empty result is reported as no-skill-candidates, not as a vocabulary gap',
    diagnoseExtraction(lowercaseKnownJd, extractJdSkills(lowercaseKnownJd)).reason,
    'no-skill-candidates'
  );
  eq(
    'the no-skill-candidates message does not claim the vocabulary is missing the terms',
    /vocabulary/i.test(diagnoseExtraction(lowercaseKnownJd, []).message),
    false
  );

  // Text copied from a rendered posting page has no list markers. Each
  // requirement is a bare line, and the next section is a bare heading
  // between blank lines.
  const unbulletedJd = `
Qualifications

3+ years experience in a business analyst role
Experience working in Jira or Azure DevOps within an Agile framework
Strong analytical skills
Possess a curiosity around new technology
Python scripting for data pipelines

Core values for Acme employees

Have a strong work ethic
Be a flexible thinker
`;
  eq(
    'bare requirement lines under a bare heading are extracted',
    extractJdSkills(unbulletedJd).sort().join(','),
    'Agile,Azure,DevOps,Jira,Python'
  );
  eq(
    'an unbulleted extraction is conclusive',
    diagnoseExtraction(unbulletedJd, extractJdSkills(unbulletedJd)),
    null
  );

  // An empty file is its own cause and should not be blamed on header matching.
  eq(
    'an empty JD is reported as empty-jd',
    diagnoseExtraction('   \n  \n', []).reason,
    'empty-jd'
  );

  // Regression (#3601): Chinese headers (zh-TW / zh-CN) like 應徵條件 or 職務需求
  // must be recognized as requirement section openers, and headers like 工作內容
  // or 福利 must close the section.
  const zhJd = `
# PHP 後端工程師

## 應徵條件
- 熟悉 PHP、MySQL、Git
- 具 Linux 基本指令能力

## 工作內容
- 開發 RESTful API
- 維護既有 專案
`;
  const zhSkills = extractJdSkills(zhJd);
  eq('Chinese requirement header (應徵條件) extracts skills', zhSkills.includes('PHP'), true);
  eq('Chinese requirement header extracts MySQL', zhSkills.includes('MySQL'), true);
  eq('Chinese requirement header extracts Git', zhSkills.includes('Git'), true);
  eq('Chinese requirement header extracts Linux', zhSkills.includes('Linux'), true);
  eq('Chinese non-requirement header (工作內容) closes requirement section', zhSkills.includes('RESTful'), false);

  eq('Chinese header diagnosis is conclusive', diagnoseExtraction(zhJd, zhSkills), null);

  // Regression (#5292): a known multi-word skill was split into one-word
  // fragments ("Prompt", "Engineering"), none of which canonicalize() knows, so
  // a CV listing the skill still got two false gaps.
  const phraseJd = '## Requirements\n- Prompt Engineering and Computer Vision experience\n- Experience with Power BI and Tool Calling\n';
  const phraseSkills = extractJdSkills(phraseJd);
  eq('known multi-word skills are extracted whole, in JD order',
    phraseSkills, ['Prompt Engineering', 'Computer Vision', 'Power BI', 'Tool Calling']);
  eq('no fragment of a multi-word skill is extracted on its own',
    ['Prompt', 'Engineering', 'Computer', 'Vision', 'Power', 'BI', 'Tool', 'Calling'].some((t) => phraseSkills.includes(t)),
    false);
  eq('a CV that lists the multi-word skills has no gap for them',
    classifySkillGaps(phraseSkills, '# Skills\nPrompt Engineering, Computer Vision, Power BI, Tool Calling\n').gap,
    []);
  eq('a multi-word alias in the JD meets its canonical name in the CV',
    classifySkillGaps(extractJdSkills('## Requirements\n- Large Language Models in production\n'), '# Skills\nLLMs\n').existing,
    ['Large Language Models']);
  eq('a lowercase multi-word skill is still not a candidate (same rule as single words)',
    extractJdSkills('## Requirements\n- prompt engineering experience\n'),
    []);
  eq('"AI" and "Demo" are not reported as skills',
    extractJdSkills('## Requirements\n- Experience with a Demo of AI tools in Python\n'),
    ['Python']);

  // Regression (#5292): Chinese requirement text is never a candidate, and a
  // block that also names a few Latin tools printed as a complete check.
  const zhPartialJd = '任职要求\n- 熟悉 Prompt, Agent, RAG, Memory, Tool Calling 等基础原理\n- 有使用通用大模型、工作流或 AI 编程工具搭建 Demo 的经验\n- 有电商 C 端或直播产品经验者优先\n';
  const zhPartialSkills = extractJdSkills(zhPartialJd);
  eq('the Chinese repro extracts its Latin skills without fragments or noise',
    zhPartialSkills, ['Prompt', 'Agent', 'RAG', 'Memory', 'Tool Calling']);
  eq('the Chinese repro is conclusive (skills were classified)',
    diagnoseExtraction(zhPartialJd, zhPartialSkills), null);
  const zhCoverage = diagnoseCoverage(zhPartialJd);
  eq('Chinese requirement text that was not read is reported as partial coverage',
    zhCoverage && zhCoverage.reason, 'untokenized-cjk');
  eq('partial coverage lists the lines that were not read',
    zhCoverage && zhCoverage.unreadLines.length, 3);
  eq('a line whose only Chinese is a short connective counts as read',
    diagnoseCoverage('任职要求\n- 熟悉 PHP、MySQL、Git\n'), null);
  eq('an English JD has no partial-coverage note',
    diagnoseCoverage(phraseJd), null);
  eq('Chinese text outside a requirements block is not reported',
    diagnoseCoverage('## 工作内容\n- 负责直播产品的用户调研与数据分析\n'), null);

  // Numbered requirement items: Chinese postings number their requirements
  // ("1、", "1.", "(1)", "①") instead of bulleting them, and English ones use
  // markdown ordered lists ("1.", "2)"). They are list items, read like "-"
  // bullets, not bare lines.
  const numberedZhJd = `
任职要求
1、熟悉 Prompt、Agent、RAG 等基础原理
2.掌握 Python,了解 Docker;
(3)熟悉 Kubernetes
①有 Redis 经验
`;
  const numberedZhSkills = extractJdSkills(numberedZhJd);
  eq(
    'numbered zh items (1、 2. (3) ①) are read as requirement items',
    [...numberedZhSkills].sort().join(','),
    'Agent,Docker,Kubernetes,Prompt,Python,RAG,Redis'
  );
  eq('numbered zh items make the run conclusive', diagnoseExtraction(numberedZhJd, numberedZhSkills), null);

  const numberedEnJd = `
## Requirements
1. Python and Docker
2) Kubernetes
3.5+ years of Terraform
`;
  // "3.5+ years" is not an item marker, so that line is not stripped as item 3;
  // it is still read, whole, as a bare requirement line next to the items.
  eq(
    'markdown ordered items are read; a "3.5+ years" line is read as a bare line',
    [...extractJdSkills(numberedEnJd)].sort().join(','),
    'Docker,Kubernetes,Python,Terraform'
  );
  // Read as bare lines, numbered items with a blank line between them would
  // each look like an isolated heading and close the block after the first.
  eq(
    'numbered items separated by blank lines are all read',
    extractJdSkills('## Requirements\n1. Python\n\n2. Docker\n\n3. Terraform\n').join(','),
    'Python,Docker,Terraform'
  );

  // Regression: plain bullets still extract exactly as before.
  eq(
    'dash bullets under a zh header still extract',
    [...extractJdSkills('任职要求\n- 熟悉 Prompt, Agent, RAG 等基础原理\n')].sort().join(','),
    'Agent,Prompt,RAG'
  );

  // Inline list on the header line, with a full-width colon and CJK separators.
  eq(
    'inline zh header list with : and 、 extracts',
    [...extractJdSkills('任职要求:Python、Docker、Kubernetes\n')].sort().join(','),
    'Docker,Kubernetes,Python'
  );
  // ...but a full-width-colon sentence with no separator is still not a list.
  eq(
    'inline zh header with : and no separator is not read as a list',
    extractJdSkills('任职要求:Curious and kind\n').length,
    0
  );

  console.log(`\njd-skill-gap self-test: ${passed} passed, ${failed} failed`);
  if (failed > 0) process.exit(1);
}

// ── Main ─────────────────────────────────────────────────────────────

// Derived from the flags this file actually accepts, so `--help` cannot
// describe an option that does not exist.
const USAGE = `Usage:
  node jd-skill-gap.mjs <jd-file> [--summary] [--self-test]

  --summary    human-readable output instead of JSON
  --self-test  run the built-in checks
  --help, -h   print this and exit`;

if (isMainModule(import.meta.url)) {
  // BEFORE any work. Unhandled, `--help` fell through to the analysis: this
  // script printed a full report for it, which is not what the flag asks for
  // and hides that it was never recognised.
  if (process.argv.slice(2).some((a) => a === '--help' || a === '-h')) {
    console.log(USAGE);
    process.exit(0);
  }
if (selfTestMode) {
  runSelfTest();
} else {
  if (!jdPathArg || !existsSync(jdPathArg)) {
    console.error('Usage: node jd-skill-gap.mjs <jd-file> [--summary]');
    console.error('       node jd-skill-gap.mjs --self-test');
    process.exit(1);
  }
  if (!existsSync(CV_PATH)) {
    console.error(`Error: cv.md not found at ${CV_PATH}`);
    console.error('Create it there, or point CAREER_OPS_ROOT / CAREER_OPS_DATA_DIR (or a .career-ops-data marker) at the directory that already has it.');
    process.exit(1);
  }

  const jdText = readFileSync(jdPathArg, 'utf-8');
  const cvText = readFileSync(CV_PATH, 'utf-8');
  const jdSkills = extractJdSkills(jdText);
  const result = classifySkillGaps(jdSkills, cvText);
  // Computed for BOTH output modes. The JSON branch is the one other tools
  // consume (modes/pdf.md Step 4 gates on it), so it is the branch that most
  // needs to say "nothing was classified" out loud - an empty three-bucket
  // object is otherwise indistinguishable from a clean bill of health.
  const diagnosis = diagnoseExtraction(jdText, jdSkills);
  const coverage = diagnoseCoverage(jdText);

  if (summaryMode) {
    console.log(`\nJD Skill-Gap Check`);
    console.log('─'.repeat(40));
    console.log(`JD skills found: ${jdSkills.length}`);
    console.log(`  ✅ Already in Skills section:   ${result.existing.join(', ') || '(none)'}`);
    console.log(`  📝 Mentioned in resume prose:   ${result.supportedByResume.join(', ') || '(none)'}`);
    console.log(`  ⚠️  Real gaps (not found anywhere): ${result.gap.join(', ') || '(none)'}`);
    // Without a recognized skills heading every match lands in "prose", which
    // reads like a CV that lists no skills rather than a heading this script
    // did not recognize (#4879).
    if (!splitSkillsSection(cvText).namedSkillsText.trim()) {
      console.log('');
      console.log('  ℹ️  No skills section recognized in cv.md (a heading containing "Skills" or "Competencies", or a top-level');
      console.log('     "Tools" / "Technologies" / "Tech Stack" heading), so nothing can be "already in Skills section".');
    }

    // An empty three-bucket summary is indistinguishable from a clean bill of
    // health, and modes/pdf.md Step 4 treats this output as a gate. Say out loud
    // that nothing was classified. Still exit 0: this is a warning, not a
    // failure, and the user decides how to proceed.
    if (diagnosis) {
      console.log('');
      console.log('  🚨 LOW CONFIDENCE: this is not a clean result.');
      console.log(`     ${diagnosis.message}`);
      console.log(`     (reason: ${diagnosis.reason})`);
    }
    if (coverage) {
      console.log('');
      console.log('  ℹ️  PARTIAL COVERAGE: not every requirement was checked.');
      console.log(`     ${coverage.message}`);
      for (const line of coverage.unreadLines) console.log(`       - ${line}`);
    }
  } else {
    // Additive key: existing consumers reading the three buckets are unaffected.
    // null when the run was conclusive, {reason, message} when it was not.
    // partialCoverage: null, or the requirement lines that were not read.
    console.log(JSON.stringify({ ...result, lowConfidence: diagnosis, partialCoverage: coverage }, null, 2));
  }
}
} // end CLI guard