Ctrl K
ComunidadGuías…

check-jd-archive.mjs

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

Archivo original ↗Preguntar a KiBot
#!/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);
}