jd-skill-gap.mjs
Rama main · réplica de lectura del repositorio original.
#!/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