#!/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 (? 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(`(? !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 [--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 [--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