#!/usr/bin/env node /** * check-jd-archive.mjs — JD Archival Validator for career-ops * * A report's `**URL:**` header is a live pointer, not an archive — it rots * the day a posting closes or gets taken down, which reliably happens * somewhere in the weeks between applying and a later interview round. Prior * to this script, "also archive the JD" was a prompt-only instruction in * `modes/pdf.md` step 4 ("write the JD to jds/{slug}.md if it isn't already * one") with zero code enforcement — on a real tracker with 7 saved reports, * `jds/` was completely empty. This is the watchdog: zero LLM, zero network, * zero writes. * * A report counts as archived when EITHER holds: * (a) it carries a `## Job Description` section (the primary mechanism — * `## Job Description (archived verbatim)` per modes/oferta.md and * modes/pdf.md) with substantive content, not an empty placeholder, or * (b) a corresponding jds/ capture exists for it. * * (b) reuses jd-capture.mjs's findCaptureForReport — the same report-number * lookup outcome.mjs already relies on — rather than inventing a new slug * matcher. That function only resolves captures written with a numeric * report-number prefix (archive-posting.mjs --report=N), which AGENTS.md's * "JD captures (jds/)" section already documents as the recommended writer; * the other jds/ naming conventions in play (date-prefixed, sha1-suffixed, * bare company-role slugs) have no report number to key on and are not * reliably resolvable back to a specific report, so they cannot be credited * here. The report's own filename ({###}-{company-slug}-{YYYY-MM-DD}.md) * already carries the canonical company slug used to disambiguate captures — * no separate slugify pass is needed. * * Reports missing BOTH are flagged. The severity of that flag depends on the * report's OWN tracker row (PR #2791 review, 2026-08-14 to 2026-08-17): * * - No tracker available to join against (fresh install, no * data/applications.md yet, or a caller testing reports/jds/ in * isolation): the legacy hard `missing-jd-archive` finding — there is no * retroactive corpus to reason about, so every report is presumptively * the "going forward" case and gets full enforcement. * - Tracker row resolves to a TERMINAL state (Rejected, Discarded, SKIP, * Hired): no finding at all, not even a warning. The application is * done; a dead JD carries no further risk. * - Tracker row resolves to a LIVE state (Evaluated, Applied, Responded, Assessment, * Interview, Offer), OR the join can't resolve the row (no match, * ambiguous match, or an unreadable/unparseable tracker) — a soft * `jd-archive-review-due` finding, same severity shape as * check-table-freshness.mjs's `review-due`: visible, never blocking CI. * santifer's ruling explicitly rejected a report-age/creation-date * cutoff here — the risk this validator exists for (a posting dying * before the JD is needed again) tracks whether the APPLICATION is * still live, not when the REPORT was written, so an old-but-live row * gets the same soft-warning treatment as a report written yesterday. * * Terminal/live classification joins report numbers against * data/applications.md via tracker-parse.mjs's own extractTrackerReportNumbers * (the same report<->row lookup set-status.mjs already relies on) and * templates/states.yml via tracker-utils.mjs's loadCanonicalStates / * resolveCanonicalState — no second tracker parser, read-only, fail-soft. * * Run: node check-jd-archive.mjs (JSON to stdout) * node check-jd-archive.mjs --summary (human-readable table) * node check-jd-archive.mjs --reports-dir path/to (override, for testing) * node check-jd-archive.mjs --jds-dir path/to (override, for testing) * node check-jd-archive.mjs --tracker path/to (override, for testing) * node check-jd-archive.mjs --no-tracker (skip the tracker join; legacy hard behavior) * node check-jd-archive.mjs --self-test * node check-jd-archive.mjs --help * * Exit codes: 1 if any hard `missing-jd-archive` finding, 0 otherwise — * `jd-archive-review-due` alone never fails the run (matches * check-table-freshness.mjs's `review-due`). * * Issue #2789 — github.com/santifer/career-ops */ import { readFileSync, readdirSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; import { join, dirname, basename, relative, isAbsolute } from 'path'; import { tmpdir } from 'os'; import { fileURLToPath } from 'url'; import { flagValue } from './lib/cli-flags.mjs'; import { findCaptureForReport } from './jd-capture.mjs'; import { getCareerOpsRoot } from './path-resolver.mjs'; import { resolveColumns, parseTrackerRow, extractTrackerReportNumbers } from './tracker-parse.mjs'; import { loadCanonicalStates, resolveCanonicalState, resolveTrackerPath } from './tracker-utils.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; import { JD_ARCHIVE_HEADING, JD_HEADING_RE, NEXT_REPORT_SECTION_RE } from './lib/jd-archive-section.mjs'; const CAREER_OPS = dirname(fileURLToPath(import.meta.url)); const DATA_ROOT = getCareerOpsRoot(); const DEFAULT_REPORTS_DIR = join(DATA_ROOT, 'reports'); const DEFAULT_JDS_DIR = join(DATA_ROOT, 'jds'); const DEFAULT_TRACKER_PATH = resolveTrackerPath(DATA_ROOT); const STATES_FILE = join(CAREER_OPS, 'templates/states.yml'); // The retroactive-enforcement terminal set (PR #2791, santifer 2026-08-17: // "build the state-based split you described" — Schlaflied 2026-08-16). // Confirmed against templates/states.yml's own canonical labels. "Offer" is // deliberately EXCLUDED even though states.yml marks it `terminal: true` for // lifecycle-ordering purposes (tracker-sync-check.mjs) — an offer in hand is // still an active, undecided application, not a closed file, and the JD may // still be needed for negotiation or comparison. const RETROACTIVE_TERMINAL_LABELS = new Set(['Rejected', 'Discarded', 'SKIP', 'Hired']); // Below this many non-whitespace characters, a "## Job Description" section // is treated as an unfilled placeholder, not an archive — a bare heading // with nothing under it (or a one-line "(see URL above)" stub) should not // pass as verbatim JD text. const MIN_ARCHIVE_CHARS = 40; const USAGE = `Usage: node check-jd-archive.mjs # full JSON findings to stdout node check-jd-archive.mjs --summary # human-readable table node check-jd-archive.mjs --reports-dir # override reports/ (testing) node check-jd-archive.mjs --jds-dir # override jds/ (testing) node check-jd-archive.mjs --tracker # override data/applications.md (testing) node check-jd-archive.mjs --no-tracker # skip the tracker join (legacy hard-only behavior) node check-jd-archive.mjs --self-test # run the in-memory test suite node check-jd-archive.mjs --help # print this usage block and exit`; // --- CLI args --- const args = process.argv.slice(2); const summaryMode = args.includes('--summary'); const selfTestMode = args.includes('--self-test'); const reportsDirArg = flagValue(args, '--reports-dir') ?? null; const jdsDirArg = flagValue(args, '--jds-dir') ?? null; const trackerArg = flagValue(args, '--tracker') ?? null; const noTrackerMode = args.includes('--no-tracker'); // --- Report filename parsing --- // reports/{###}-{company-slug}-{YYYY-MM-DD}.md (AGENTS.md "Save report .md"). // The company slug may itself contain hyphens (e.g. confidential-hays for // agency-mediated postings, #1596), so the match is anchored on the fixed // numeric-prefix and trailing-date shapes and everything between is the slug. const REPORT_FILENAME_RE = /^(\d+)-(.+)-(\d{4}-\d{2}-\d{2})\.md$/; export function parseReportFilename(filename) { const m = REPORT_FILENAME_RE.exec(filename); if (!m) return null; return { reportNum: parseInt(m[1], 10), companySlug: m[2], date: m[3] }; } // --- Archive-section detection --- // JD_HEADING_RE and NEXT_REPORT_SECTION_RE are shared with the evaluators that // write the section (lib/jd-archive-section.mjs), so writer and reader agree on // where it starts and ends. Content runs to the next KNOWN report-section // heading or end of file, not to any "## " line, because a verbatim JD can // carry its own sub-headings (CodeRabbit, PR #2791). HTML comments are // stripped before the length check so a commented-out template stub doesn't // count as archived text. // The unfilled modes/oferta.md template placeholder ("(the posting's full // text, pasted verbatim — see requirement below)") is 66 characters — longer // than MIN_ARCHIVE_CHARS, so a report where the evaluator copied the // template heading but never filled it in would otherwise pass validation // (CodeRabbit, PR #2791). Reject it explicitly rather than relying on length // alone. const UNFILLED_TEMPLATE_PLACEHOLDER = "(the posting's full text, pasted verbatim — see requirement below)"; // Every localized evaluation mode (modes//*.md) carries its own // translation of that placeholder under the same heading, and each one clears // MIN_ARCHIVE_CHARS too. The set is read from the templates themselves rather // than copied here, so a new locale or a reworded placeholder cannot drift out // of it. A placeholder is the parenthesized line (ASCII or full-width // parentheses, for the CJK templates) directly under the heading. The English // constant stays in the set even when modes/ cannot be read. const PLACEHOLDER_LINE_RE = /^[((].*[))]$/; // Whitespace-insensitive, so a placeholder re-wrapped or re-indented on paste // is still recognized. Whitespace is removed, not collapsed to a space: a line // break between two CJK characters has no space to collapse to. const normalizePlaceholder = (text) => text.replace(/\s+/g, ''); export function collectTemplatePlaceholders(modesDir) { const placeholders = new Set([normalizePlaceholder(UNFILLED_TEMPLATE_PLACEHOLDER)]); const files = []; let entries; try { entries = readdirSync(modesDir, { withFileTypes: true }); } catch { return placeholders; } for (const entry of entries) { const path = join(modesDir, entry.name); if (entry.isFile() && entry.name.endsWith('.md')) { files.push(path); } else if (entry.isDirectory()) { // One unreadable locale directory must not drop every other locale. try { for (const name of readdirSync(path)) { if (name.endsWith('.md')) files.push(join(path, name)); } } catch { continue; } } } for (const file of files) { let lines; try { lines = readFileSync(file, 'utf-8').split(/\r?\n/); } catch { continue; } lines.forEach((line, i) => { const next = (lines[i + 1] ?? '').trim(); if (line.trim() === JD_ARCHIVE_HEADING && PLACEHOLDER_LINE_RE.test(next)) { placeholders.add(normalizePlaceholder(next)); } }); } return placeholders; } // The templates require a leading "Posted: ..." line in the section // (modes/oferta.md), so an evaluator can fill that line in and leave the // placeholder under it. The line is dropped before the comparison, as // isPathOnlyPointer() does below. // // batch/batch-prompt.md carries a placeholder of its own, which clears // MIN_ARCHIVE_CHARS too. It is outside modes/ and braced rather than // parenthesized, so the collector never sees it, and batch-runner.sh rewrites // the {{JD_FILE}} inside it, so it can only be matched by shape. The template's // closing --- rule stays in the extracted section, so it is dropped first. const BATCH_TEMPLATE_PLACEHOLDER_RE = /^\{the JD text from .+ pasted here verbatim\}$/i; let templatePlaceholders = null; function isUnfilledTemplatePlaceholder(strippedSection) { templatePlaceholders ??= collectTemplatePlaceholders(join(CAREER_OPS, 'modes')); const withoutPostedLine = strippedSection.replace(POSTED_LINE_RE, ''); return templatePlaceholders.has(normalizePlaceholder(withoutPostedLine)) || BATCH_TEMPLATE_PLACEHOLDER_RE.test(withoutPostedLine.replace(/^-{3,}\s*$/gm, '').trim().replace(/\s+/g, ' ')); } // modes/oferta.md documents a second legitimate shape for this section: a // pointer to a jds/ capture in place of the verbatim text, for a JD too long // to paste inline. A pointer sentence like "See jds/042-acme-2026-01-15.md // for the full archive (archive-posting.mjs --report=042)." easily clears // MIN_ARCHIVE_CHARS on length alone, which would let a WRONG or NONEXISTENT // path pass validation without ever being resolved (CodeRabbit, PR #2791 // round 3). // // A section counts as a path-only pointer — and is NOT credited as embedded // text, forcing checkJdArchive()'s findCaptureForReport() resolution to // actually verify the capture — only when the ENTIRE section (after an // optional leading "Posted: ..." line) matches nothing but the canonical // pointer sentence below. Substantive JD prose that merely mentions a jds/ // path in passing must still be credited directly (CodeRabbit, PR #2791 // round 4) — an earlier version of this check treated ANY jds/*.md // reference anywhere in the section as pointer-only, which wrongly rejected // real archived text alongside an incidental path mention. // Tracker links also name PDF captures from archive-posting and plain text // captures. The embedded pointer sentence below accepts .md and .pdf. const JDS_PATH_RE = /jds\/[^\s()<>]+?\.(?:pdf|txt|md)(?=$|[\s)\]`>]|[.,;:!?](?=\s|$))/i; const POSTED_LINE_RE = /^Posted:.*$/im; const POINTER_SENTENCE_RE = /^See\s+jds\/[^\s()]+\.(?:md|pdf)\s+for the full archive(?:\s*\(archive-posting\.mjs\s+--report=\d+\))?\.?$/i; function isPathOnlyPointer(strippedSection) { const withoutPostedLine = strippedSection.replace(POSTED_LINE_RE, '').trim(); return POINTER_SENTENCE_RE.test(withoutPostedLine); } // A third rejection category, alongside the unfilled-template placeholder and // the path-only pointer sentence above: content that clears MIN_ARCHIVE_CHARS // on length alone but was never a job posting to begin with — a fetch that // hit a login/auth wall, a 404 shell, a paywall interstitial, or a "please // enable JavaScript" placeholder instead of the real page (#3829). Without // this, a report archives e.g. "Sign in to view this job · Join LinkedIn to // see who you know at Acme" (well past 40 chars) under a heading that // promises "(archived verbatim)", and the validator reports green — a false // positive worse than no archive at all, since the user believes the real // posting is saved. // // Each pattern is anchored to phrasing distinctive enough not to collide // with real JD prose. Deliberately NOT included: generic "this posting has // closed" / "no longer available" phrasing standing alone — an ATS that // shows a genuine "this posting has closed" message for an EXPIRED listing // is legitimate content worth keeping for the record (the report may still // need the JD's substance for a later comparison). Only the specific // login-wall/404-shell/paywall/JS-required shapes the issue calls out are // rejected here, not every possible "closed" phrasing — over-rejecting a // real archived-but-closed posting would be its own bug (#3829 analysis). const NON_CONTENT_MARKERS = [ { category: 'login-wall', reason: 'looks like a sign-in/login wall, not a posting', re: /(sign in to view this job|join linkedin to see who you know at|please log in to continue|sign in to continue)/i, }, { category: '404-shell', reason: 'looks like a 404 / page-not-found shell, not a posting', re: /(404\s*(?:error|not found)|page not found|this job posting is no longer available)/i, }, { category: 'paywall', reason: 'looks like a paywall/subscription interstitial, not a posting', re: /(subscribe to continue reading|this content is for subscribers)/i, }, { category: 'js-required', reason: 'looks like a "please enable JavaScript" shell, not a posting', re: /(please enable javascript|this site requires javascript|#-]*(?:paraphrased|restated) (?:from|version)\b|[—–-]\s*paraphras(?:e|ed)\s*$|\b(?:what follows|the following|(?:the )?(?:text|passages?|content) below|this (?:is|description|section|posting|text|summary|document))[^.\n]{0,60}?\bnot (?:a |the )?(?:literal|verbatim) (?:copy|quote|text|reproduction|archive|posting)\b|\bparaphrase,? not (?:a |the )?(?:literal|verbatim)\b|\b(?:tool|fetcher|evaluator|scraper)\b[^.\n]{0,30}?\b(?:could|would|did|can) ?not (?:be )?(?:reproduc|quot|return|captur|copi)\w*[^.\n]{0,40}\b(?:literal|verbatim)\b)/im, }, { category: 'bot-challenge', // Matched on established challenge PHRASES, deliberately not the bare // word "captcha" — a real posting can legitimately mention CAPTCHA as a // technology (a security-engineering role's requirements, say), and that // single word alone is not distinctive enough to tell the two apart. // These phrases are how the actual interstitial page renders, not a // topic a JD would describe. reason: 'looks like a bot-verification/challenge page, not a posting', re: /(checking your browser before accessing|verify(?:ing)? (?:that )?you are (?:a )?human|please stand by,? while we (?:are )?check(?:ing)? your browser|cloudflare ray id|captcha verification required to continue)/i, }, ]; // Returns the first matching marker ({category, reason}) or null. Exported // so checkJdArchive() can surface the specific reason in a finding's detail // (per #3829: "`--summary` should name the reason so a user reads 'looks // like a sign-in wall, not a posting' rather than a generic miss") — a // separate lookup from the boolean hasEmbeddedJdArchive() below so the two // stay independently testable while sharing the exact same stripped-section // input. export function detectNonContentMarker(strippedSection) { for (const marker of NON_CONTENT_MARKERS) { if (marker.re.test(strippedSection)) return marker; } return null; } // Shared section extraction, used by both hasEmbeddedJdArchive() and the // non-content-marker lookup in checkJdArchive() so the two never drift on // what counts as "the stripped section text" (heading match, next-heading // truncation, and the fixed-point comment strip all applied identically). // Returns the trimmed section text, or null when there is no // "## Job Description" heading at all. export function extractStrippedJdSection(content) { const text = String(content ?? ''); const m = JD_HEADING_RE.exec(text); if (!m) return null; const rest = text.slice(m.index + m[0].length); const nextHeadingOffset = rest.search(NEXT_REPORT_SECTION_RE); const section = nextHeadingOffset === -1 ? rest : rest.slice(0, nextHeadingOffset); // Loop the comment-strip to a fixed point rather than a single pass: a // single `.replace()` can leave a reconstructible `` behind when // the input has overlapping/nested comment-marker fragments (removing an // inner match splices the surrounding characters into a new valid match // the single pass never re-scans) — CodeQL "incomplete multi-character // sanitization" on PR #2791. let stripped = section; let prev; do { prev = stripped; stripped = stripped.replace(//g, ''); } while (stripped !== prev); return stripped.trim(); } // Marker lookup over the whole section, heading line included. The heading is // outside extractStrippedJdSection()'s text, and an honest evaluator may retitle // it ("## Job Description (paraphrase, not verbatim)") without touching the body. export function detectJdSectionMarker(content) { const stripped = extractStrippedJdSection(content); if (stripped === null) return null; const heading = JD_HEADING_RE.exec(String(content ?? ''))[0]; // The heading is evaluator-written, never JD text, so a plain "not verbatim" // there is a disclosure ("(not verbatim)", ", not verbatim"). const headingDisclosure = /\bnot (?:a |the )?(?:literal|verbatim)\b/i.test(heading) ? NON_CONTENT_MARKERS.find((m) => m.category === 'paraphrase') : null; return detectNonContentMarker(stripped) ?? detectNonContentMarker(heading) ?? headingDisclosure; } export function hasEmbeddedJdArchive(content) { const stripped = extractStrippedJdSection(content); if (stripped === null) return false; if (isUnfilledTemplatePlaceholder(stripped)) return false; if (isPathOnlyPointer(stripped)) return false; if (detectJdSectionMarker(content)) return false; return stripped.length >= MIN_ARCHIVE_CHARS; } // --- Tracker join (retroactive-enforcement classification) --- /** * Classify every report number the tracker links to as 'terminal' or 'live', * for the retroactive-enforcement severity split (PR #2791 review). Read-only * — never writes, never touches applications.md's own lock. * * Reuses tracker-parse.mjs's own row parsing and report<->row lookup * (extractTrackerReportNumbers, the same one set-status.mjs's `--report N` * selector calls) and tracker-utils.mjs's loadCanonicalStates / * resolveCanonicalState for states.yml — no second tracker parser, no second * alias table. * * Returns null when there is nothing to join against at all (no tracker * file, unreadable tracker, or an unparseable/missing states.yml) — the * caller's contract is that null means "fall back to legacy hard * enforcement," NOT "treat every report as live." A report number simply * absent from the returned map (no linking row, or 2+ rows linking it) is * the fail-soft case instead: the caller treats a missing map entry as * 'live', matching santifer's "a row the join can't resolve gets the * warning, not an error" instruction. * * @param {string|null} trackerPath - Path to applications.md, or null/absent to skip the join. * @param {string} statesPath - Path to templates/states.yml. * @returns {Map|null} */ export function classifyReportsByTrackerState(trackerPath, statesPath) { if (!trackerPath || !existsSync(trackerPath)) return null; let lines; try { lines = readFileSync(trackerPath, 'utf-8').split('\n'); } catch { return null; // unreadable tracker — fall back to legacy hard enforcement, not a crash } let states; try { states = loadCanonicalStates(statesPath); } catch { return null; // unreadable/malformed states.yml — same fallback } const colmap = resolveColumns(lines); const byReport = new Map(); // reportNum -> matching tracker rows for (const line of lines) { const row = parseTrackerRow(line, colmap); if (!row) continue; for (const num of extractTrackerReportNumbers(row.report, row.notes)) { if (!byReport.has(num)) byReport.set(num, []); byReport.get(num).push(row); } } const classification = new Map(); for (const [num, matches] of byReport) { // Ambiguous — 2+ rows link the same report number — is the fail-soft // case, same as "no row at all": leave it OUT of the map so the caller // defaults it to 'live' rather than guessing which row is authoritative. if (matches.length !== 1) continue; const canonicalLabel = resolveCanonicalState(matches[0].status, states); if (canonicalLabel && RETROACTIVE_TERMINAL_LABELS.has(canonicalLabel)) { classification.set(num, 'terminal'); } // An unrecognized/unresolvable status cell also stays out of the map — // fail-soft to 'live', not an error. } return classification; } /** * Map reportNum -> an existing `jds/` capture named by a tracker row. * * `findCaptureForReport` resolves a capture only by its FILENAME — a report-number * prefix (`276-…`) or the report's company slug. A capture named freely * (`xr-bug-triage-sunnyvale-agency-2026-08-19.md`) is invisible to it even when a * tracker row links it explicitly, so a genuinely archived application read as * unarchived. The tracker's own link is the authoritative statement that THIS * capture belongs to THIS application, so consult it before reporting a miss. * * Only captures that exist on disk are mapped: a dangling link is not an archive. * Paths are resolved against the data root (the parent of `jdsDir`) because the * link is written root-relative (`jds/foo.md`). * * @param {string|null} trackerPath * @param {string} jdsDir * @returns {Map} reportNum -> absolute capture path */ export function mapTrackerCaptureLinks(trackerPath, jdsDir) { const map = new Map(); if (!trackerPath || !existsSync(trackerPath)) return map; let lines; try { lines = readFileSync(trackerPath, 'utf-8').split('\n'); } catch { return map; // unreadable tracker — fail soft, exactly like classifyReportsByTrackerState } const dataRoot = dirname(jdsDir); const colmap = resolveColumns(lines); for (const line of lines) { const row = parseTrackerRow(line, colmap); if (!row) continue; const cell = `${row.report ?? ''} ${row.notes ?? ''}`; const match = cell.match(JDS_PATH_RE); if (!match) continue; if (match[0].split(/[\\/]/).includes('..')) continue; // The link is written root-relative (`jds/foo.md`), but the captures // directory is overridable with --jds-dir, so the literal `jds/` segment is // not guaranteed to be where they live. Try the root-relative path first, // then the same basename inside the directory actually in use. // A link such as `jds/../../x.pdf` must not reach a file outside the data // root, or the capture directory in use. const within = (base, candidate) => { const rel = relative(base, candidate); return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); }; // Reject an escaping link outright, so the basename fallback cannot credit // an unrelated in-root file that happens to share its name. if (!within(dataRoot, join(dataRoot, match[0]))) continue; const abs = [join(dataRoot, match[0]), join(jdsDir, basename(match[0]))] .find((candidate) => existsSync(candidate)); if (!abs) continue; // dangling link is not an archive // extractTrackerReportNumbers only recognizes `reports/` links, so a row // whose Report cell points straight at a capture — `[276](jds/foo.md)` — // yields no number at all. Fall back to the row's own id so such a row is // still keyed; without this the capture is found and then dropped. const keys = extractTrackerReportNumbers(row.report, row.notes); if (!keys.length && Number.isInteger(row.num)) keys.push(row.num); for (const num of keys) { if (!map.has(num)) map.set(num, abs); } } return map; } // --- Core check --- // Pure function over an already-resolved reports/jds directory pair, so the // self-test runs entirely on its own fixtures. `trackerPath`/`statesPath` are // optional — omitting trackerPath (or pointing it at a file that doesn't // exist) preserves the pre-#2791-review hard-enforcement-only behavior, which // is what every fixture-only self-test below still exercises. export function checkJdArchive(reportsDir, jdsDir, { trackerPath = null, statesPath = STATES_FILE } = {}) { const findings = []; const warnings = []; let reportsScanned = 0; if (!existsSync(reportsDir)) { return { reportsScanned, findings, warnings }; } const classification = classifyReportsByTrackerState(trackerPath, statesPath); const trackerCaptures = mapTrackerCaptureLinks(trackerPath, jdsDir); const files = readdirSync(reportsDir) .filter((f) => f.endsWith('.md') && !/^\d+-RESERVED\.md$/.test(f)) .sort(); for (const file of files) { reportsScanned += 1; let content; try { content = readFileSync(join(reportsDir, file), 'utf-8'); } catch (e) { warnings.push({ type: 'warning', file, detail: `could not read file: ${e.message.split('\n')[0]}` }); continue; } if (hasEmbeddedJdArchive(content)) continue; const meta = parseReportFilename(file); let capture = null; if (meta && existsSync(jdsDir)) { capture = findCaptureForReport(jdsDir, meta.reportNum, { companySlug: meta.companySlug }); } if (capture !== null) continue; const reportNum = meta ? meta.reportNum : null; // The tracker may name a capture this report's own filename cannot resolve // (freely-named capture, no number prefix, different slug). That link is an // explicit statement of ownership, so honor it before reporting a miss. if (reportNum !== null && trackerCaptures.has(reportNum)) continue; // No tracker joined at all -> legacy hard behavior (going-forward / // fresh-install case: nothing to classify against, so full enforcement). // Tracker joined -> 'terminal' skips entirely; anything else (explicit // 'live', or absent from the map at all — no row, ambiguous rows, or an // unresolvable status) is the soft review-due finding. const state = classification === null ? null : (reportNum !== null && classification.get(reportNum) === 'terminal' ? 'terminal' : 'live'); if (state === 'terminal') continue; // done deal, zero retroactive risk — not even a warning // A rejected section may carry a specific reason (fetch-failure/ // non-content marker, #3829) rather than just being absent or too // short — surface it so a user reads "looks like a sign-in wall, not a // posting" instead of a generic miss. const marker = detectJdSectionMarker(content); const detail = marker ? `"## Job Description" section ${marker.reason} — not credited as an archive, and no jds/ capture found${meta ? ` for report ${meta.reportNum} (company slug "${meta.companySlug}")` : ''}` : meta ? `no "## Job Description" section with archived JD text, and no jds/ capture found for report ${meta.reportNum} (company slug "${meta.companySlug}")` : `no "## Job Description" section with archived JD text, and the filename does not match the {###}-{company-slug}-{YYYY-MM-DD}.md convention so no jds/ capture could be resolved`; findings.push(state === 'live' ? { type: 'jd-archive-review-due', file, report: reportNum, companySlug: meta ? meta.companySlug : null, detail: `${detail} — tracker row is still live (or unresolved), so this is a soft warning, not a blocker`, } : { type: 'missing-jd-archive', file, report: reportNum, companySlug: meta ? meta.companySlug : null, detail, }); } return { reportsScanned, findings, warnings }; } export const hasMissingArchive = (findings) => findings.some((f) => f.type === 'missing-jd-archive'); // States that mean an application was actually SENT. A row in one of these has // a real counterparty and a real history, so having no record of what the role // asked for is a hole in the candidate's own archive — distinct from `SKIP` / // `Evaluated`, which were never applied to and are out of scope here. const SUBMITTED_LABELS = new Set(['Applied', 'Responded', 'Interview', 'Offer', 'Hired', 'Rejected']); /** * Applications that were SENT but have no archive of any kind. * * `checkJdArchive` iterates `reports/*.md`, so an application recorded as a * tracker row with no report file is invisible to it — never flagged, never * counted, even though it is the case where the record is *most* incomplete. * Agency-sourced roles land here routinely: they arrive as a pasted description * with no posting URL, so no report is ever written. * * Soft by design — these are returned separately and never set a non-zero exit, * matching how `jd-archive-review-due` behaves. * * @returns {Array<{type: string, row: string, company: string, role: string, status: string, detail: string}>} */ export function findUnarchivedApplications(trackerPath, reportsDir, jdsDir, statesPath = STATES_FILE) { if (!trackerPath || !existsSync(trackerPath)) return []; let lines; let states; try { lines = readFileSync(trackerPath, 'utf-8').split('\n'); states = loadCanonicalStates(statesPath); } catch { return []; // unreadable tracker or states.yml — fail soft, never crash a health check } const trackerCaptures = mapTrackerCaptureLinks(trackerPath, jdsDir); const reportFiles = existsSync(reportsDir) ? readdirSync(reportsDir).filter((f) => f.endsWith('.md')) : []; const reportsByNum = new Map(); for (const f of reportFiles) { const meta = parseReportFilename(f); if (meta) reportsByNum.set(meta.reportNum, f); } const colmap = resolveColumns(lines); const out = []; for (const line of lines) { const row = parseTrackerRow(line, colmap); if (!row) continue; const label = resolveCanonicalState(row.status, states); if (!label || !SUBMITTED_LABELS.has(label)) continue; // Same fallback as mapTrackerCaptureLinks: a Report cell pointing straight // at a capture carries no `reports/` link to extract a number from. const nums = extractTrackerReportNumbers(row.report, row.notes); const keys = nums.length ? nums : (Number.isInteger(row.num) ? [row.num] : []); // Any linked capture, or any report file that exists, counts as a record. const hasCapture = keys.some((n) => trackerCaptures.has(n)); const hasReport = keys.some((n) => reportsByNum.has(n)); if (hasCapture || hasReport) continue; out.push({ type: 'application-no-archive', row: row.num ?? '?', company: row.company ?? '?', role: row.role ?? '?', status: label, detail: 'application was sent but has no report file and no jds/ capture — ' + 'nothing records what the role asked for', }); } return out; } // --- Summary mode --- function printSummary(result) { const { reportsScanned, findings, warnings } = result; console.log(`\n${'='.repeat(78)}`); console.log(' JD Archive Coverage — career-ops'); console.log(` reports scanned: ${reportsScanned}`); console.log(`${'='.repeat(78)}\n`); if (findings.length === 0) { console.log(reportsScanned === 0 ? ' No report files found under reports/.\n' : ' Every report has an archived JD (embedded section or jds/ capture).\n'); } else { const header = ' ' + 'Type'.padEnd(22) + 'Report'.padEnd(10) + 'File'.padEnd(30) + 'Detail'; console.log(header); console.log(' ' + '-'.repeat(90)); for (const f of findings) { const typeCol = f.type.padEnd(22); const reportCol = (f.report !== null ? String(f.report) : '?').padEnd(10); const fileCol = f.file.substring(0, 28).padEnd(30); console.log(' ' + typeCol + reportCol + fileCol + f.detail); } console.log(''); } if (warnings.length) { console.log(` ${warnings.length} warning${warnings.length === 1 ? '' : 's'} (files skipped, never fatal):`); for (const w of warnings) { console.log(` ${w.file}: ${w.detail}`); } console.log(''); } } function printUnarchivedApplications(rows) { if (!rows.length) return; console.log(' Applications sent with no archive at all (soft — never blocks):'); console.log(' ' + 'Row'.padEnd(8) + 'Status'.padEnd(12) + 'Company'.padEnd(28) + 'Role'); console.log(' ' + '-'.repeat(90)); for (const r of rows) { console.log(' ' + String(r.row).padEnd(8) + r.status.padEnd(12) + String(r.company).substring(0, 26).padEnd(28) + String(r.role).substring(0, 44)); } console.log('\n These were applied to, so the posting had real consequences, but nothing'); console.log(' on disk records what the role asked for. Capture one with:'); console.log(' node archive-posting.mjs --report= (live posting)'); console.log(' or save the original description to jds/ and link it from the tracker row.\n'); } // --- Self-test (fixtures only — never reads the real reports/ for findings) --- function runSelfTest() { let pass = 0; let fail = 0; const check = (cond, label) => { if (cond) { pass += 1; } else { fail += 1; console.error(` FAIL: ${label}`); } }; // --- Unit-level checks on the pure functions --- check(parseReportFilename('042-acme-2026-01-15.md')?.reportNum === 42, 'parseReportFilename extracts the report number'); check(parseReportFilename('042-acme-2026-01-15.md')?.companySlug === 'acme', 'parseReportFilename extracts a simple company slug'); check(parseReportFilename('042-confidential-hays-2026-01-15.md')?.companySlug === 'confidential-hays', 'parseReportFilename extracts a hyphenated company slug (agency-mediated posting, #1596)'); check(parseReportFilename('not-a-report.md') === null, 'parseReportFilename rejects a non-conforming filename'); check(parseReportFilename('042-acme-2026-01-15.txt') === null, 'parseReportFilename requires the .md extension'); check(hasEmbeddedJdArchive('## Job Description (archived verbatim)\n\n' + 'A'.repeat(50) + '\n\n## Machine Summary\nfoo'), 'hasEmbeddedJdArchive recognizes the canonical heading with substantive content'); check(hasEmbeddedJdArchive('## Job Description\n\nWe are looking for a Senior Widget Engineer to join our growing platform team.'), 'hasEmbeddedJdArchive recognizes a bare "## Job Description" heading (no parenthetical) with content'); check(!hasEmbeddedJdArchive('## Job Description (archived verbatim)\n\n\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a section containing only an HTML-comment placeholder'); check(!hasEmbeddedJdArchive('## Job Description (archived verbatim)\n\n-->\n\n## Machine Summary'), 'hasEmbeddedJdArchive strips nested/overlapping comment markers to a fixed point rather than leaving a reconstructed behind after one pass (CodeQL incomplete-sanitization fix, PR #2791)'); check(!hasEmbeddedJdArchive('## Job Description (archived verbatim)\n\nTBD\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a section too short to be real JD text'); check(!hasEmbeddedJdArchive('# Evaluation: Acme — Engineer\n\n**URL:** https://example.com\n'), 'hasEmbeddedJdArchive returns false when there is no Job Description section at all'); check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nWe are looking for a Senior Widget Engineer.\n\n## Responsibilities\n\nShip widgets and mentor the team.\n\n## Machine Summary\nfoo'), 'hasEmbeddedJdArchive keeps a JD-internal "## Responsibilities" sub-heading inside the section instead of truncating on it (CodeRabbit, PR #2791)'); check(!hasEmbeddedJdArchive( "## Job Description (archived verbatim)\n(the posting's full text, pasted verbatim — see requirement below)\n\n## Machine Summary"), 'hasEmbeddedJdArchive rejects the unfilled modes/oferta.md template placeholder even though it exceeds MIN_ARCHIVE_CHARS by length alone (CodeRabbit, PR #2791)'); check(!hasEmbeddedJdArchive( "## Job Description (archived verbatim)\n(the posting's full text,\n pasted verbatim — see requirement below)\n\n## Machine Summary"), 'hasEmbeddedJdArchive rejects the English placeholder when it was re-wrapped on paste'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\nPosted: 2026-10-20 (shown as 1 week ago)\n(职位描述全文,\n逐字粘贴——见下方要求)\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a Chinese placeholder re-wrapped between two CJK characters, under a Posted line that lifts it past MIN_ARCHIVE_CHARS (CodeRabbit, PR #5926)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n(职位描述全文,逐字粘贴——见下方要求)\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects the unfilled modes/zh/oferta.md placeholder, read from the template itself (full-width parentheses)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n(der vollständige Text der Stellenanzeige, wortgleich eingefügt — siehe Anforderung unten)\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects the unfilled modes/de/angebot.md placeholder, read from the template itself'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\nPosted: 3 days ago\n(der vollständige Text der Stellenanzeige, wortgleich eingefügt — siehe Anforderung unten)\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a placeholder left under a filled-in "Posted:" line (CodeRabbit, PR #5926)'); check(!hasEmbeddedJdArchive( "## Job Description (archived verbatim)\nPosted: not visible in source\n(the posting's full text, pasted verbatim — see requirement below)\n\n## Machine Summary"), 'hasEmbeddedJdArchive rejects the English placeholder under a "Posted: not visible in source" line'); check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\nPosted: 3 days ago\nWe are looking for a Senior Widget Engineer to join our growing platform team.\n\n## Machine Summary'), 'hasEmbeddedJdArchive still credits real JD text under a "Posted:" line'); { // A locale added after this checker was written is picked up from its // template alone: no list in the checker to update. const modesDir = mkdtempSync(join(tmpdir(), 'jd-archive-placeholders-')); try { mkdirSync(join(modesDir, 'tr')); writeFileSync(join(modesDir, 'tr', 'is-ilani.md'), '```markdown\n## Job Description (archived verbatim)\n(ilanın tam metni, olduğu gibi yapıştırılır — aşağıdaki gereksinime bakın)\n```\n'); mkdirSync(join(modesDir, 'xx')); writeFileSync(join(modesDir, 'xx', 'guide.md'), '## Job Description (archived verbatim)\nThis heading is followed by prose, not a placeholder, so nothing is collected.\n'); const found = collectTemplatePlaceholders(modesDir); check(found.has(normalizePlaceholder('(ilanın tam metni, olduğu gibi yapıştırılır — aşağıdaki gereksinime bakın)')), 'collectTemplatePlaceholders reads a Turkish placeholder from a modes/tr template'); check(found.has(normalizePlaceholder("(the posting's full text, pasted verbatim — see requirement below)")), 'collectTemplatePlaceholders always keeps the English placeholder'); check(found.size === 2, 'collectTemplatePlaceholders collects only parenthesized lines directly under the heading'); check(collectTemplatePlaceholders(join(modesDir, 'missing')).size === 1, 'collectTemplatePlaceholders falls back to the English placeholder when modes/ cannot be read'); } finally { rmSync(modesDir, { recursive: true, force: true }); } } check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nSee jds/042-acme-corp-2026-01-15.md for the full archive (archive-posting.mjs --report=042).\n\n## Machine Summary'), 'hasEmbeddedJdArchive does not credit a path-only pointer sentence as embedded text, even though it clears MIN_ARCHIVE_CHARS by length alone — the pointer must resolve via findCaptureForReport instead (CodeRabbit, PR #2791)'); check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nThis role owns curriculum design end to end, including SCORM packaging and LMS rollout, across three regional teams and a portfolio of concurrent projects.\n\n## Machine Summary'), 'hasEmbeddedJdArchive still credits substantive real prose with no jds/ path reference at all'); check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nThis role owns curriculum design end to end, including SCORM packaging and LMS rollout. See jds/legacy-notes.md for historical context on the prior version of this posting.\n\n## Machine Summary'), 'hasEmbeddedJdArchive credits substantive JD prose even when it also mentions a jds/*.md path in passing — the section is not JUST the canonical pointer sentence, so it is read as archived text, not a pointer (CodeRabbit, PR #2791 round 4)'); // --- Fetch-failure / non-content markers (#3829) --- // A LinkedIn-style login wall clears MIN_ARCHIVE_CHARS comfortably on // length alone, so each shape needs its own rejection, mirroring the // placeholder/pointer checks above. check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nSign in to view this job · Join LinkedIn to see who you know at Acme Corporation.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a LinkedIn-style login-wall shell even though it clears MIN_ARCHIVE_CHARS by length alone (#3829)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nPlease log in to continue. You must sign in to view this content and manage your job alerts.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a generic "please log in to continue" auth-wall shell (#3829)'); // CodeRabbit review on #3837: a bare "please log in" alternative (with no // "to continue"/"to view" qualifier) was too broad and matched legitimate // JD prose instructing the eventual HIRE to log in to an internal system — // narrowed to the full "please log in to continue" phrase. Regression test // for the exact false-positive shape CodeRabbit flagged. check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nThis role manages our online course catalog. Please log in to our internal LMS after onboarding to review the current curriculum before your first day.\n\n## Machine Summary'), 'hasEmbeddedJdArchive does not false-positive on real JD prose containing a bare "Please log in" instruction unrelated to the archive itself being a login wall (CodeRabbit, PR #3837)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\n404 Not Found. The page you requested could not be located on this server.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a 404 error shell (#3829)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nThis job posting is no longer available. It may have been filled or removed by the employer.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a "this job posting is no longer available" removed-posting shell (#3829)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nSubscribe to continue reading. This content is for subscribers only — create a free account to keep reading.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a paywall/subscription interstitial (#3829)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nPlease enable JavaScript to run this application. This site requires JavaScript to display job listings.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a "please enable JavaScript" shell (#3829)'); check(detectNonContentMarker('Sign in to view this job · Join LinkedIn to see who you know at Acme.')?.category === 'login-wall', 'detectNonContentMarker classifies a login-wall shell with the login-wall category'); check(detectNonContentMarker('404 Not Found. Page not found.')?.category === '404-shell', 'detectNonContentMarker classifies a 404 shell with the 404-shell category'); check(detectNonContentMarker('Subscribe to continue reading this article.')?.category === 'paywall', 'detectNonContentMarker classifies a paywall interstitial with the paywall category'); check(detectNonContentMarker('Please enable JavaScript to run this application.')?.category === 'js-required', 'detectNonContentMarker classifies a JS-required shell with the js-required category'); check(detectNonContentMarker('This role owns curriculum design end to end, including SCORM packaging and LMS rollout across three teams.') === null, 'detectNonContentMarker returns null for substantive real JD prose with no non-content phrasing'); // A genuine, terse-but-real JD excerpt must still pass — proves the new // rejection category is not over-aggressive on legitimate short postings. check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nWe are hiring a part-time Instructional Design Assistant to support course updates in our LMS, 10 hours/week, remote, $28/hr. Email your resume to hiring@example.com.\n\n## Machine Summary'), 'hasEmbeddedJdArchive still credits a genuine, terse real JD excerpt — the non-content-marker rejection is not over-aggressive (#3829)'); // A real posting that legitimately says the ROLE requires signing in to an // internal portal (not that the ARCHIVE itself is a login wall) should not // false-positive just because it mentions "sign in" in passing without any // of the anchored login-wall phrasing. check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nOnce hired, you will sign in to our internal LMS daily to manage learner rosters and publish course updates across three regional campuses.\n\n## Machine Summary'), 'hasEmbeddedJdArchive does not false-positive on real JD prose that merely mentions signing in to an internal system, since it does not match the anchored login-wall phrasing (#3829)'); // --- Self-declared paraphrase (#5179) --- // An evaluator that cannot quote the posting writes a restatement and says so // in the section. The prose clears MIN_ARCHIVE_CHARS, so without a marker it // is credited as an archive under a heading that promises "verbatim". check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nNote: the fetch tool would not return the literal text. What follows is a detailed, close paraphrase of the posting, not a literal copy.\n\nTitle: Director of Marketing. Owns demand generation and reports to the CMO.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a section that calls itself a paraphrase while the heading says archived verbatim (#5179)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\n**Mandate (paraphrased):** Own pipeline generation across all online channels and report to the CMO.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a section whose body labels its content "(paraphrased)" (#5179)'); check(!hasEmbeddedJdArchive( '## Job Description (paraphrase, not verbatim)\n\nTitle: Director of Marketing. Owns demand generation, reports to the CMO, manages a team of eight.\n\n## Machine Summary'), 'hasEmbeddedJdArchive does not credit a section whose own heading says it is not verbatim (#5179)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nThe tool could not reproduce the verbatim text, so this section restates it. Title: Director of Marketing, reports to the CMO.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a section that says verbatim reproduction was not possible (#5179)'); check(detectNonContentMarker('This is a close paraphrase, not a literal copy of the posting.')?.category === 'paraphrase', 'detectNonContentMarker classifies a self-declared paraphrase with the paraphrase category (#5179)'); check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nYou will paraphrase dense source material into plain language for our course catalog and edit copy written by three regional teams.\n\n## Machine Summary'), 'hasEmbeddedJdArchive does not false-positive on real JD prose that merely uses the word paraphrase as a duty (#5179)'); check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nWe are hiring a part-time Instructional Design Assistant to support course updates in our LMS, 10 hours/week, remote, $28/hr. Candidates must be able to restate technical content for lay readers.\n\n## Machine Summary'), 'hasEmbeddedJdArchive does not false-positive on real JD prose that mentions restating content (#5179)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nParaphrased from the original posting: Director of Marketing, owns demand generation and reports to the CMO.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects an unparenthesized "Paraphrased from the original posting" body disclosure (#5179 review)'); check(!hasEmbeddedJdArchive( '## Job Description - paraphrased\n\nDirector of Marketing. Owns demand generation, reports to the CMO, manages a team of eight.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a heading that ends in "- paraphrased" (#5179 review)'); check(!hasEmbeddedJdArchive( '## Job Description \u2014 paraphrased\n\nDirector of Marketing. Owns demand generation, reports to the CMO, manages a team of eight.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a heading that ends in an em dash and "paraphrased" (#5179 review)'); check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nIn this role you will produce a paraphrased version of source material for our course catalog, working from drafts supplied by three regional teams.\n\n## Machine Summary'), 'hasEmbeddedJdArchive does not false-positive on a JD duty that asks for a paraphrased version of source material (#5179 review)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\n**Restated from the original posting:** Director of Marketing, owns demand generation and reports to the CMO.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a line-start "Restated from the original posting" disclosure (#5179 review)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nThis description was paraphrased from the original posting. Director of Marketing, owns demand generation and reports to the CMO.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a "was paraphrased" disclosure (#5179 review)'); check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nOur earlier course catalog was paraphrased from legacy vendor manuals, and you will rewrite those entries from the original sources with three regional teams.\n\n## Machine Summary'), 'hasEmbeddedJdArchive does not false-positive on JD prose where something other than the section was paraphrased (#5179 review)'); check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nCreate training notes (a close paraphrase of the technical specifications) for new hires across three regional teams, and review them monthly.\n\n## Machine Summary'), 'hasEmbeddedJdArchive does not false-positive on a JD duty with a parenthetical that mentions a paraphrase (#5179 review)'); check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nYour deliverable is a faithful paraphrase of customer interviews, written for our product team and reviewed by three regional leads.\n\n## Machine Summary'), 'hasEmbeddedJdArchive does not false-positive on a JD duty where a deliverable is a paraphrase (#5179 review)'); check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nCreate a summary that is not a literal copy of source notes, and review it with three regional leads each month before it ships.\n\n## Machine Summary'), 'hasEmbeddedJdArchive does not false-positive on a JD duty asking for a summary that is not a literal copy (#5179 review)'); check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nYou cannot quote customer data verbatim in external reports, so you will summarize findings for the regional leads each month.\n\n## Machine Summary'), 'hasEmbeddedJdArchive does not false-positive on a JD duty that forbids quoting customer data verbatim (#5179 review)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nThe fetch tool would not return the literal text, so the director of marketing role below is written from memory of the page.\n\n## Machine Summary'), 'hasEmbeddedJdArchive still rejects an evaluator disclosure that the fetch tool would not return the literal text (#5179 review)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nThis is a paraphrase of the original posting. Director of Marketing, owns demand generation and reports to the CMO.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a bare "This is a paraphrase" disclosure (#5179 review)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nWhat follows is paraphrased from the original posting. Director of Marketing, owns demand generation and reports to the CMO.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects "What follows is paraphrased" (#5179 review)'); check(!hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nThis section is restated from the original posting. Director of Marketing, owns demand generation and reports to the CMO.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects "This section is restated" (#5179 review)'); check(!hasEmbeddedJdArchive( '## Job Description (not verbatim)\n\nDirector of Marketing. Owns demand generation, reports to the CMO, manages a team of eight.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a heading with a parenthetical "not verbatim" (#5179 review)'); check(!hasEmbeddedJdArchive( '## Job Description, not verbatim\n\nDirector of Marketing. Owns demand generation, reports to the CMO, manages a team of eight.\n\n## Machine Summary'), 'hasEmbeddedJdArchive rejects a comma-separated "not verbatim" heading (#5179 review)'); check(hasEmbeddedJdArchive( '## Job Description (archived verbatim)\n\nCandidates need not verbatim-copy source text. Survey answers are not literal quotes, and you will review them for tone across three regional teams.\n\n## Machine Summary'), 'hasEmbeddedJdArchive does not false-positive on real JD prose that says "not literal" or "verbatim" as a duty (#5179 review)'); check(isPathOnlyPointer('See jds/042-2026-01-15_acme_director.pdf for the full archive (archive-posting.mjs --report=42).'), 'a PDF pointer sentence is read as a pointer, so its capture must resolve (#5179 review)'); // --- Fixture directory tree (mkdtempSync, mirrors the repo's own test convention) --- const tmpDir = mkdtempSync(join(tmpdir(), 'check-jd-archive-test-')); const reportsDir = join(tmpDir, 'reports'); const jdsDir = join(tmpDir, 'jds'); mkdirSync(reportsDir, { recursive: true }); mkdirSync(jdsDir, { recursive: true }); try { // Fixture 1: no archive section, no jds/ capture -> flagged. writeFileSync(join(reportsDir, '001-acme-2026-01-15.md'), [ '# Evaluation: Acme — Senior Widget Engineer', '', '**Date:** 2026-01-15', '**URL:** https://example.com/jobs/acme-widget-engineer', '**Score:** 4.2/5', '', '## Machine Summary', 'score: 4.2', ].join('\n')); // Fixture 2: embedded archive section with substantive content -> not flagged. writeFileSync(join(reportsDir, '002-globex-2026-01-16.md'), [ '# Evaluation: Globex — Instructional Designer', '', '**Date:** 2026-01-16', '**URL:** https://example.com/jobs/globex-id', '**Score:** 4.5/5', '', '## Job Description (archived verbatim)', '', 'Globex Corporation is seeking an Instructional Designer to build learning', 'experiences for our internal enablement platform. Requirements: 3+ years', 'of L&D experience, familiarity with Articulate 360, strong stakeholder', 'communication skills.', '', '## Machine Summary', 'score: 4.5', ].join('\n')); // Fixture 3: no archive section, but a matching jds/ capture (report-number // prefixed, per archive-posting.mjs --report=N) -> not flagged (either form counts). writeFileSync(join(reportsDir, '003-initech-2026-01-17.md'), [ '# Evaluation: Initech — EdTech Specialist', '', '**Date:** 2026-01-17', '**URL:** https://example.com/jobs/initech-edtech', '**Score:** 4.0/5', '', '## Machine Summary', 'score: 4.0', ].join('\n')); writeFileSync(join(jdsDir, '003-2026-01-17_initech_edtech-specialist.pdf'), 'fake-pdf-bytes'); // Fixture 4: filename doesn't match the {###}-{slug}-{date}.md convention -> // still flagged (can't be resolved to a jds/ capture), but with the // "no meta" detail branch rather than a crash. writeFileSync(join(reportsDir, 'hand-named-report.md'), '# Evaluation: Weyland — Analyst\n\n**URL:** https://example.com\n'); // Fixture 5: the archive section is a path-only pointer to a jds/ capture // that does NOT exist -> flagged, not silently accepted on length alone // (CodeRabbit, PR #2791). writeFileSync(join(reportsDir, '005-umbrella-2026-01-20.md'), [ '# Evaluation: Umbrella Corp — Analyst', '', '**URL:** https://example.com/jobs/umbrella-analyst', '', '## Job Description (archived verbatim)', 'See jds/005-umbrella-2026-01-20.md for the full archive (archive-posting.mjs --report=005).', '', '## Machine Summary', 'score: 4.0', ].join('\n')); // Deliberately no matching file written under jdsDir for report 005. // Fixture 6: the archive section is a path-only pointer to a jds/ capture // that DOES exist -> not flagged, the pointer correctly resolves. writeFileSync(join(reportsDir, '006-oscorp-2026-01-21.md'), [ '# Evaluation: Oscorp — Analyst', '', '**URL:** https://example.com/jobs/oscorp-analyst', '', '## Job Description (archived verbatim)', 'Posted: 2026-01-18', 'See jds/006-oscorp-2026-01-21.md for the full archive (archive-posting.mjs --report=006).', '', '## Machine Summary', 'score: 4.0', ].join('\n')); writeFileSync(join(jdsDir, '006-2026-01-21_oscorp_analyst.pdf'), 'fake-pdf-bytes'); // Fixture 7: the archive section is a LinkedIn-style login wall that // clears MIN_ARCHIVE_CHARS on length alone, with no jds/ capture to fall // back on -> flagged, and the finding's detail names the specific reason // rather than a generic miss (#3829). writeFileSync(join(reportsDir, '007-wayne-2026-01-22.md'), [ '# Evaluation: Wayne Enterprises — Analyst', '', '**URL:** https://linkedin.com/jobs/view/wayne-analyst', '', '## Job Description (archived verbatim)', '', 'Sign in to view this job · Join LinkedIn to see who you know at Wayne Enterprises.', '', '## Machine Summary', 'score: 4.0', ].join('\n')); const result = checkJdArchive(reportsDir, jdsDir); check(result.reportsScanned === 7, `all 7 fixture reports scanned (got ${result.reportsScanned})`); const flaggedFiles = new Set(result.findings.map((f) => f.file)); check(flaggedFiles.has('001-acme-2026-01-15.md'), 'report with neither archive form is flagged missing-jd-archive'); check(!flaggedFiles.has('002-globex-2026-01-16.md'), 'report with an embedded archive section is not flagged'); check(!flaggedFiles.has('003-initech-2026-01-17.md'), 'report with a matching jds/ capture (no embedded section) is not flagged'); check(flaggedFiles.has('005-umbrella-2026-01-20.md'), 'a path-only pointer to a NONEXISTENT jds/ capture is flagged, not accepted on length alone'); check(!flaggedFiles.has('006-oscorp-2026-01-21.md'), 'a path-only pointer to an EXISTING jds/ capture is not flagged — resolves via findCaptureForReport'); check(flaggedFiles.has('007-wayne-2026-01-22.md'), 'a LinkedIn-style login-wall archive is flagged, not accepted on length alone (#3829)'); check(flaggedFiles.has('hand-named-report.md'), 'report with a non-conforming filename and no archive section is flagged'); const handNamedFinding = result.findings.find((f) => f.file === 'hand-named-report.md'); check(handNamedFinding?.report === null, 'non-conforming filename finding carries report: null instead of guessing'); const loginWallFinding = result.findings.find((f) => f.file === '007-wayne-2026-01-22.md'); check(loginWallFinding?.detail?.includes('sign-in/login wall'), 'the login-wall finding names the specific reason in its detail, not a generic miss (#3829)'); check(result.findings.every((f) => f.type === 'missing-jd-archive'), 'every finding uses the missing-jd-archive type'); check(hasMissingArchive(result.findings) === true, 'hasMissingArchive is true when findings are present'); // Company-slug disambiguation: a jds/ capture with the right report-number // prefix but a DIFFERENT company must not be credited to this report. const mismatchReportsDir = join(tmpDir, 'reports-mismatch'); const mismatchJdsDir = join(tmpDir, 'jds-mismatch'); mkdirSync(mismatchReportsDir, { recursive: true }); mkdirSync(mismatchJdsDir, { recursive: true }); writeFileSync(join(mismatchReportsDir, '005-umbrella-2026-01-20.md'), '# Evaluation: Umbrella — Analyst\n\n**URL:** https://example.com\n'); writeFileSync(join(mismatchJdsDir, '005-2026-01-20_othercorp_analyst.pdf'), 'fake-pdf-bytes'); const mismatchResult = checkJdArchive(mismatchReportsDir, mismatchJdsDir); check(mismatchResult.findings.some((f) => f.file === '005-umbrella-2026-01-20.md'), 'a jds/ capture for the same report number but a different company is not credited (company-slug guard)'); // Empty reports dir -> clean empty result, exit 0 path. const emptyReportsDir = join(tmpDir, 'reports-empty'); mkdirSync(emptyReportsDir, { recursive: true }); const emptyResult = checkJdArchive(emptyReportsDir, jdsDir); check(emptyResult.reportsScanned === 0 && emptyResult.findings.length === 0 && !hasMissingArchive(emptyResult.findings), 'no report files -> empty result, exit 0 (the designed empty-repo case, matches a fresh checkout with reports/*.md gitignored)'); // Missing reports dir entirely (never created) -> same clean empty result, no crash. const neverCreatedResult = checkJdArchive(join(tmpDir, 'does-not-exist'), jdsDir); check(neverCreatedResult.reportsScanned === 0 && neverCreatedResult.findings.length === 0, 'missing reports directory -> empty result, no crash'); // Missing jds dir (never created) with no embedded section -> still flagged, no crash. const noJdsDirResult = checkJdArchive(reportsDir, join(tmpDir, 'jds-does-not-exist')); check(noJdsDirResult.findings.some((f) => f.file === '001-acme-2026-01-15.md'), 'missing jds/ directory does not crash the lookup — falls through to flagged'); // --- Retroactive-enforcement classification (PR #2791 review, 2026-08-17) --- // Four reports, all missing their archive, joined against one synthetic // tracker exercising all four classification outcomes at once: a // terminal-state row, a live-state row, a report number with no linking // row at all, and one linked by two rows (ambiguous). const trackerReportsDir = join(tmpDir, 'reports-tracker'); mkdirSync(trackerReportsDir, { recursive: true }); writeFileSync(join(trackerReportsDir, '101-acme-2026-01-01.md'), '# Evaluation: Acme — Analyst\n\n**URL:** https://example.com\n'); writeFileSync(join(trackerReportsDir, '102-globex-2026-01-02.md'), '# Evaluation: Globex — Analyst\n\n**URL:** https://example.com\n'); writeFileSync(join(trackerReportsDir, '103-initech-2026-01-03.md'), '# Evaluation: Initech — Analyst\n\n**URL:** https://example.com\n'); writeFileSync(join(trackerReportsDir, '104-umbrella-2026-01-04.md'), '# Evaluation: Umbrella — Analyst\n\n**URL:** https://example.com\n'); const trackerPath = join(tmpDir, 'applications.md'); writeFileSync(trackerPath, [ '| # | Date | Company | Role | Score | Status | PDF | Report | Notes |', '|---|------|---------|------|-------|--------|-----|--------|-------|', // 101: terminal state (Rejected) -> no finding at all. '| 1 | 2026-01-01 | Acme | Analyst | 4.0/5 | Rejected | ✅ | [101](reports/101-acme-2026-01-01.md) | |', // 102: live state (Applied) -> soft review-due finding. '| 2 | 2026-01-02 | Globex | Analyst | 4.0/5 | Applied | ✅ | [102](reports/102-globex-2026-01-02.md) | |', // 103 (Initech) is deliberately unlinked by any row -> unresolved join. // 104: linked by TWO rows -> ambiguous join, fails soft. '| 3 | 2026-01-04 | Umbrella | Analyst A | 4.0/5 | Applied | ✅ | [104](reports/104-umbrella-2026-01-04.md) | |', '| 4 | 2026-01-04 | Umbrella | Analyst B | 3.5/5 | Interview | ✅ | [104](reports/104-umbrella-2026-01-04.md) | |', ].join('\n')); const trackerResult = checkJdArchive(trackerReportsDir, jdsDir, { trackerPath }); const findingTypeByFile = new Map(trackerResult.findings.map((f) => [f.file, f.type])); check(!findingTypeByFile.has('101-acme-2026-01-01.md'), 'terminal-state (Rejected) tracker row -> report missing its archive gets NO finding at all, not even a warning'); check(findingTypeByFile.get('102-globex-2026-01-02.md') === 'jd-archive-review-due', 'live-state (Applied) tracker row -> report missing its archive gets a soft jd-archive-review-due finding, not a hard blocker'); check(findingTypeByFile.get('103-initech-2026-01-03.md') === 'jd-archive-review-due', 'report number with NO linking tracker row -> unresolved join fails soft to the same review-due warning, not an error'); check(findingTypeByFile.get('104-umbrella-2026-01-04.md') === 'jd-archive-review-due', 'report number linked by 2+ tracker rows (ambiguous) -> fails soft to review-due rather than guessing which row is authoritative'); check(!hasMissingArchive(trackerResult.findings), 'a tracker-joined run touching only terminal/live/unresolved rows never trips the hard-blocking hasMissingArchive/exit-1 path'); // --- Tracker-named jds/ captures + un-archived applications --- // findCaptureForReport resolves a capture only by FILENAME (number prefix or // company slug). A freely-named capture the tracker links explicitly was // invisible to it, so an archived application read as unarchived. const linkReportsDir = join(tmpDir, 'reports-capturelink'); const linkJdsDir = join(tmpDir, 'jds-capturelink'); mkdirSync(linkReportsDir, { recursive: true }); mkdirSync(linkJdsDir, { recursive: true }); // 201's capture is named nothing like the report: no number prefix, different slug. writeFileSync(join(linkReportsDir, '201-cyberdyne-2026-02-01.md'), '# Evaluation: Cyberdyne — Analyst\n\n**URL:** https://example.com\n'); writeFileSync(join(linkJdsDir, 'freely-named-agency-posting-2026-02-01.md'), 'Posted: 2026-02-01\n\n' + 'A'.repeat(200)); // 202's linked capture does not exist -> dangling link, must NOT be credited. writeFileSync(join(linkReportsDir, '202-tyrell-2026-02-02.md'), '# Evaluation: Tyrell — Analyst\n\n**URL:** https://example.com\n'); const linkTracker = join(tmpDir, 'applications-capturelink.md'); writeFileSync(linkTracker, [ '| # | Date | Company | Role | Score | Status | PDF | Report | Notes |', '|---|------|---------|------|-------|--------|-----|--------|-------|', '| 201 | 2026-02-01 | Cyberdyne | Analyst | 4.0/5 | Applied | ✅ | [201](reports/201-cyberdyne-2026-02-01.md) | see jds/freely-named-agency-posting-2026-02-01.md |', '| 202 | 2026-02-02 | Tyrell | Analyst | 4.0/5 | Applied | ✅ | [202](reports/202-tyrell-2026-02-02.md) | see jds/does-not-exist.md |', // 203: Report cell points STRAIGHT at a capture, so there is no reports/ // link to extract a number from — the row's own id is the only key. '| 203 | 2026-02-03 | Wonka | Analyst | N/A | Interview | ❌ | [203](jds/freely-named-agency-posting-2026-02-01.md) | |', // 204: applied, nothing on disk at all -> the un-archived application case. '| 204 | 2026-02-04 | Soylent | Analyst | N/A | Applied | ❌ | — | |', // 205: never applied to -> out of scope, must not be reported. '| 205 | 2026-02-05 | Initrode | Analyst | 2.0/5 | SKIP | ❌ | — | |', ].join('\n')); const linkMap = mapTrackerCaptureLinks(linkTracker, linkJdsDir); check(linkMap.has(201), 'mapTrackerCaptureLinks resolves a freely-named capture via the tracker row that links it'); check(!linkMap.has(202), 'mapTrackerCaptureLinks refuses a dangling jds/ link — a missing file is not an archive'); check(linkMap.has(203), "mapTrackerCaptureLinks keys by the row's own id when the Report cell links a capture instead of a report"); const linkResult = checkJdArchive(linkReportsDir, linkJdsDir, { trackerPath: linkTracker }); const linkTypes = new Map(linkResult.findings.map((f) => [f.file, f.type])); check(!linkTypes.has('201-cyberdyne-2026-02-01.md'), 'a report whose tracker row names an existing jds/ capture is NOT flagged, even though the filename cannot resolve it'); check(linkTypes.get('202-tyrell-2026-02-02.md') === 'jd-archive-review-due', 'a report whose tracker row names a MISSING capture is still flagged — the link is not taken on faith'); const unarchived = findUnarchivedApplications(linkTracker, linkReportsDir, linkJdsDir); const unarchivedRows = new Set(unarchived.map((r) => String(r.row))); check(unarchivedRows.has('204'), 'findUnarchivedApplications reports an applied-to row with no report and no capture'); check(!unarchivedRows.has('203'), 'findUnarchivedApplications does not report a row whose Report cell links an existing capture'); check(!unarchivedRows.has('201'), 'findUnarchivedApplications does not report a row that has a report file'); check(!unarchivedRows.has('205'), 'findUnarchivedApplications ignores never-applied states (SKIP) — only sent applications count'); check(unarchived.every((r) => r.type === 'application-no-archive'), 'every un-archived-application row carries the application-no-archive type'); check(findUnarchivedApplications(null, linkReportsDir, linkJdsDir).length === 0, 'findUnarchivedApplications fails soft to an empty list when no tracker is available'); // Going-forward / no-tracker enforcement is unaffected: the SAME 4 // fixtures, with the tracker join disabled, are the pre-existing hard // blocker — the retroactive softening only ever activates when there IS // a resolvable tracker to join against, never as a blanket downgrade. const noTrackerResult = checkJdArchive(trackerReportsDir, jdsDir); // no options -> trackerPath stays null check(noTrackerResult.findings.filter((f) => f.type === 'missing-jd-archive').length === 4, 'without a tracker to join against, all 4 fixtures stay the legacy hard missing-jd-archive finding (going-forward/fresh-install enforcement unaffected)'); check(hasMissingArchive(noTrackerResult.findings) === true, 'no-tracker run still trips hasMissingArchive (exit 1) — retroactive softening never applies without a resolvable tracker'); // --no-tracker is the CLI's own opt-out; verify it via the same // options-object contract the CLI wires it through. const explicitNoTrackerResult = checkJdArchive(trackerReportsDir, jdsDir, { trackerPath: null }); check(hasMissingArchive(explicitNoTrackerResult.findings) === true, 'passing trackerPath: null explicitly reproduces the --no-tracker CLI path (hard enforcement)'); // classifyReportsByTrackerState in isolation: nonexistent tracker file -> null. check(classifyReportsByTrackerState(join(tmpDir, 'does-not-exist.md'), STATES_FILE) === null, 'classifyReportsByTrackerState returns null (the fall-back-to-hard signal) when the tracker file does not exist'); } finally { rmSync(tmpDir, { recursive: true, force: true }); } console.log(`\n check-jd-archive self-test: ${pass} passed, ${fail} failed\n`); process.exit(fail > 0 ? 1 : 0); } // --- Run (CLI only; guarded so the module is safely importable for tests) --- if (isMainModule(import.meta.url)) { if (args.includes('--help') || args.includes('-h')) { console.log(USAGE); process.exit(0); } if (selfTestMode) { runSelfTest(); } const reportsDir = reportsDirArg || DEFAULT_REPORTS_DIR; const jdsDir = jdsDirArg || DEFAULT_JDS_DIR; const trackerPath = noTrackerMode ? null : (trackerArg || DEFAULT_TRACKER_PATH); const result = checkJdArchive(reportsDir, jdsDir, { trackerPath }); const unarchivedApplications = findUnarchivedApplications(trackerPath, reportsDir, jdsDir); if (summaryMode) { printSummary(result); printUnarchivedApplications(unarchivedApplications); } else { console.log(JSON.stringify({ generatedAt: new Date().toISOString(), reportsScanned: result.reportsScanned, findings: result.findings, warnings: result.warnings, unarchivedApplications, }, null, 2)); } process.exit(hasMissingArchive(result.findings) ? 1 : 0); }