check-jd-archive.mjs
Rama main · réplica de lectura del repositorio original.
#!/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 <path> # override reports/ (testing)
node check-jd-archive.mjs --jds-dir <path> # override jds/ (testing)
node check-jd-archive.mjs --tracker <path> # 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/<lang>/*.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|<noscript)/i,
},
{
category: 'paraphrase',
// An evaluator that cannot quote the posting (fetch-tool copyright guard,
// quote-length limit) writes a restatement and usually SAYS so in the
// section. A restatement is not an archive: it carries the evaluator's
// reading of the posting, not the posting (#5179). Matched on the
// evaluator's own disclosure phrasing, deliberately not the bare words
// "paraphrase" or "restate", which a real JD can use as a duty ("you will
// paraphrase source material") or in finance prose ("restated financials").
reason: 'says it is a paraphrase or restatement, not the verbatim posting',
re: /(\(paraphras(?:e|ed)\b[^)\n]*\)\s*(?::|$)|\b(?:what follows|the following|(?:the )?(?:text|passages?|content) below|this(?: (?:description|section|posting|text|summary|document))?) (?:is|are) (?:a |an )?(?:(?:close|detailed|faithful|complete|non-fabricated|full|accurate)[ ,]+)*(?:paraphrase|paraphrased|restatement|restated)\b|\bthis section restates\b|\bthis (?:description|section|posting|text|summary|document) (?:was|has been|is) (?:paraphrased|restated)\b|^[\s*_>#-]*(?: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(/<!--[\s\S]*?-->/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<number,'terminal'|'live'>|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<number, string>} 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=<N> (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<!-- paste JD here -->\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);
}