Ctrl K
ComunidadGuías…

analyze-patterns.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * analyze-patterns.mjs — Rejection Pattern Detector for career-ops
 *
 * Parses applications.md + all linked reports, extracts dimensions
 * (archetype, seniority, remote, gaps, scores), classifies outcomes,
 * and outputs structured JSON with actionable patterns.
 *
 * Run: node analyze-patterns.mjs          (JSON to stdout)
 *      node analyze-patterns.mjs --summary (human-readable table)
 *      node analyze-patterns.mjs --min-threshold 3
 *      node analyze-patterns.mjs --min-vendor-n 8   (per-vendor sample floor)
 *      node analyze-patterns.mjs --min-family-n 20  (role-family suggestion floor)
 *      node analyze-patterns.mjs --self-test
 */

import { readFileSync, existsSync, realpathSync, writeFileSync, symlinkSync, rmSync } from 'fs';
import { join, dirname, relative, isAbsolute, sep } from 'path';
import { fileURLToPath } from 'url';
import { isMainModule } from './lib/is-main-module.mjs';
import { loadYaml } from './lib/yaml-document.mjs';
import { machineSummarySection } from './lib/machine-summary-section.mjs';
import { resolveColumns, parseTrackerRow, normalizeVia, firstLinkTarget } from './tracker-parse.mjs';
import { getCareerOpsRoot, resolveTrackerPath } from './path-resolver.mjs';
import { loadCanonicalStates, resolveCanonicalStateId, foldStatusInput, trackerLinkDirsFor, resolveWorkspaceRoot, resolveWorkspaceRootFor } from './tracker-utils.mjs';
import { atsVendorOf } from './ats-vendor.mjs';
import { parseStatusLogStages, recoverFunnelStages } from './funnel-stages.mjs';
import { flagValue, hasFlag, validateFlags } from './lib/cli-flags.mjs';
import { localToday } from './lib/local-today.mjs';

const CAREER_OPS = getCareerOpsRoot();
const APPS_FILE = resolveTrackerPath(CAREER_OPS);
// Report links resolve from the same directories verify-pipeline.mjs checks:
// the tracker's folder as realpathed and as spelled (a symlinked tracker FILE
// writes links relative to its target; a symlinked data root, or macOS's
// /var -> /private/var tmpdir, needs the spelled form, since withinReports()
// compares lexically), then the data root.
const TRACKER_DIRS = trackerLinkDirsFor(CAREER_OPS);
// A CAREER_OPS_TRACKER that points at another workspace brings that
// workspace's reports/ with it (see resolveWorkspaceRoot): the workspace of
// the tracker as spelled, and of its target when the tracker is a symlink into
// another workspace. The data root's own reports/ stays allowed for a tracker
// kept apart from its reports.
const REPORTS_ROOTS = [...new Set([
  join(CAREER_OPS, 'reports'),
  join(resolveWorkspaceRootFor(CAREER_OPS), 'reports'),
  join(resolveWorkspaceRoot(APPS_FILE), 'reports'),
])];
const REPORTS_DIR = join(CAREER_OPS, 'reports');

const MACHINE_SUMMARY_FIELDS = new Set([
  'company',
  'role',
  'score',
  'legitimacy_tier',
  'archetype',
  'final_decision',
  'hard_stops',
  'soft_gaps',
  'top_strengths',
  'risk_level',
  'confidence',
  // Evidence states and unresolved checks behind the existing confidence tier.
  // Preserved for report consumers; historical outcome analysis does not use
  // them to change the Global Score or calibration rates.
  'score_evidence',
  'confidence_gaps',
  'next_action',
  // Optional context fields accepted for future reports.
  'domain',
  'seniority',
  'remote',
  'team_size',
  // Issue 1380: predicted skip/discard reasons from the agent.
  'discard_reasons',
  'advertised_comp',
  // JD-stated work location used by salary-gap's informational relocation
  // comparison. Preserved for report consumers; pattern scoring ignores it.
  'posting_location',
  'via',
  'company_confidential',
  'risk_summary',
  // Work-authorization / visa-sponsorship tier from Block A (report + Machine
  // Summary only). Allowlisted so it round-trips; no consumer logic yet.
  'work_auth',
  // Reporting line stated by the JD, verbatim (report + Machine Summary only).
  // Allowlisted so it round-trips; no consumer logic yet.
  'reports_to',
  // Optional local cache metadata; never an input to candidate pattern analysis.
  'job_facts_cache',
  // Block B's requirement -> importance table, mirrored row by row (evidence
  // tier, importance band, match). Allowlisted so it round-trips; no consumer
  // logic yet, deliberately: importance is score-neutral, so nothing that folds
  // historical scores may start reading it without its own design pass.
  'requirement_importance',
  // Optional positioning label beside the 1-5 score (RFC #4720): challenge,
  // target, safety_net, out_of_scope or unknown, with its reason and the four
  // evidence inputs. Allowlisted so it round-trips; report output only, and
  // nothing that folds historical scores may read it to change a score.
  'positioning_band',
  'positioning_band_reason',
  'positioning_band_evidence',
]);

const args = process.argv.slice(2);

const KNOWN_FLAGS = ['--min-threshold', '--min-vendor-n', '--min-family-n', '--self-test', '--summary', '--help', '-h'];
const VALUE_FLAGS = ['--min-threshold', '--min-vendor-n', '--min-family-n'];
const USAGE = `Usage:
  node analyze-patterns.mjs                       # analyze application patterns as JSON
  node analyze-patterns.mjs --summary             # print a human-readable summary
  node analyze-patterns.mjs --min-threshold <n>   # minimum submitted applications required (default: 5)
  node analyze-patterns.mjs --min-vendor-n <n>    # minimum sample per vendor/channel (default: 8)
  node analyze-patterns.mjs --min-family-n <n>    # minimum applications per role family before a suggestion (default: 20)
  node analyze-patterns.mjs --self-test           # run the built-in consistency checks
  node analyze-patterns.mjs --help                # show this message`;

// --- CLI args ---
const summaryMode = args.includes('--summary');

// CLI values stay at their defaults when this module is imported by tests.
// Parsing/validation is performed only in the main-module guard below.
let MIN_THRESHOLD = 5;
let MIN_VENDOR_N = 8;

// --- Status normalization ---
// Every alias comes from templates/states.yml, the single source of truth —
// no table here. states.yml ships with the code, so it is read from the
// script's own directory, never from the (overridable) data root.
const CANONICAL_STATES = loadCanonicalStates(
  join(dirname(fileURLToPath(import.meta.url)), 'templates', 'states.yml'));

function normalizeStatus(raw) {
  // An unrecognized status keeps its own folded text, so it still shows up
  // under its own name in the funnel instead of vanishing.
  return resolveCanonicalStateId(raw, CANONICAL_STATES)
    ?? foldStatusInput(String(raw ?? '').replace(/\s+\d{4}-\d{2}-\d{2}.*$/, ''));
}

export function classifyOutcome(status) {
  const s = normalizeStatus(status);
  // 'hired' is the strongest positive outcome — a landed job. It must not fall
  // through to the 'pending' default, which would drag conversion rates down.
  if (['hired', 'interview', 'offer', 'responded', 'assessment'].includes(s)) return 'positive';
  // 'applied' is SENT, not answered: denominator only, never the numerator.
  // Mirrors ADVANCED_STATUSES, which already excludes it.
  if (s === 'applied') return 'awaiting';
  if (s === 'rejected') return 'negative';
  // Withdrawn by the candidate or the posting died: neither a submission the
  // canonical funnel counts (stats.mjs) nor an employer decision.
  if (s === 'discarded') return 'discarded';
  if (s === 'skip') return 'self_filtered';
  return 'pending'; // evaluated
}

// --- Rate denominators ---
//
// A frequency is only meaningful against the population that could have
// produced it. Both counters below exist because `enriched.length` — EVERY
// tracker row — was standing in for two much smaller populations, silently
// deflating every derived percentage and the thresholds computed from them.
//
// Entries eligible to carry a discard/skip reason. A 'pending' (Evaluated,
// never acted on) or 'positive' row has no reason to state, so counting it in
// the base only dilutes the share. Must stay in lockstep with the filter that
// guards the discard-reason harvest loop.
function discardableBase(enriched) {
  return enriched.filter(e => REASON_BEARING.has(e.outcome)).length;
}

// Entries that actually carry gaps, i.e. the ones a blocker can be extracted
// from. Entries whose report has no gaps (or no report at all) can never
// contribute a blocker and must not pad the denominator.
function gapBearingBase(enriched) {
  return enriched.filter(e => e.report?.gaps?.length > 0).length;
}

// Outcome buckets a breakdown row carries. Kept in one place so a new bucket
// cannot be added to classifyOutcome without every counter learning about it —
// `entry[outcome]++` on a missing key silently writes NaN.
export const OUTCOME_BUCKETS = ['positive', 'awaiting', 'negative', 'discarded', 'self_filtered', 'pending'];
// Buckets whose rows can carry a skip/discard reason or a blocker: the base for
// discard-reason shares and the source of blocker / tech-gap harvesting.
const REASON_BEARING = new Set(['negative', 'discarded', 'self_filtered']);

// Sample floors for the prescriptive recommendations. Small on purpose: they
// do not claim statistical confidence, they stop a single row from becoming
// an instruction ("avoid X", "set the threshold at Y").
// A prescription ("double down", "avoid") needs at least this many DECIDED
// outcomes — employer silence is not evidence in either direction.
const MIN_DECIDED_FOR_RECOMMENDATION = 2;
const MIN_POSITIVE_SCORES_FOR_THRESHOLD = 3;

/** A zeroed counter row for the per-segment breakdowns. */
export function newOutcomeCounts() {
  const row = { total: 0 };
  for (const bucket of OUTCOME_BUCKETS) row[bucket] = 0;
  return row;
}

/**
 * Rates for one breakdown row. `conversionRate` divides by SUBMITTED, never by
 * `total` (which also counts Evaluated rows never sent). `decidedRate` divides
 * by the rows with a recorded outcome and is `null`, not 0, while nothing is
 * decided. `decided` ships as a count so callers gate on facts, not on a
 * rounded percentage.
 */
export function withOutcomeRates(data) {
  const submitted = data.positive + data.negative + data.awaiting;
  const decided = data.positive + data.negative;
  return {
    ...data,
    submitted,
    decided,
    conversionRate: submitted > 0 ? Math.round((data.positive / submitted) * 100) : 0,
    decidedRate: decided > 0 ? Math.round((data.positive / decided) * 100) : null,
  };
}

/** Group entries by a key, count outcomes, attach rates. Largest bucket first. */
function breakdownBy(enriched, labelKey, keyOf) {
  const map = new Map();
  for (const e of enriched) {
    const k = keyOf(e);
    if (!map.has(k)) map.set(k, newOutcomeCounts());
    const row = map.get(k);
    row.total++;
    row[e.outcome]++;
  }
  return [...map.entries()]
    .map(([label, data]) => ({ [labelKey]: label, ...withOutcomeRates(data) }))
    .sort((a, b) => b.total - a.total);
}

/**
 * The segment worth doubling down on: highest decidedRate among rows with
 * enough DECIDED outcomes. Ranking by conversionRate would let "1 positive +
 * 1 awaiting" (50%) outrank "5 positive + 15 awaiting" (25%). A rate that
 * rounds to 0% (1 of 201) is not a lane to double down on.
 */
export function bestDecidedSegment(rows) {
  return rows
    .filter(r => r.decided >= MIN_DECIDED_FOR_RECOMMENDATION && r.decidedRate > 0)
    .sort((a, b) => b.decidedRate - a.decidedRate || b.decided - a.decided)[0] || null;
}

/**
 * The segment to avoid: nothing advanced across enough DECIDED outcomes, the
 * most evidence first. Gate on counts, not the rounded rate: 1 positive of 201
 * decided rounds to 0% and is not "none advanced"; 1 negative + 1 awaiting is
 * a data point, not a pattern.
 */
export function worstDecidedSegment(rows) {
  return rows
    .filter(r => r.positive === 0 && r.decided >= MIN_DECIDED_FOR_RECOMMENDATION)
    .sort((a, b) => b.decided - a.decided)[0] || null;
}

/**
 * Score floor from decided outcomes. The lowest positive score is always
 * reported as an observation; it becomes a recommended threshold only when
 * enough positives carry a score AND at least one rejected application scored
 * below it — "nothing below X advanced" is vacuous when nothing below X was
 * ever decided.
 */
export function scoreThresholdFrom(positiveScoresRaw, negativeScoresRaw) {
  const positive = positiveScoresRaw.filter(s => s > 0);
  const negative = negativeScoresRaw.filter(s => s > 0);
  const min = positive.length > 0 ? Math.min(...positive) : 0;
  const rejectedBelow = negative.filter(s => s < min).length;
  const sufficient = positive.length >= MIN_POSITIVE_SCORES_FOR_THRESHOLD;
  const prescribe = sufficient && rejectedBelow > 0;
  let reasoning;
  if (positive.length === 0) reasoning = 'No positive outcome carries a score yet.';
  else if (!sufficient) reasoning = `Lowest score among positive outcomes so far is ${min}, but only ${positive.length} positive outcome(s) carry a score (${MIN_POSITIVE_SCORES_FOR_THRESHOLD} needed before this is a threshold).`;
  else if (!prescribe) reasoning = `Lowest score among ${positive.length} positive outcomes is ${min}, but no rejected application scored below it — nothing shows that lower scores fail.`;
  else reasoning = `None of the ${positive.length} positive outcomes scored below ${min}, and ${rejectedBelow} rejected application(s) did.`;
  return {
    recommended: prescribe ? Math.floor(min * 10) / 10 : null,
    observedMinimum: min > 0 ? min : null,
    sampleSize: positive.length,
    sufficientSample: sufficient,
    rejectedBelow,
    reasoning,
    positiveRange: positive.length > 0 ? `${min} - ${Math.max(...positive)}` : 'N/A',
  };
}

// Vendor outcome analysis intentionally stays on the five URL-fingerprintable
// community ATS families it has always reported. The shared detector knows
// more providers for scan routing, but those do not widen this analysis.
const VENDOR_ANALYSIS_SCOPE = Object.freeze(['greenhouse', 'lever', 'ashby', 'workday', 'icims']);

/** Keep vendor analysis inside its declared taxonomy, not arbitrary hosts. */
export function knownAtsVendorOf(rawUrl) {
  const vendor = atsVendorOf(rawUrl);
  return VENDOR_ANALYSIS_SCOPE.includes(vendor) ? vendor : null;
}

// Statuses that count as a submitted application for channel-yield analysis.
// 'evaluated' was never sent, 'skip' is self-filtered, and 'discarded' (withdrawn
// or the posting closed) proves neither a submission nor an answer — the same
// set stats.mjs uses for its canonical funnel. Module-scoped so the self-test
// can assert membership and the channel-yield pass and self-test share one set.
const SUBMITTED_STATUSES = new Set(['applied', 'responded', 'assessment', 'interview', 'offer', 'hired', 'rejected']);

// Statuses that count as "advanced past screening" — STRICTER than
// outcome=='positive': a bare 'applied' (submitted, no reply yet) does NOT
// count. 'hired' is the furthest advance of all.
const ADVANCED_STATUSES = new Set(['responded', 'assessment', 'interview', 'offer', 'hired']);

// Print order for the CONVERSION FUNNEL summary. A status absent here is
// silently omitted from the printed funnel, so this must track states.yml.
const FUNNEL_ORDER = ['evaluated', 'applied', 'responded', 'assessment', 'interview', 'offer', 'hired', 'rejected', 'discarded', 'skip'];

function normalizeList(value) {
  if (Array.isArray(value)) return value.map(v => String(v).trim()).filter(Boolean);
  if (value === null || value === undefined || value === '') return [];
  if (typeof value === 'object') return [];
  return [String(value).trim()].filter(Boolean);
}

function normalizeScalar(value) {
  if (typeof value === 'string') return value.trim() || null;
  if (typeof value === 'number' && Number.isFinite(value)) return String(value);
  return null;
}

export function parseMachineSummary(content) {
  const fenceMatch = machineSummarySection(content).match(/^##\s*Machine Summary\s*\n+```(?:yaml|yml|json)?\s*\n([\s\S]*?)\n```/i);
  if (!fenceMatch) return null;

  const raw = fenceMatch[1].trim();
  if (!raw) return null;

  try {
    const parsed = loadYaml(raw);
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
    return Object.fromEntries(
      Object.entries(parsed).filter(([key]) => MACHINE_SUMMARY_FIELDS.has(key))
    );
  } catch {
    return null;
  }
}

// --- Via channel analysis (#1596 follow-up) ---
// Pure: group submitted applications by their Via channel (agency/recruiter
// firm) and compute per-agency advance rates, plus the agency-vs-direct
// aggregate. Channel identity uses the SAME normalizeVia key as the
// merge-tracker dedup guard (tracker-parse.mjs): NFKC + Unicode letters/digits,
// so "Hays" / "HAYS " / full-width "HAYS" land in one bucket while distinct
// non-Latin agencies (リクルートAgent vs パーソルAgent) stay separate. The
// first raw spelling seen is kept for display. Rows in `submitted` whose Via
// cell is empty (legacy tracker without the column, or a blank cell — as
// opposed to the explicit `—` direct marker) belong to neither bucket; they
// are counted as `unknownVia` so agencySubmitted + directSubmitted can't
// silently undershoot the submitted total.
function buildViaChannelAnalysis(submitted, isAdvanced, minSample = MIN_VENDOR_N) {
  const viaOf = (e) => String(e.via ?? '').trim();
  const isDirect = (v) => v === '—' || v === '-';
  const agencySubmitted = submitted.filter(e => { const v = viaOf(e); return v !== '' && !isDirect(v); });
  const directSubmitted = submitted.filter(e => isDirect(viaOf(e)));
  // null, not 0, for a channel nothing went through, as decidedRate is: a
  // "direct 0 apps 0% advance" line reads as a channel that was tried and
  // failed, for a user who simply applies through agencies.
  const rate = (arr) => (arr.length > 0 ? Math.round((arr.filter(isAdvanced).length / arr.length) * 100) : null);

  const byAgency = new Map();
  for (const e of agencySubmitted) {
    const raw = viaOf(e);
    // All-symbol names (e.g. "***") normalize to '' — fall back to the
    // NFKC-lowercased raw string so DISTINCT all-symbol names stay distinct
    // buckets instead of merging into one shared empty key.
    const key = normalizeVia(raw) || raw.normalize('NFKC').toLowerCase();
    if (!byAgency.has(key)) byAgency.set(key, { agency: raw, total: 0, advanced: 0 });
    const entry = byAgency.get(key);
    entry.total++;
    if (isAdvanced(e)) entry.advanced++;
  }
  const breakdown = [...byAgency.values()]
    .map(d => ({
      agency: d.agency,
      total: d.total,
      advanced: d.advanced,
      advanceRate: d.total > 0 ? Math.round((d.advanced / d.total) * 100) : 0,
      sufficientSample: d.total >= minSample,
    }))
    .sort((a, b) => b.total - a.total);

  return {
    minSampleForClaim: minSample,
    agencySubmitted: agencySubmitted.length,
    directSubmitted: directSubmitted.length,
    // Coverage honesty: submitted rows with an empty Via cell (no `—` marker)
    // that fall into neither bucket. Non-zero means the agency/direct split
    // covers only a subset of submissions.
    unknownVia: submitted.length - agencySubmitted.length - directSubmitted.length,
    agencyAdvanceRate: rate(agencySubmitted),
    directAdvanceRate: rate(directSubmitted),
    breakdown,
  };
}

// --- Tech-stack-gap extraction (shared by the analysis pass and the self-test) ---
// Canonical display spelling keyed by lowercased alias, so "react native" /
// "NODEJS" collapse into one bucket rather than one per case variant.
const TECH_CANONICAL = new Map([
  'JavaScript', 'TypeScript', 'Python', 'Ruby', 'Java', 'Go', 'Rust',
  'React Native', 'React', 'Angular', 'Django', 'Flask', 'Rails', 'PHP',
  'Laravel', 'Symfony', 'Kotlin', 'Swift', 'C++', 'C#', '.NET', 'MongoDB',
  'MySQL', 'PostgreSQL', 'Redis', 'GraphQL', 'REST', 'AWS', 'GCP', 'Azure',
  'Docker', 'Kubernetes', 'Terraform', 'Supabase', 'Inngest',
].map(t => [t.toLowerCase(), t]));
TECH_CANONICAL.set('node.js', 'Node.js').set('nodejs', 'Node.js');
TECH_CANONICAL.set('vue.js', 'Vue.js').set('vuejs', 'Vue.js');

// (?<!\w) / (?!\w) lookarounds, NOT \b: a trailing \b never matches after a
// symbol edge, so "C++", "C#" and ".NET" — three of the most common stacks —
// were silently never extracted, vanishing from the tech-gap rollup and the
// "filter out roles requiring X" recommendation. Same symbol-edge fix that
// skill-extract.mjs and upskill.mjs already carry. Ordered longest-first
// (React Native before React) so the specific alternative wins at a position.
const TECH_MENTION_RE = /(?<!\w)(JavaScript|TypeScript|Python|Ruby|Java|Go|Rust|Node\.?js|React Native|React|Angular|Vue\.?js|Django|Flask|Rails|PHP|Laravel|Symfony|Kotlin|Swift|C\+\+|C#|\.NET|MongoDB|MySQL|PostgreSQL|Redis|GraphQL|REST|AWS|GCP|Azure|Docker|Kubernetes|Terraform|Supabase|Inngest)(?!\w)/gi;

/**
 * Canonical tech names mentioned in a gap description.
 * @param {string} description
 * @returns {string[]} Canonical tech names, one entry per mention (may repeat).
 */
function extractTechMentions(description) {
  const matches = String(description ?? '').match(TECH_MENTION_RE);
  if (!matches) return [];
  return matches.map(m => TECH_CANONICAL.get(m.toLowerCase()) || m);
}

/**
 * Run the in-process self-test for this script's pure helpers.
 *
 * Covers the Machine Summary parser (including the MACHINE_SUMMARY_FIELDS
 * allowlist and the nested `risk_summary` / `requirement_importance` shapes),
 * ATS vendor detection, and the Via channel analysis. Exits non-zero on the
 * first batch of failures so CI and `--self-test` fail loudly.
 *
 * @returns {void}
 */
function runSelfTest() {
  const summary = parseMachineSummary(`
## Machine Summary

\`\`\`yaml
company: "Acme"
role: "Staff AI Engineer"
score: 4.4
legitimacy_tier: "High Confidence"
archetype: "AI Platform / LLMOps Engineer"
final_decision: "Apply"
hard_stops: []
soft_gaps:
  - "No direct healthcare domain experience"
top_strengths:
  - "Production evaluation pipelines"
risk_level: "Medium"
confidence: "High"
next_action: "Follow up on ticket #42 with tailored CV"
work_auth: "unstated"
posting_location: "Halifax, NS"
via: "Hays"
company_confidential: true
\`\`\`
`);

  const failures = [];
  if (!summary) failures.push('summary was not parsed');
  if (summary?.score !== 4.4) failures.push('numeric score was not parsed');
  if (!Array.isArray(summary?.hard_stops) || summary.hard_stops.length !== 0) failures.push('empty list was not parsed');
  if (summary?.soft_gaps?.[0] !== 'No direct healthcare domain experience') failures.push('list item was not parsed');
  if (summary?.next_action !== 'Follow up on ticket #42 with tailored CV') failures.push('hash-containing scalar field was not parsed');
  if (summary?.via !== 'Hays') failures.push('via was not preserved from Machine Summary');
  if (summary?.company_confidential !== true) failures.push('company_confidential boolean was not preserved from Machine Summary');
  if (summary?.work_auth !== 'unstated') failures.push('work_auth field was not preserved from Machine Summary');
  if (summary?.posting_location !== 'Halifax, NS') failures.push('posting_location field was not preserved from Machine Summary');

  // Backward compat (#1737): summaries without risk_summary parse as before, key simply absent.
  if ('risk_summary' in (summary ?? {})) failures.push('summary without risk_summary must not gain the key');

  // risk_summary preservation (#1737): the nested map must survive the
  // MACHINE_SUMMARY_FIELDS allowlist intact — nested keys preserved, not
  // flattened or dropped.
  const riskSummary = parseMachineSummary(`
## Machine Summary

\`\`\`yaml
company: "Acme"
role: "Staff AI Engineer"
score: 4.4
legitimacy_tier: "High Confidence"
risk_summary:
  legitimacy: high_confidence
  classification: clear
  culture: caution
  interview_redflags: not_evaluated
  ai_infra: not_evaluated
\`\`\`
`)?.risk_summary;
  if (!riskSummary || typeof riskSummary !== 'object' || Array.isArray(riskSummary)) {
    failures.push('risk_summary nested map was dropped or not parsed as a map');
  } else {
    if (riskSummary.legitimacy !== 'high_confidence') failures.push('risk_summary.legitimacy was not preserved');
    if (riskSummary.classification !== 'clear') failures.push('risk_summary.classification was not preserved');
    if (riskSummary.culture !== 'caution') failures.push('risk_summary.culture was not preserved');
    if (riskSummary.interview_redflags !== 'not_evaluated') failures.push('risk_summary.interview_redflags was not preserved');
    if (riskSummary.ai_infra !== 'not_evaluated') failures.push('risk_summary.ai_infra was not preserved');
  }

  // requirement_importance preservation: Block B's table is a LIST OF MAPS,
  // a shape no allowlisted key had before it. The allowlist filters top-level
  // keys only, so the list must survive with every row's nested fields intact
  // — including jd_signal: null, which is legal for the structural/inferred
  // evidence tiers and must not be dropped or coerced.
  const reqImportance = parseMachineSummary(`
## Machine Summary

\`\`\`yaml
company: "Acme"
role: "Staff AI Engineer"
score: 4.4
requirement_importance:
  - requirement: "Fluent German (C1)"
    jd_signal: "Verhandlungssicheres Deutsch ist Voraussetzung"
    evidence: "stated"
    importance: "critical"
    match: "missing"
  - requirement: "Kubernetes in production"
    jd_signal: null
    evidence: "inferred"
    importance: "meaningful"
    match: "partial"
\`\`\`
`)?.requirement_importance;
  if (!Array.isArray(reqImportance) || reqImportance.length !== 2) {
    failures.push('requirement_importance list was dropped or not parsed as a list');
  } else {
    const [stated, inferred] = reqImportance;
    if (stated.requirement !== 'Fluent German (C1)') failures.push('requirement_importance[0].requirement was not preserved');
    if (stated.jd_signal !== 'Verhandlungssicheres Deutsch ist Voraussetzung') failures.push('requirement_importance[0].jd_signal verbatim quote was not preserved');
    if (stated.evidence !== 'stated') failures.push('requirement_importance[0].evidence was not preserved');
    if (stated.importance !== 'critical') failures.push('requirement_importance[0].importance was not preserved');
    if (stated.match !== 'missing') failures.push('requirement_importance[0].match was not preserved');
    if (inferred.jd_signal !== null) failures.push('requirement_importance[1].jd_signal null was not preserved');
    if (inferred.evidence !== 'inferred') failures.push('requirement_importance[1].evidence was not preserved');
    // The Block B gate, asserted on parsed data: an inferred row may never
    // reach the two bands that create the interview-risk + mitigation
    // obligation. Enforced in the modes; checked here so a fixture that
    // violates it can never be introduced as "expected" output.
    if (inferred.importance === 'critical' || inferred.importance === 'high') {
      failures.push('requirement_importance fixture violates the inferred cap (evidence: inferred must never be critical/high)');
    }
  }

  // Backward compat: summaries without requirement_importance parse as before.
  if ('requirement_importance' in (summary ?? {})) failures.push('summary without requirement_importance must not gain the key');

  // Vendor detection (community ATS only; white-labeled → null)
  const vendorCases = [
    ['https://boards.greenhouse.io/acme/jobs/12345', 'greenhouse'],
    ['https://job-boards.eu.greenhouse.io/acme/jobs/9', 'greenhouse'],
    ['https://jobs.lever.co/acme/abc-def', 'lever'],
    ['https://jobs.ashbyhq.com/acme/uuid', 'ashby'],
    ['https://acme.wd1.myworkdayjobs.com/en-US/careers/job/R-1', 'workday'],
    ['https://careers.icims.com/jobs/9/x', 'icims'],
    ['https://jobs.dayforcehcm.com/en-US/co/CANDIDATEPORTAL/jobs/1', 'dayforce'],
    ['https://careers.example.com/jobs/1', 'careers.example.com'],
    ['not a url', null],
    ['', null],
    [null, null],
  ];
  for (const [url, expected] of vendorCases) {
    const got = atsVendorOf(url);
    if (got !== expected) failures.push(`atsVendorOf(${JSON.stringify(url)}) → ${JSON.stringify(got)}, expected ${JSON.stringify(expected)}`);
  }

  // Report header fields survive the locale they were written in. French output
  // puts a space before the colon and accents "Archétype"; both used to make the
  // field parse as absent, silently. parseReport strips `**` first, so the cases
  // below are given in that post-strip form. Call the pattern rather than the
  // string: `subject.match(re)` makes CodeQL read these URL fixtures as patterns
  // (js/incomplete-hostname-regexp) because of their unescaped host dots.
  const headerCases = [
    ['URL: https://job-boards.greenhouse.io/acme/jobs/1', REPORT_URL_RE, 'https://job-boards.greenhouse.io/acme/jobs/1'],
    ['URL : https://job-boards.greenhouse.io/acme/jobs/1', REPORT_URL_RE, 'https://job-boards.greenhouse.io/acme/jobs/1'],
    ['URL\u00a0: https://jobs.lever.co/acme/x', REPORT_URL_RE, 'https://jobs.lever.co/acme/x'],
    ['Archetype: Delivery Manager', REPORT_ARCHETYPE_RE, 'Delivery Manager'],
    ['Archétype : Delivery Manager', REPORT_ARCHETYPE_RE, 'Delivery Manager'],
    ['Arquetipo: Delivery Manager', REPORT_ARCHETYPE_RE, 'Delivery Manager'],
  ];
  for (const [line, re, expected] of headerCases) {
    const got = re.exec(line)?.[1] ?? null;
    if (got !== expected) failures.push(`report header ${JSON.stringify(line)} → ${JSON.stringify(got)}, expected ${JSON.stringify(expected)}`);
  }

  // A header that is genuinely absent must still yield null, so the fix cannot
  // turn "no field" into a false positive.
  if (REPORT_URL_RE.test('Score: 4.4/5')) failures.push('REPORT_URL_RE matched a line with no URL field');

  // A header field is single-line. A label split from its colon, or a value pushed to
  // the next line, must not parse — otherwise the capture picks up whatever URL happens
  // to follow in the document.
  const splitHeaderCases = [
    ['URL\n: https://elsewhere.example.com/x', REPORT_URL_RE],
    ['URL :\nhttps://elsewhere.example.com/y', REPORT_URL_RE],
    ['Archétype\n: Delivery Manager', REPORT_ARCHETYPE_RE],
  ];
  for (const [text, re] of splitHeaderCases) {
    if (re.test(text)) failures.push(`split-line header parsed as valid: ${JSON.stringify(text)}`);
  }

  // One case per localized mode that ships its own archetype label, so a mode
  // renaming its header breaks a named assertion instead of silently emptying
  // the archetype breakdown.
  const localeArchetypeCases = [
    ['Arquétipo: Delivery Manager', 'pt'],
    ['Arquetipo: Delivery Manager', 'es'],
    ['Archétype : Delivery Manager', 'fr (accented)'],
    ['Archetype : Delivery Manager', 'fr (as the mode writes it)'],
    ['Archetipo: Delivery Manager', 'it'],
    ['Archetyp: Delivery Manager', 'de, pl'],
    ['Arketipe: Delivery Manager', 'id'],
    ['Arketype: Delivery Manager', 'da'],
    ['Arketip: Delivery Manager', 'tr'],
    ['Архетип: Delivery Manager', 'ru, ua'],
  ];
  for (const [line, locale] of localeArchetypeCases) {
    const got = REPORT_ARCHETYPE_RE.exec(line)?.[1] ?? null;
    if (got !== 'Delivery Manager') failures.push(`archetype label for ${locale} → ${JSON.stringify(got)}`);
  }

  // Block A stays English-only on purpose, so the header fallback is what serves
  // localized reports. Loosening the Block A cell pattern to accept a suffixed
  // qualifier ("Archétype détecté") would also match a scoring row whose first
  // cell merely starts with the word, and capture its weight as the archetype:
  //   | **Archétype / séniorité** | 25 % | **2.0** | …
  // That is the leak #3808 closed, reopened from the table side.
  const scoringRow = '| Archétype / séniorité | 25 % | 2.0 |';
  if (REPORT_ARCHETYPE_RE.test(scoringRow)) failures.push('archetype pattern matched a scoring table row');

  // Via channel analysis (#1596): agency vs direct yield, normalized buckets.
  const viaRows = [
    { via: 'Hays', normalizedStatus: 'interview' },
    { via: 'HAYS ', normalizedStatus: 'rejected' },   // same bucket as Hays
    { via: 'HAYS', normalizedStatus: 'rejected' }, // full-width → same bucket as Hays (NFKC)
    { via: 'Randstad', normalizedStatus: 'rejected' },
    { via: 'リクルートAgent', normalizedStatus: 'interview' }, // non-Latin: distinct agency...
    { via: 'パーソルAgent', normalizedStatus: 'rejected' },    // ...must NOT merge with the one above
    { via: '—', normalizedStatus: 'responded' },       // direct
    { via: '—', normalizedStatus: 'rejected' },        // direct
    { via: '', normalizedStatus: 'applied' },          // no Via column → unknownVia, neither bucket
  ];
  const viaResult = buildViaChannelAnalysis(viaRows, (e) => ADVANCED_STATUSES.has(e.normalizedStatus), 2);
  if (viaResult.agencySubmitted !== 6) failures.push(`via: agencySubmitted → ${viaResult.agencySubmitted}, expected 6`);
  if (viaResult.directSubmitted !== 2) failures.push(`via: directSubmitted → ${viaResult.directSubmitted}, expected 2`);
  if (viaResult.unknownVia !== 1) failures.push(`via: unknownVia → ${viaResult.unknownVia}, expected 1 (submitted row with empty Via must be counted, not silently dropped)`);
  if (viaResult.directAdvanceRate !== 50) failures.push(`via: directAdvanceRate → ${viaResult.directAdvanceRate}, expected 50`);
  const hays = viaResult.breakdown.find(a => a.agency === 'Hays');
  if (!hays || hays.total !== 3 || hays.advanceRate !== 33) {
    failures.push(`via: Hays bucket wrong (case/space/full-width variants must merge) → ${JSON.stringify(hays)}`);
  }
  if (!hays?.sufficientSample) failures.push('via: Hays should meet the n=2 sample bar');
  const recruit = viaResult.breakdown.find(a => a.agency === 'リクルートAgent');
  const persol = viaResult.breakdown.find(a => a.agency === 'パーソルAgent');
  if (!recruit || !persol || recruit.total !== 1 || persol.total !== 1) {
    failures.push(`via: distinct non-Latin agencies must stay separate buckets → リクルートAgent=${JSON.stringify(recruit)}, パーソルAgent=${JSON.stringify(persol)}`);
  }
  const randstad = viaResult.breakdown.find(a => a.agency === 'Randstad');
  if (randstad?.sufficientSample) failures.push('via: Randstad (n=1) must be flagged as too small for a claim');
  if (buildViaChannelAnalysis([], () => false).breakdown.length !== 0) {
    failures.push('via: empty input must produce an empty breakdown');
  }

  // Hired status (canonical per states.yml; follow-up to PR #2050). A landed job
  // is the strongest positive outcome and the furthest advance — it must not be
  // mis-bucketed as 'pending' or dropped from channel yield / the funnel.
  if (classifyOutcome('Hired') !== 'positive') failures.push(`hired: classifyOutcome('Hired') → ${classifyOutcome('Hired')}, expected 'positive'`);
  // Every hired alias must resolve to 'hired' — testing only one lets the others regress silently.
  for (const alias of ['contratado', 'contratada', 'accepted', 'accept']) {
    if (normalizeStatus(alias) !== 'hired') failures.push(`hired: normalizeStatus('${alias}') → ${normalizeStatus(alias)}, expected 'hired'`);
  }
  // Every submitted status must land in exactly one outcome bucket, and every
  // bucket must be a declared one — a status added to one list and not the other
  // would silently vanish from the rates.
  for (const st of SUBMITTED_STATUSES) {
    const bucket = classifyOutcome(st);
    if (!['positive', 'negative', 'awaiting'].includes(bucket)) failures.push(`buckets: '${st}' classifies as '${bucket}', so submitted counts would disagree with the channel-yield pass`);
  }
  if (classifyOutcome('Discarded') !== 'discarded' || SUBMITTED_STATUSES.has('discarded')) failures.push('discarded: withdrawn/closed rows must be their own bucket and not count as submitted');

  if (!ADVANCED_STATUSES.has('hired')) failures.push('hired: ADVANCED_STATUSES must include hired (a hire advanced past screening)');
  if (!SUBMITTED_STATUSES.has('hired')) failures.push('hired: SUBMITTED_STATUSES must include hired (a hire was submitted)');
  if (!FUNNEL_ORDER.includes('hired')) failures.push('hired: FUNNEL_ORDER must include hired so it prints in the funnel');
  const hiredVia = buildViaChannelAnalysis(
    [{ via: 'Hays', normalizedStatus: 'hired' }, { via: 'Hays', normalizedStatus: 'rejected' }],
    (e) => ADVANCED_STATUSES.has(e.normalizedStatus), 1);
  const hiredHays = hiredVia.breakdown.find(a => a.agency === 'Hays');
  if (!hiredHays || hiredHays.advanced !== 1 || hiredHays.advanceRate !== 50) {
    failures.push(`hired: must count as advanced in channel yield (1/2 = 50%) → ${JSON.stringify(hiredHays)}`);
  }

  // Tech-gap extraction (regression): symbol-edge stacks were silently dropped
  // because a trailing \b never matches after "+"/"#" (C++, C#, .NET).
  const techHits = extractTechMentions('Requires C++, C# and .NET, plus React Native and Go');
  for (const expected of ['C++', 'C#', '.NET', 'React Native', 'Go']) {
    if (!techHits.includes(expected)) failures.push(`tech extraction dropped "${expected}"`);
  }
  // No false positives from substrings ("Go" in "Google", "Java" in "JavaScripting").
  if (extractTechMentions('Google Cloud and JavaScripting skills').length !== 0) {
    failures.push('tech extraction false-positived on Google/JavaScripting');
  }
  // Case/punctuation variants collapse to one canonical bucket.
  if (extractTechMentions('nodejs, Node.js, NODEJS').some(t => t !== 'Node.js')) {
    failures.push('node.js case variants failed to canonicalize');
  }

  // Production aggregation regressions. The fixture distinguishes the fixed
  // denominator from the old full-tracker base: 3 tags among 20 eligible rows
  // trigger a recommendation, while 3 among all 30 rows would not.
  const baseFixture = [];
  for (let i = 0; i < 20; i++) {
    baseFixture.push({
      outcome: i === 19 ? 'negative' : 'self_filtered',
      notes: i < 3 ? `SKIP: geo-block${i === 0 ? '; SKIP: geo-block' : ''}` : '',
      report: { gaps: [] },
    });
  }
  for (let i = 0; i < 10; i++) {
    baseFixture.push({ outcome: 'pending', notes: '', report: null });
  }
  const baseSignals = buildPatternSignals(baseFixture);
  if (baseSignals.discardReasonBase !== 20) {
    failures.push(`discardReasonBase counted ${baseSignals.discardReasonBase}, expected 20`);
  }
  if (baseSignals.blockerBase !== 0) {
    failures.push(`blockerBase counted ${baseSignals.blockerBase}, expected 0 (no gaps in fixture)`);
  }
  const geoReason = baseSignals.discardReasonStats.find(d => d.reason === 'geo-block');
  if (geoReason?.frequency !== 3 || geoReason?.percentage !== 15) {
    failures.push(`discard aggregation returned ${JSON.stringify(geoReason)}, expected frequency 3 and percentage 15`);
  }
  if (!baseSignals.discardReasonRecommendation) {
    failures.push('3 discard reasons among 20 eligible entries did not trigger a recommendation');
  }

  // One report whose gaps all resolve to geo-restriction is one affected entry,
  // not three. The explicit count catches both missing and misclassified fixture
  // descriptions instead of merely checking that no count exceeds the base.
  const dupBlockerFixture = [
    { outcome: 'negative', notes: '', report: { gaps: [
      { description: 'US-only remote', severity: 'hard' },
      { description: 'Remote US only, no sponsorship', severity: 'hard' },
      { description: 'US-only residency required', severity: 'hard' },
    ] } },
    { outcome: 'negative', notes: '', report: { gaps: [{ description: 'US-only remote', severity: 'hard' }] } },
    // Gapless rows make the fixture distinguish blockerBase (2) from the old
    // full-tracker denominator (5). Restoring `/ enriched.length` must turn the
    // expected 100% into 40% and fail this regression.
    { outcome: 'pending', notes: '', report: { gaps: [] } },
    { outcome: 'pending', notes: '', report: null },
    { outcome: 'positive', notes: '', report: { gaps: [] } },
  ];
  const blockerSignals = buildPatternSignals(dupBlockerFixture);
  const geoBlocker = blockerSignals.blockerAnalysis.find(b => b.blocker === 'geo-restriction');
  if (geoBlocker?.frequency !== blockerSignals.blockerBase || geoBlocker?.percentage !== 100) {
    failures.push(`geo blocker aggregation returned ${JSON.stringify(geoBlocker)} against base ${blockerSignals.blockerBase}, expected 2/2 (100%)`);
  }

  // Repeated technology mentions across one report count once per entry, and a
  // withdrawn/closed ('discarded') row is harvested like a rejected one.
  const techSignals = buildPatternSignals([
    { outcome: 'negative', notes: '', report: { gaps: [
      { description: 'Java is required', severity: 'hard' },
      { description: 'Production Java and Go experience', severity: 'hard' },
    ] } },
    { outcome: 'discarded', notes: '', report: { gaps: [{ description: 'Java is required', severity: 'hard' }] } },
  ]);
  const javaGap = techSignals.techStackGaps.find(g => g.skill === 'Java');
  if (javaGap?.frequency !== 2) {
    failures.push(`technology harvest returned ${JSON.stringify(javaGap)}, expected Java frequency 2 (one negative + one discarded row)`);
  }

  // Empty populations must expose zero bases and no NaN-bearing stats.
  const emptySignals = buildPatternSignals([]);
  if (emptySignals.discardReasonBase !== 0 || emptySignals.blockerBase !== 0
      || emptySignals.discardReasonStats.length !== 0 || emptySignals.blockerAnalysis.length !== 0
      || emptySignals.discardReasonRecommendation) {
    failures.push(`empty pattern signals were not empty: ${JSON.stringify(emptySignals)}`);
  }

  // Remote classifier (regression): the "70+" signal ends in "+", so a
  // trailing \b silently dropped it and "70+ countries" postings fell to the
  // weaker 'regional remote' bucket instead of 'global remote'.
  if (classifyRemote('Fully remote — hiring in 70+ countries') !== 'global remote') {
    failures.push('classifyRemote did not read "70+ countries" as global remote');
  }
  if (classifyRemote('US-only remote') !== 'geo-restricted') {
    failures.push('classifyRemote geo-restricted precedence regressed');
  }

  // Reports-root containment: a legit link stays inside reports/, a crafted
  // traversal link escapes root and must be rejected before parseReport. join()
  // collapses '..' at the call site, so the candidate is already absolute here.
  {
    const legit = join(CAREER_OPS, 'reports', '042-acme-2026-01-01.md');
    if (!withinReports(legit)) failures.push('containment: legit reports/ path wrongly rejected');
    const escape = join(CAREER_OPS, 'reports/../../../etc/passwd');
    if (withinReports(escape)) failures.push('containment: traversal path escaped reports/ (path-traversal guard broken)');
    const sibling = join(CAREER_OPS, 'reports-evil', 'x.md');
    if (withinReports(sibling)) failures.push('containment: reports-prefixed sibling dir wrongly accepted');
    // The same lexical guard against a workspace's reports root (REPORTS_ROOTS).
    const otherRoot = join(CAREER_OPS, 'workspace', 'reports');
    if (withinReports(join(otherRoot, '..', '..', 'reports', 'x.md'), otherRoot)) failures.push('containment: traversal escaped a workspace reports root');
    if (withinReports(join(otherRoot, '..', 'reports-evil', 'x.md'), otherRoot)) failures.push('containment: sibling of a workspace reports root wrongly accepted');
    if (withinReports(otherRoot, otherRoot)) failures.push('containment: a reports root itself wrongly accepted as a report');
  }

  // Symlink-escape + missing-file graceful degradation (#2655). realpath
  // canonicalization must reject a symlink whose target resolves OUTSIDE
  // reports/ (a lexical-only guard would follow it), while a real file inside
  // reports/ still passes and a missing candidate degrades gracefully (the
  // downstream read returns null) rather than throwing.
  {
    const reportsDir = join(CAREER_OPS, 'reports');
    if (existsSync(reportsDir)) {
      const tag = `__co2655-${process.pid}-${Date.now()}`;
      const realReport = join(reportsDir, `${tag}-real.md`);
      const escapeLink = join(reportsDir, `${tag}-escape.md`);
      const missing = join(reportsDir, `${tag}-missing.md`);
      // Missing candidate must not throw and must stay accepted so the
      // downstream read returns null (pre-#2385 existsSync-removal semantics).
      try {
        if (!withinReports(missing)) failures.push('containment: missing report file wrongly rejected (should degrade to a null read, not a hard skip)');
      } catch (err) {
        failures.push(`containment: missing report file threw instead of degrading gracefully (${err.code || err.message})`);
      }
      try {
        writeFileSync(realReport, '# real report\n');
        if (!withinReports(realReport)) failures.push('containment: real file inside reports/ wrongly rejected');
        // Symlink whose target resolves outside reports/ (this module file);
        // its lexical path is under reports/ but realpath escapes and must be
        // rejected. symlinkSync often needs privilege on Windows — skip the
        // assertion (do not fail) when the platform refuses.
        let symlinkCreated = false;
        try {
          symlinkSync(fileURLToPath(import.meta.url), escapeLink);
          symlinkCreated = true;
        } catch (err) {
          if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ENOSYS') {
            console.log(`analyze-patterns self-test: skipping symlink-escape assertion (platform refused symlink creation: ${err.code})`);
          } else {
            throw err;
          }
        }
        if (symlinkCreated && withinReports(escapeLink)) {
          failures.push('containment: symlink escaping reports/ was accepted (realpath containment broken)');
        }
      } finally {
        rmSync(realReport, { force: true });
        rmSync(escapeLink, { force: true });
      }
    }
  }

  // Role-family interview rate (#6028): a small family must not earn a suggestion.
  {
    const rows = [];
    for (let i = 1; i <= 8; i++) rows.push({ num: i, role: 'Office Administrator', normalizedStatus: i === 1 ? 'interview' : 'applied', report: null });
    for (let i = 9; i <= 40; i++) rows.push({ num: i, role: 'Payroll Clerk', normalizedStatus: 'applied', report: null });
    const rf = buildRoleFamilyInterviewRate(rows, '');
    const admin = rf.families.find(f => f.family === 'Administrative');
    if (admin?.verdict !== NOT_ENOUGH_DATA || rf.suggestions.length > 0) failures.push('role-family: 8 applications with 1 interview produced a suggestion');
  }

  if (failures.length > 0) {
    console.error(`analyze-patterns self-test failed: ${failures.join('; ')}`);
    process.exit(1);
  }

  console.log('analyze-patterns self-test OK (Machine Summary parser + vendor detection + via channel analysis)');
  process.exit(0);
}

// The ledger sits next to the tracker (same rule as stats.mjs/funnel-velocity.mjs).
function readStatusLog() {
  const file = join(dirname(APPS_FILE), 'status-log.tsv');
  return existsSync(file) ? readFileSync(file, 'utf-8') : '';
}

// --- Parse applications.md ---
function parseTracker() {
  if (!existsSync(APPS_FILE)) return [];
  const content = readFileSync(APPS_FILE, 'utf-8');
  const lines = content.split('\n');
  const colmap = resolveColumns(lines);
  const entries = [];
  for (const line of lines) {
    const row = parseTrackerRow(line, colmap);
    if (row) entries.push(row);
  }
  return entries;
}

// Canonical reports-root containment. A tracker link resolves to a candidate
// path; accept it only if it stays inside a reports/ directory (the data
// root's by default; see REPORTS_ROOTS for the others). Two
// layers: a cheap lexical traversal guard (no stat) rejects a crafted link like
// reports/../../etc/passwd, which join() collapses to a path that is no longer
// under reports/; then realpath canonicalization rejects a
// symlink whose target escapes reports/ (a lexical-only check would follow it).
// realpathSync throws ENOENT/ENOTDIR for a not-yet-created candidate or a
// missing reports root — both are non-fatal: a missing candidate falls through
// to the downstream read (which returns null, preserving prior semantics), a
// missing root means there are simply no reports. Only genuinely unexpected
// errors rethrow, matching readTextIfExists. The same guard as upskill.mjs's,
// with the reports root as a parameter.
function withinReports(candidate, reportsRoot = join(CAREER_OPS, 'reports')) {
  const rootRelative = relative(reportsRoot, candidate);
  if (!rootRelative || isAbsolute(rootRelative) || rootRelative.includes('..')) return false;
  let realRoot;
  try {
    realRoot = realpathSync(reportsRoot);
  } catch (err) {
    if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return false;
    throw err;
  }
  let realCandidate;
  try {
    realCandidate = realpathSync(candidate);
  } catch (err) {
    if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return true;
    throw err;
  }
  const rootWithSep = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
  return realCandidate === realRoot || realCandidate.startsWith(rootWithSep);
}

// Read a file, returning null when it does not exist. A pre-flight existsSync
// costs a full stat per report and races with the read (#2385); attempting the
// read and handling the missing-file error costs the same as a bare read.
function readTextIfExists(path) {
  try {
    return readFileSync(path, 'utf-8');
  } catch (err) {
    if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null;
    throw err;
  }
}

// --- Parse a single report file ---
// --- Report header field patterns (locale-tolerant) ---
//
// A report header is written by the modes in the user's output language, and
// French typography puts a space before a colon: `**URL :**`, `**Archétype :**`.
// The original patterns required `URL:` and `Archetype:` with no space and no
// accent, so every French report parsed as "field absent" — silently, since a
// missing header field is a legitimate state for older reports and never raises.
//
// The failure is invisible and it empties the analysis rather than breaking it:
// on a 224-report corpus, 128 reports lost their URL (so their ATS vendor fell
// into the `unknown` bucket, leaving the vendor breakdown to speak for 1 report
// out of 22 submitted) and 61 lost their archetype. Same shape as #3495.
//
// The whitespace class is horizontal-only: space, tab, and U+00A0, the one French
// editors insert automatically. `\s` would also match a line break, letting a header
// split across two lines parse as valid and capture a value that belongs to the next
// line; a header field is single-line by definition, so the class is bounded rather
// than left permissive. Keep the accent-less spellings: reports written before this
// fix, and the `Arquetipo` Spanish variant, must keep parsing.
const REPORT_URL_RE = /^URL[ \t\u00a0]*:[ \t\u00a0]*(https?:\/\/\S+)/im;
// Every archetype header label shipped by a localized evaluation mode in this
// repo. Each form is extracted from the mode that writes it, never translated
// here: an invented spelling adds a key no report ever carries while the real
// one keeps being dropped. Same sourcing rule #3679 applies on the web side.
//
// Longest-first so a shorter form never shadows a longer one that starts with it
// (Arketip / Arketipe). Backtracking would resolve it either way; the order makes
// the intent explicit rather than incidental.
const ARCHETYPE_LABELS = [
  'Arquétipo',  // pt   — modes/pt/oferta.md
  'Arquetipo',  // es   — modes/es/oferta.md
  'Archétype',  // fr   — as generated when the agent writes the accent
  'Archetype',  // en, nl, zh, zh-TW — and modes/fr/offre.md, which writes it unaccented
  'Archetipo',  // it   — modes/it/annuncio.md
  'Archetyp',   // de, pl — modes/de/angebot.md, modes/pl/oferta.md
  'Arketipe',   // id   — modes/id/lowongan.md
  'Arketype',   // da, nb — modes/da/oferta.md, modes/nb/stilling.md
  'Arketip',    // tr   — modes/tr/is-ilani.md
  'Архетип',    // ru, ua — modes/ru/oferta.md, modes/ua/oferta.md
];

const HEADER_HSPACE = '[ \\t\\u00a0]*';
const REPORT_ARCHETYPE_RE = new RegExp(
  `^(?:${ARCHETYPE_LABELS.join('|')})${HEADER_HSPACE}:${HEADER_HSPACE}(.+?)$`,
  'im',
);

function parseReport(reportPath) {
  const content = readTextIfExists(reportPath);
  if (content === null) return null;
  const report = {
    company: null,
    role: null,
    url: null,
    archetype: null,
    legitimacyTier: null,
    finalDecision: null,
    seniority: null,
    remote: null,
    teamSize: null,
    comp: null,
    domain: null,
    riskLevel: null,
    confidence: null,
    nextAction: null,
    topStrengths: [],
    discardReasons: [],
    scores: {},
    gaps: [],
  };

  const machineSummary = parseMachineSummary(content);
  if (machineSummary) {
    report.machineSummary = machineSummary;
    report.company = normalizeScalar(machineSummary.company) || report.company;
    report.role = normalizeScalar(machineSummary.role) || report.role;
    report.archetype = normalizeScalar(machineSummary.archetype) || report.archetype;
    report.legitimacyTier = normalizeScalar(machineSummary.legitimacy_tier) || report.legitimacyTier;
    report.finalDecision = normalizeScalar(machineSummary.final_decision) || report.finalDecision;
    report.domain = normalizeScalar(machineSummary.domain) || report.domain;
    report.seniority = normalizeScalar(machineSummary.seniority) || report.seniority;
    report.remote = normalizeScalar(machineSummary.remote) || report.remote;
    report.teamSize = normalizeScalar(machineSummary.team_size) || report.teamSize;
    report.riskLevel = normalizeScalar(machineSummary.risk_level) || report.riskLevel;
    report.confidence = normalizeScalar(machineSummary.confidence) || report.confidence;
    report.nextAction = normalizeScalar(machineSummary.next_action) || report.nextAction;
    report.topStrengths = normalizeList(machineSummary.top_strengths);
    report.discardReasons = normalizeList(machineSummary.discard_reasons);

    if (typeof machineSummary.score === 'number') {
      report.scores.global = machineSummary.score;
    }

    for (const hardStop of normalizeList(machineSummary.hard_stops)) {
      report.gaps.push({ description: hardStop, severity: 'hard stop', mitigation: '' });
    }
    for (const softGap of normalizeList(machineSummary.soft_gaps)) {
      report.gaps.push({ description: softGap, severity: 'soft gap', mitigation: '' });
    }
  }

  // Strip bold markers for easier matching
  const plain = content.replace(/\*\*/g, '');

  // Extract Block A table (Role Summary) — works with both EN and ES headers
  // Archetype cell may be labeled "Archetype", "Arquetipo", or "Detected archetype" (drift from EN translation).
  const blockARegex = /\|\s*(?:Detected\s+)?(?:Archetype|Arquetipo)\s*\|\s*(.*?)\s*\|/i;
  const seniorityRegex = /\|\s*(?:Seniority|Nivel|Level)\s*\|\s*(.*?)\s*\|/i;
  const remoteRegex = /\|\s*(?:Remote|Remoto|Location)\s*\|\s*(.*?)\s*\|/i;
  const teamRegex = /\|\s*(?:Team|Team size|Equipo)\s*\|\s*(.*?)\s*\|/i;
  const compRegex = /\|\s*(?:Comp|Salary|Salario|Listed salary)\s*\|\s*(.*?)\s*\|/i;
  const domainRegex = /\|\s*(?:Domain|Dominio|Industry)\s*\|\s*(.*?)\s*\|/i;

  // Fallback: report header field `Archetype: ...` (newer reports use this).
  const headerArchRegex = REPORT_ARCHETYPE_RE;

  // Report header carries `**URL:**` between Score and PDF (see CLAUDE.md /
  // Pipeline Integrity). Capture the first http(s) URL on that line for vendor
  // detection; reports predating the field simply leave url null (→ unknown bucket).
  const urlMatch = plain.match(REPORT_URL_RE);
  if (urlMatch && !report.url) report.url = urlMatch[1].trim().replace(/[)>\].,]+$/, '');

  const archMatch = plain.match(blockARegex) || plain.match(headerArchRegex);
  if (archMatch && !report.archetype) report.archetype = archMatch[1].trim();

  const senMatch = plain.match(seniorityRegex);
  if (senMatch && !report.seniority) report.seniority = senMatch[1].trim();

  const remMatch = plain.match(remoteRegex);
  if (remMatch && !report.remote) report.remote = remMatch[1].trim();

  const teamMatch = plain.match(teamRegex);
  if (teamMatch && !report.teamSize) report.teamSize = teamMatch[1].trim();

  const compMatch = plain.match(compRegex);
  if (compMatch && !report.comp) report.comp = compMatch[1].trim();

  const domainMatch = plain.match(domainRegex);
  if (domainMatch && !report.domain) report.domain = domainMatch[1].trim();

  // Extract scoring table — look for table with "Global" row (using plain, bold already stripped)
  const scoreRegex = /\|\s*(?:CV Match|Match con CV)\s*\|\s*([\d.]+)\/5\s*\|/i;
  const northStarRegex = /\|\s*(?:North Star)\s*\|\s*([\d.]+)\/5\s*\|/i;
  const compScoreRegex = /\|\s*(?:Comp)\s*\|\s*([\d.]+)\/5\s*\|/i;
  const culturalRegex = /\|\s*(?:Cultural signals|Cultural)\s*\|\s*([\d.]+)\/5\s*\|/i;
  const redFlagsRegex = /\|\s*(?:Red flags)\s*\|\s*([-+]?[\d.]+)\s*\|/i;
  const globalRegex = /\|\s*(?:Global)\s*\|\s*([\d.]+)\/5\s*\|/i;

  const cvScoreMatch = plain.match(scoreRegex);
  if (cvScoreMatch && report.scores.cvMatch === undefined) report.scores.cvMatch = parseFloat(cvScoreMatch[1]);

  const nsMatch = plain.match(northStarRegex);
  if (nsMatch && report.scores.northStar === undefined) report.scores.northStar = parseFloat(nsMatch[1]);

  const csMatch = plain.match(compScoreRegex);
  if (csMatch && report.scores.comp === undefined) report.scores.comp = parseFloat(csMatch[1]);

  const culMatch = plain.match(culturalRegex);
  if (culMatch && report.scores.cultural === undefined) report.scores.cultural = parseFloat(culMatch[1]);

  const rfMatch = plain.match(redFlagsRegex);
  if (rfMatch && report.scores.redFlags === undefined) report.scores.redFlags = parseFloat(rfMatch[1]);

  const glMatch = plain.match(globalRegex);
  if (glMatch && report.scores.global === undefined) report.scores.global = parseFloat(glMatch[1]);

  // Extract gaps table
  const gapTableRegex = /\|\s*Gap\s*\|\s*Severity\s*\|.*?\n\|[-|\s]+\n([\s\S]*?)(?:\n\n|\n##|\n\*\*|$)/i;
  const gapTableMatch = content.match(gapTableRegex);
  if (gapTableMatch) {
    const gapRows = gapTableMatch[1].split('\n').filter(r => r.startsWith('|'));
    for (const row of gapRows) {
      const cols = row.split('|').map(s => s.trim()).filter(Boolean);
      if (cols.length >= 2) {
        const duplicate = report.gaps.some(g => g.description.toLowerCase() === cols[0].toLowerCase());
        if (!duplicate) {
          report.gaps.push({
            description: cols[0],
            severity: cols[1].toLowerCase(),
            mitigation: cols[2] || '',
          });
        }
      }
    }
  }

  return report;
}

// --- Classify remote policy into buckets ---
function classifyRemote(raw) {
  if (!raw) return 'unknown';
  const lower = raw.toLowerCase();
  // Order matters: check geo-restricted before general remote
  if (/\b(us[- ]?only|canada[- ]?only|residents only|usa only|us residents|canada residents)\b/.test(lower)) return 'geo-restricted';
  if (/\bargentina\s+remote\s+only\b/.test(lower)) return 'geo-restricted';
  if (/\b(hybrid|on-?site|office|columbus|cape town|relocat)\b/.test(lower)) return 'hybrid/onsite';
  // (?<!\w)/(?!\w) not \b: the "70+" signal ends in "+", and a trailing \b
  // never matches after a symbol edge, so "remote in 70+ countries" fell
  // through to the weaker 'regional remote' bucket. Word alternatives behave
  // identically under either boundary, so this only rescues the "70+" case.
  if (/(?<!\w)(global|anywhere|worldwide|no restrict|70\+|work from anywhere)(?!\w)/.test(lower)) return 'global remote';
  if (/\b(remote|latam|americas|brazil|fully remote)\b/.test(lower)) return 'regional remote';
  return 'unknown';
}

// --- Detect ATS vendor from a posting URL ---
// Host-only match, deliberately looser than liveness-api.mjs's resolveAtsApi()
// (which needs the full posting path to build an API URL) — a tracker report's
// URL may point at a board/careers page, not a canonical posting.
//
// --- Classify company size ---
function classifyCompanySize(teamSize) {
  if (!teamSize) return 'unknown';
  const lower = teamSize.toLowerCase();
  // Extract numbers
  const nums = lower.match(/[\d,]+/g);
  if (nums) {
    const max = Math.max(...nums.map(n => parseInt(n.replace(/,/g, ''))));
    if (max <= 50) return 'startup';
    if (max <= 500) return 'scaleup';
    return 'enterprise';
  }
  if (/\b(small|elite|tiny|founding)\b/.test(lower)) return 'startup';
  if (/\b(large|enterprise|global)\b/.test(lower)) return 'enterprise';
  return 'unknown';
}

// --- Extract hard blocker keywords from gaps ---
function extractBlockerType(gap) {
  const desc = gap.description.toLowerCase();
  const sev = gap.severity.toLowerCase();
  if (sev.includes('nice') || sev.includes('soft')) return null; // skip soft gaps
  if (/\b(residency|us[- ]only|canada|location|visa|geo|country|region)\b/.test(desc)) return 'geo-restriction';
  if (/\b(javascript|typescript|python|ruby|java|go|rust|node|react|angular|vue|django|flask|rails)\b/.test(desc)) return 'stack-mismatch';
  if (/\b(senior|staff|lead|principal|director|manager|head)\b/.test(desc)) return 'seniority-mismatch';
  if (/\b(hybrid|on-?site|office|relocat)\b/.test(desc)) return 'onsite-requirement';
  return 'other';
}

function recordedDiscardReasons(entry) {
  if (!REASON_BEARING.has(entry.outcome)) return new Set();
  const matches = (entry.notes || '').match(/(?:DISCARD|SKIP):\s*([^,;\n]+)/gi) || [];
  return new Set(matches
    .map(match => match.replace(/^(?:DISCARD|SKIP):\s*/i, '').trim().toLowerCase())
    .filter(Boolean));
}

// The vocabulary ships with the code, not with the user's data, so it resolves
// from this file's directory rather than CAREER_OPS_ROOT.
const DISCARD_REASONS_FILE = join(dirname(fileURLToPath(import.meta.url)), 'templates/discard-reasons.yml');

/**
 * Canonical discard-reason ids from templates/discard-reasons.yml (#2785).
 * Returns null when the file is missing, malformed, or lists no id; callers
 * then report every label as `other` instead of inventing a vocabulary.
 */
export function loadDiscardReasonVocabulary(file = DISCARD_REASONS_FILE) {
  try {
    const parsed = loadYaml(readFileSync(file, 'utf8'));
    const reasons = Array.isArray(parsed?.reasons) ? parsed.reasons : [];
    const ids = reasons.map(reason => reason?.id).filter(id => typeof id === 'string' && id);
    return ids.length > 0 ? new Set(ids) : null;
  } catch {
    return null;
  }
}

// Spelling only: case, surrounding whitespace, and space/hyphen vs underscore.
// Synonyms are never guessed; an unmatched label stays `other`.
function discardReasonKey(label) {
  return label.trim().toLowerCase().replace(/[\s-]+/g, '_');
}

/**
 * Forecasts stay separate from recorded outcomes (#2785). The base is tracker
 * entries whose linked report explicitly supplies prediction data, including
 * an empty list; a missing or malformed field is unknown, not an empty forecast.
 * All statuses remain eligible: advancing later does not erase a prediction.
 * Only canonical ids from templates/discard-reasons.yml enter the shares. Every
 * other label is listed under `predictedDiscardReasonOther` with its own
 * spelling (case and surrounding whitespace normalized) and is never counted
 * in `predictedDiscardReasonStats`.
 */
export function buildPredictedDiscardReasonSignals(enriched, vocabulary = loadDiscardReasonVocabulary()) {
  const counts = new Map();
  const otherCounts = new Map();
  let entriesWithOther = 0;
  const coverage = {
    entriesWithReports: 0,
    entriesWithPredictionData: 0,
    entriesWithPredictions: 0,
    entriesWithRecordedReasons: 0,
    // Valid prediction data (even []) plus an eligible recorded reason. This
    // measures comparison coverage, never agreement between the two sources.
    entriesWithBothSources: 0,
  };
  for (const entry of enriched) {
    const hasRecordedReasons = recordedDiscardReasons(entry).size > 0;
    if (hasRecordedReasons) coverage.entriesWithRecordedReasons++;
    if (!entry.report) continue;
    coverage.entriesWithReports++;
    const raw = entry.report.machineSummary?.discard_reasons;
    // normalizeList accepts scalars for older reports, but also stringifies
    // objects inside lists. Do not publish those coercions as predicted reasons.
    if (typeof raw !== 'string'
        && !(Array.isArray(raw) && raw.every(reason => typeof reason === 'string'))) continue;
    coverage.entriesWithPredictionData++;
    if (hasRecordedReasons) coverage.entriesWithBothSources++;
    const reasons = new Set((entry.report.discardReasons || [])
      .map(reason => reason.trim().toLowerCase()).filter(Boolean));
    if (reasons.size > 0) coverage.entriesWithPredictions++;
    const canonical = new Set();
    const other = new Set();
    for (const reason of reasons) {
      const key = discardReasonKey(reason);
      if (vocabulary?.has(key)) canonical.add(key);
      else other.add(reason);
    }
    for (const reason of canonical) counts.set(reason, (counts.get(reason) || 0) + 1);
    for (const reason of other) otherCounts.set(reason, (otherCounts.get(reason) || 0) + 1);
    if (other.size > 0) entriesWithOther++;
  }
  const base = coverage.entriesWithPredictionData;
  const byFrequency = (a, b) => b.frequency - a.frequency || a.reason.localeCompare(b.reason);
  return {
    predictedDiscardReasonStats: [...counts.entries()]
      .map(([reason, frequency]) => ({ reason, frequency, percentage: Math.round(frequency / base * 100) }))
      .sort(byFrequency),
    // Reported beside the shares, never inside them. A growing `entries` share
    // is the signal that the vocabulary needs a new member.
    predictedDiscardReasonOther: {
      vocabularyLoaded: vocabulary !== null,
      entries: entriesWithOther,
      percentage: base ? Math.round(entriesWithOther / base * 100) : 0,
      reasons: [...otherCounts.entries()]
        .map(([reason, frequency]) => ({ reason, frequency }))
        .sort(byFrequency),
    },
    predictedDiscardReasonBase: base,
    discardReasonCoverage: coverage,
  };
}

/**
 * Build the blocker, discard-reason, and technology signals used by both the
 * production analysis and regression fixtures.
 * @param {Array<object>} enriched Tracker entries enriched with outcomes/reports.
 * @returns {object} Aggregated rates, population bases, and discard recommendation.
 */
function buildPatternSignals(enriched) {
  const blockerCounts = new Map();
  const blockerBase = gapBearingBase(enriched);
  for (const e of enriched) {
    if (!e.report?.gaps) continue;
    const entryBlockers = new Set();
    for (const gap of e.report.gaps) {
      const type = extractBlockerType(gap);
      if (type) entryBlockers.add(type);
    }
    for (const type of entryBlockers) {
      blockerCounts.set(type, (blockerCounts.get(type) || 0) + 1);
    }
  }
  const blockerAnalysis = [...blockerCounts.entries()]
    .map(([blocker, frequency]) => ({
      blocker,
      frequency,
      percentage: blockerBase ? Math.round((frequency / blockerBase) * 100) : 0,
    }))
    .sort((a, b) => b.frequency - a.frequency);

  const discardReasonCounts = new Map();
  for (const e of enriched) {
    for (const key of recordedDiscardReasons(e)) {
      discardReasonCounts.set(key, (discardReasonCounts.get(key) || 0) + 1);
    }
  }
  const discardReasonBase = discardableBase(enriched);
  const discardReasonStats = [...discardReasonCounts.entries()]
    .map(([reason, frequency]) => ({
      reason,
      frequency,
      percentage: discardReasonBase ? Math.round((frequency / discardReasonBase) * 100) : 0,
    }))
    .sort((a, b) => b.frequency - a.frequency);

  const stackGapCounts = new Map();
  for (const e of enriched) {
    if (!REASON_BEARING.has(e.outcome)) continue;
    if (!e.report?.gaps) continue;
    const entryTechs = new Set();
    for (const gap of e.report.gaps) {
      for (const tech of extractTechMentions(gap.description)) entryTechs.add(tech);
    }
    for (const tech of entryTechs) {
      stackGapCounts.set(tech, (stackGapCounts.get(tech) || 0) + 1);
    }
  }
  const techStackGaps = [...stackGapCounts.entries()]
    .map(([skill, frequency]) => ({ skill, frequency }))
    .sort((a, b) => b.frequency - a.frequency)
    .slice(0, 15);

  const topDiscardReason = discardReasonStats[0];
  const discardReasonRecommendation = topDiscardReason
    && topDiscardReason.frequency >= Math.max(3, Math.ceil(discardReasonBase * 0.15))
    ? {
      action: `Add "${topDiscardReason.reason}" filter to modes/_custom.md to avoid wasting evaluation effort`,
      reasoning: `"${topDiscardReason.reason}" is the most frequent discard reason (${topDiscardReason.frequency}x, ${topDiscardReason.percentage}% of ${discardReasonBase} eligible entries with self-filtered or negative outcomes).`,
      impact: 'high',
    }
    : null;

  return {
    blockerBase,
    blockerAnalysis,
    discardReasonBase,
    discardReasonStats,
    techStackGaps,
    discardReasonRecommendation,
  };
}

// --- Main analysis ---
// --- Interview rate by role family (#6028) ---
// Advisory and read-only: groups rows by role family and reports how often each
// family reached Interview, so a user can see where applications convert. It
// never feeds scoring, targeting or portals.yml.
//
// Role family = the archetype recorded in the row's report; rows without one
// fall back to the first matching keyword group below (first match wins), and
// otherwise land in "Unclassified". The table is built in and deliberately
// small; it is system-layer code, so user-specific families belong in report
// archetypes (user layer), not here.
const ROLE_FAMILY_KEYWORDS = Object.freeze([
  ['HR / people operations', /\b(hr|human resources|talent|recruit\w*|payroll|people (ops|operations|partner|coordinator))\b/i],
  ['Learning and development', /\b(instruction\w*|learning|training|curriculum|l&d|e-?learning|educator|teacher)\b/i],
  ['Administrative', /\b(administrat\w*|office|receptionist|secretary|executive assistant|clerk)\b/i],
  ['Data and reporting', /\b(data|analyst|analytics|reporting|bi)\b/i],
  ['Software engineering', /\b(software|engineer\w*|developer|devops|programmer)\b/i],
]);
const UNCLASSIFIED_FAMILY = 'Unclassified';
const familyKey = (name) => name.normalize('NFKC').toLowerCase();
const UNCLASSIFIED_KEY = familyKey(UNCLASSIFIED_FAMILY);
// A suggestion needs at least this many applications in the family AND a rate
// at least this multiple of the overall rate. Below either, only counts print.
let MIN_FAMILY_N = 20;
const MIN_FAMILY_LIFT = 2;
const NOT_ENOUGH_DATA = 'not enough data';

export function roleFamilyOf(entry) {
  const archetype = String(entry?.report?.archetype ?? '').trim();
  if (archetype) return archetype;
  const role = String(entry?.role ?? '');
  for (const [family, re] of ROLE_FAMILY_KEYWORDS) {
    if (re.test(role)) return family;
  }
  return UNCLASSIFIED_FAMILY;
}

/** Wilson 95% interval for k of n, as percentages with one decimal. */
function wilson95(k, n) {
  if (n === 0) return null;
  const z = 1.96, p = k / n, d = 1 + (z * z) / n;
  const centre = (p + (z * z) / (2 * n)) / d;
  const half = (z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))) / d;
  const r1 = (x) => Math.round(x * 1000) / 10;
  return [r1(Math.max(0, centre - half)), r1(Math.min(1, centre + half))];
}

/**
 * Interview rate per role family. "Reached interview" folds the status-log the
 * same way stats.mjs/funnel-velocity.mjs do (recoverFunnelStages), so a row
 * rejected after an interview still counts; with no ledger it falls back to
 * the current status. Only rows that were actually applied count.
 *
 * @param {Array} enriched - tracker rows (num, role, normalizedStatus, report).
 * @param {string} [statusLogContent] - raw data/status-log.tsv text.
 */
export function buildRoleFamilyInterviewRate(enriched, statusLogContent = '', { minFamilyN = MIN_FAMILY_N } = {}) {
  const ledger = parseStatusLogStages(statusLogContent);
  const statusByNum = new Map(enriched.map(e => [e.num, e.normalizedStatus]));
  const reached = recoverFunnelStages(statusByNum, ledger);
  const r1 = (x) => Math.round(x * 10) / 10;

  const groups = new Map();
  const seen = new Set();
  let applied = 0, interviews = 0;
  for (const e of enriched) {
    if (seen.has(e.num)) continue;
    seen.add(e.num);
    const rank = reached.get(e.num) || 0;
    if (rank < 1) continue;
    const family = roleFamilyOf(e);
    const key = familyKey(family);
    if (!groups.has(key)) groups.set(key, { family, applied: 0, interviews: 0 });
    const g = groups.get(key);
    g.applied++;
    applied++;
    if (rank >= 4) { g.interviews++; interviews++; }
  }

  const families = [...groups.entries()].map(([key, g]) => {
    const sufficientSample = g.applied >= minFamilyN;
    const lifts = interviews > 0 && g.interviews > 0
      && g.interviews * applied >= MIN_FAMILY_LIFT * interviews * g.applied;
    const suggest = key !== UNCLASSIFIED_KEY && sufficientSample && lifts;
    return {
      family: g.family,
      applied: g.applied,
      interviews: g.interviews,
      ratePct: r1((g.interviews / g.applied) * 100),
      ci95Pct: sufficientSample ? wilson95(g.interviews, g.applied) : null,
      sufficientSample,
      verdict: suggest ? 'suggest' : (sufficientSample ? 'no clear lift' : NOT_ENOUGH_DATA),
    };
  }).sort((a, b) => b.applied - a.applied || a.family.localeCompare(b.family));

  const overallRatePct = applied > 0 ? r1((interviews / applied) * 100) : null;
  const suggestions = families.filter(f => f.verdict === 'suggest').map(f =>
    `${f.family}: ${f.interviews}/${f.applied} reached interview (${f.ratePct}%) vs ${overallRatePct}% overall -- consider weighting more applications here. Advisory only; location, pay and visa fit may still outweigh it.`);

  return {
    basis: ledger.length > 0 ? 'ledger' : 'snapshot',
    minFamilyN,
    minLiftRatio: MIN_FAMILY_LIFT,
    overall: { applied, interviews, ratePct: overallRatePct },
    families,
    suggestions,
  };
}

export function analyze(entries = parseTracker(), { statusLogContent } = {}) {
  if (entries.length === 0) {
    // noData marks this as the empty-tracker case rather than a failure, so the
    // exit status below does not have to match on the message text.
    return { error: 'No applications found in tracker.', noData: true };
  }

  // Enrich entries with report data and classification
  const enriched = entries.map(e => {
    const reportTarget = firstLinkTarget(e.report);
    // Tracker links are relative to the tracker file's own directory (see
    // merge-tracker.mjs link normalization, and TRACKER_DIRS); fall back to
    // repo root for legacy root-relative links. Each candidate is guarded to
    // a reports/ root before the read is attempted; parseReport returns null
    // for a missing file, so no pre-flight existsSync is needed (#2385).
    let reportData = null;
    if (reportTarget != null) {
      const candidates = new Set([
        ...TRACKER_DIRS.map((dir) => join(dir, reportTarget)),
        join(CAREER_OPS, reportTarget),
      ]);
      for (const candidate of candidates) {
        if (!REPORTS_ROOTS.some((root) => withinReports(candidate, root))) continue;
        reportData = parseReport(candidate);
        if (reportData) break;
      }
    }
    const outcome = classifyOutcome(e.status);
    const trackerScore = parseFloat(e.score);
    const score = Number.isFinite(trackerScore)
      ? trackerScore
      : (Number.isFinite(reportData?.scores?.global) ? reportData.scores.global : 0);

    // Fallback: if report didn't have Remote field, try the notes column
    const remoteSource = reportData?.remote || e.notes || '';
    const teamSource = reportData?.teamSize || '';

    return {
      ...e,
      normalizedStatus: normalizeStatus(e.status),
      outcome,
      score,
      report: reportData,
      remoteBucket: classifyRemote(remoteSource),
      companySize: classifyCompanySize(teamSource),
      vendor: knownAtsVendorOf(reportData?.url),
    };
  });

  // The floor counts applications actually SENT: a tracker of skipped and
  // withdrawn rows has no outcomes to learn from.
  const sent = enriched.filter(e => SUBMITTED_STATUSES.has(e.normalizedStatus));
  if (sent.length < MIN_THRESHOLD) {
    return {
      error: `Not enough data: ${sent.length}/${MIN_THRESHOLD} applications sent. Keep applying and come back later.`,
      current: sent.length,
      threshold: MIN_THRESHOLD,
    };
  }

  // --- Funnel ---
  const funnel = {};
  for (const e of enriched) {
    const s = e.normalizedStatus;
    funnel[s] = (funnel[s] || 0) + 1;
  }

  // --- Score comparison by outcome ---
  const scoresByOutcome = Object.fromEntries(OUTCOME_BUCKETS.map((b) => [b, []]));
  for (const e of enriched) {
    if (e.score > 0) scoresByOutcome[e.outcome].push(e.score);
  }

  const scoreStats = (arr) => {
    if (arr.length === 0) return { avg: 0, min: 0, max: 0, count: 0 };
    const avg = arr.reduce((a, b) => a + b, 0) / arr.length;
    return {
      avg: Math.round(avg * 100) / 100,
      min: Math.min(...arr),
      max: Math.max(...arr),
      count: arr.length,
    };
  };

  const scoreComparison = Object.fromEntries(
    OUTCOME_BUCKETS.map((bucket) => [bucket, scoreStats(scoresByOutcome[bucket])])
  );

  const archetypeBreakdown = breakdownBy(enriched, 'archetype', e => e.report?.archetype || 'Unknown');

  // --- Blocker / discard-reason / technology analysis ---
  // Shared with --self-test so fixtures exercise the production aggregation.
  const {
    blockerBase,
    blockerAnalysis,
    discardReasonBase,
    discardReasonStats,
    techStackGaps,
    discardReasonRecommendation,
  } = buildPatternSignals(enriched);

  const remotePolicy = breakdownBy(enriched, 'policy', e => e.remoteBucket);

  const companySizeBreakdown = breakdownBy(enriched, 'size', e => e.companySize);

  // --- ATS vendor / channel analysis (algorithmic-monoculture aware) ---
  // Motivation: Bommasani et al., "Algorithmic Monocultures in Hiring" (FAccT
  // 2026, arXiv:2605.27371) — rejections routed through a shared screening
  // vendor are correlated, not independent. If a concentrated channel yields
  // nothing, feeding it the same profile has diminishing returns; the rational
  // move is to divert those companies to referral/direct contact.
  //
  // HONESTY: this reports CHANNEL YIELD, not discrimination. A single tracker
  // can't causally separate "the vendor's algorithm filters me" from "that
  // vendor skews toward a segment I fit poorly" — but "stop feeding a dead
  // channel, go around it" is rational under either explanation.
  //
  // "Advanced" here is STRICTER than the outcome=='positive' bucket: a bare
  // 'applied' (submitted, no reply yet) does NOT count as passing screening.
  const isAdvanced = (e) => ADVANCED_STATUSES.has(e.normalizedStatus);

  // Only applications we actually submitted count toward channel yield (drop
  // 'evaluated' = never applied, and 'skip' = self-filtered).
  const submitted = enriched.filter(e => SUBMITTED_STATUSES.has(e.normalizedStatus));
  const overallAdvanced = submitted.filter(isAdvanced).length;
  const overallAdvanceRate = submitted.length > 0
    ? Math.round((overallAdvanced / submitted.length) * 100) : 0;

  const vendorMap = new Map();
  for (const e of submitted) {
    const v = e.vendor || 'unknown';
    if (!vendorMap.has(v)) vendorMap.set(v, { total: 0, advanced: 0 });
    const entry = vendorMap.get(v);
    entry.total++;
    if (isAdvanced(e)) entry.advanced++;
  }

  // Recommendations only fire on buckets with enough n to not be noise; the
  // breakdown still SHOWS every bucket (with its n) so nothing is hidden.
  const vendorBreakdown = [...vendorMap.entries()]
    .filter(([v]) => v !== 'unknown')
    .map(([vendor, data]) => ({
      vendor,
      total: data.total,
      advanced: data.advanced,
      advanceRate: data.total > 0 ? Math.round((data.advanced / data.total) * 100) : 0,
      sharePct: submitted.length > 0 ? Math.round((data.total / submitted.length) * 100) : 0,
      sufficientSample: data.total >= MIN_VENDOR_N,
    }))
    .sort((a, b) => b.total - a.total);

  const identifiedCount = submitted.length - (vendorMap.get('unknown')?.total || 0);
  const vendorAnalysis = {
    scope: [...VENDOR_ANALYSIS_SCOPE],
    minSampleForClaim: MIN_VENDOR_N,
    submitted: submitted.length,
    identified: identifiedCount,
    coveragePct: submitted.length > 0 ? Math.round((identifiedCount / submitted.length) * 100) : 0,
    overallAdvanceRate,
    breakdown: vendorBreakdown,
    citation: 'Bommasani et al., Algorithmic Monocultures in Hiring, FAccT 2026 (arXiv:2605.27371)',
  };

  // --- Via channel analysis (#1596 follow-up): per-agency advance rate ---
  // Same honesty rules as the vendor analysis above: this reports CHANNEL
  // YIELD. In an agency-mediated search the highest-leverage decision is which
  // recruiter relationships to invest in — this shows which ones convert.
  // Rows only carry `via` when the tracker has the optional Via column
  // (#1596); without it every bucket is empty and nothing is claimed.
  const viaChannelAnalysis = buildViaChannelAnalysis(submitted, isAdvanced);

  // --- Score threshold analysis ---
  const scoreThreshold = scoreThresholdFrom(scoresByOutcome.positive, scoresByOutcome.negative);

  // --- Generate recommendations ---
  const recommendations = [];

  if (discardReasonRecommendation) recommendations.push(discardReasonRecommendation);

  // Geo-restriction recommendation
  const geoBlocker = blockerAnalysis.find(b => b.blocker === 'geo-restriction');
  if (geoBlocker && geoBlocker.percentage >= 20) {
    recommendations.push({
      action: `Review location and work-authorization filters in portals.yml -- ${geoBlocker.frequency}/${blockerBase} entries carrying gaps (${geoBlocker.percentage}%) hit a geo-restriction blocker`,
      reasoning: `${geoBlocker.frequency} of ${blockerBase} entries carrying gaps are affected by a location or work-authorization restriction. Review these filters before applying to similar roles.`,
      impact: 'high',
    });
  }

  // Stack mismatch recommendation
  const stackBlocker = blockerAnalysis.find(b => b.blocker === 'stack-mismatch');
  if (stackBlocker && stackBlocker.percentage >= 15) {
    const topGaps = techStackGaps.slice(0, 3).map(g => g.skill).join(', ');
    recommendations.push({
      action: `Filter out roles requiring ${topGaps} as primary stack -- ${stackBlocker.percentage}% hit stack mismatch`,
      reasoning: `Core stack gaps (${topGaps}) are the most common technical blockers in negative outcomes.`,
      impact: 'high',
    });
  }

  if (scoreThreshold.recommended !== null && scoreThreshold.recommended > 3.0) {
    recommendations.push({
      action: `Set minimum score threshold at ${scoreThreshold.recommended}/5 before generating PDFs`,
      reasoning: scoreThreshold.reasoning,
      impact: 'medium',
    });
  }

  const bestArchetype = bestDecidedSegment(archetypeBreakdown);
  if (bestArchetype) {
    recommendations.push({
      action: `Double down on "${bestArchetype.archetype}" roles (${bestArchetype.decidedRate}% of decided outcomes advanced, ${bestArchetype.conversionRate}% of all sent)`,
      reasoning: `${bestArchetype.positive} of ${bestArchetype.decided} decided applications in this archetype advanced (${bestArchetype.awaiting} still awaiting a reply).`,
      impact: 'medium',
    });
  }

  const worstRemote = worstDecidedSegment(remotePolicy);
  if (worstRemote) {
    recommendations.push({
      action: `Avoid "${worstRemote.policy}" roles (0 of ${worstRemote.decided} decided outcomes advanced, ${worstRemote.submitted} sent)`,
      reasoning: `None of the ${worstRemote.decided} decided applications with "${worstRemote.policy}" policy led to progress.`,
      impact: 'medium',
    });
  }

  // Channel-monoculture recommendation: a concentrated vendor (>= 25% of
  // submissions, sufficient sample) whose advance rate is well below EVERY OTHER
  // channel is a dead channel worth routing around, not re-feeding. The baseline
  // is leave-one-out (this vendor vs all other submissions) — comparing to an
  // overall rate that INCLUDES the vendor understates the gap when it dominates.
  let deadChannel = null;
  for (const v of vendorBreakdown) {
    if (!v.sufficientSample || v.sharePct < 25) continue;
    const others = submitted.filter(e => (e.vendor || 'unknown') !== v.vendor);
    if (others.length === 0) continue;
    const othersRate = Math.round((others.filter(isAdvanced).length / others.length) * 100);
    // Meaningful gap only: the rest of the pipeline must be doing at least
    // moderately better, so we're not flagging a uniformly cold market.
    if (v.advanceRate < othersRate && othersRate - v.advanceRate >= 10) {
      if (!deadChannel || v.advanceRate < deadChannel.advanceRate) deadChannel = { ...v, othersRate };
    }
  }
  if (deadChannel) {
    recommendations.push({
      action: `Route ${deadChannel.vendor} companies through referral / direct contact -- ${deadChannel.sharePct}% of your applications flow through it at a ${deadChannel.advanceRate}% advance rate (vs ${deadChannel.othersRate}% through other channels)`,
      reasoning: `${deadChannel.advanced}/${deadChannel.total} ${deadChannel.vendor} applications advanced past screening, well below your other channels. Under algorithmic monoculture (Bommasani et al., FAccT 2026) a shared screener's rejections are correlated -- re-applying the same profile through the same engine has diminishing returns; a human channel bypasses it. Channel yield, not a discrimination claim.`,
      impact: 'high',
    });
  }

  // Best-converting agency (#1596 follow-up): with a sufficient sample and a
  // clear lead over the overall pipeline, that recruiter relationship is worth
  // prioritizing. One recommendation at most — the breakdown shows the rest.
  const topAgency = viaChannelAnalysis.breakdown
    .filter(a => a.sufficientSample && a.advanced > 0 && a.advanceRate >= overallAdvanceRate + 10)
    .sort((a, b) => b.advanceRate - a.advanceRate)[0];
  if (topAgency) {
    recommendations.push({
      action: `Prioritize roles via ${topAgency.agency} -- ${topAgency.advanceRate}% advance rate across ${topAgency.total} submissions (overall: ${overallAdvanceRate}%)`,
      reasoning: `${topAgency.advanced}/${topAgency.total} applications through ${topAgency.agency} advanced past screening, well above your overall rate. In an agency-mediated search the highest-leverage decision is which recruiter relationships to invest in -- this one converts. Channel yield, not a causal claim.`,
      impact: 'medium',
    });
  }

  // Date range
  const dates = enriched.map(e => e.date).filter(Boolean).sort();

  const byOutcome = Object.fromEntries(
    OUTCOME_BUCKETS.map((bucket) => [bucket, enriched.filter(e => e.outcome === bucket).length])
  );

  return {
    metadata: {
      total: enriched.length,
      dateRange: { from: dates[0], to: dates[dates.length - 1] },
      analysisDate: localToday(),
      byOutcome,
      // The same rates as every breakdown row, over the whole tracker — the
      // one honest place to quote "X% of what I sent advanced".
      outcomeRates: withOutcomeRates({ total: enriched.length, ...byOutcome }),
    },
    funnel,
    scoreComparison,
    archetypeBreakdown,
    roleFamilyInterviewRate: buildRoleFamilyInterviewRate(enriched, statusLogContent ?? readStatusLog()),
    blockerAnalysis,
    remotePolicy,
    companySizeBreakdown,
    vendorAnalysis,
    viaChannelAnalysis,
    scoreThreshold,
    techStackGaps,
    discardReasonStats,
    ...buildPredictedDiscardReasonSignals(enriched),
    // Populations the percentages above are shares of. Exported because a
    // consumer cannot sanity-check a rate whose denominator is invisible —
    // that opacity is precisely what let the wrong base survive unnoticed.
    discardReasonBase,
    blockerBase,
    recommendations,
  };
}

// --- Summary mode (human-readable) ---
function printSummary(result) {
  if (result.error) {
    console.log(`\n${result.error}\n`);
    return;
  }

  const { metadata, funnel, scoreComparison, archetypeBreakdown, roleFamilyInterviewRate, blockerAnalysis, remotePolicy, scoreThreshold, techStackGaps, discardReasonStats, recommendations } = result;

  console.log(`\n${'='.repeat(60)}`);
  console.log(`  Pattern Analysis — ${metadata.analysisDate}`);
  console.log(`  ${metadata.total} applications (${metadata.dateRange.from} to ${metadata.dateRange.to})`);
  console.log(`${'='.repeat(60)}\n`);

  // Funnel
  console.log('CONVERSION FUNNEL');
  console.log('-'.repeat(40));
  for (const status of FUNNEL_ORDER) {
    if (funnel[status]) {
      const pct = Math.round((funnel[status] / metadata.total) * 100);
      console.log(`  ${status.padEnd(15)} ${String(funnel[status]).padStart(3)} (${pct}%)`);
    }
  }

  // Score comparison
  console.log('\nSCORE BY OUTCOME');
  console.log('-'.repeat(40));
  for (const [group, stats] of Object.entries(scoreComparison)) {
    if (stats.count > 0) {
      console.log(`  ${group.padEnd(15)} avg ${stats.avg}/5  (${stats.count} entries, range ${stats.min}-${stats.max})`);
    }
  }

  // Interview rate by role family (#6028)
  const rf = roleFamilyInterviewRate;
  if (rf && rf.families.length > 0) {
    console.log('\nINTERVIEW RATE BY ROLE FAMILY');
    console.log('-'.repeat(40));
    console.log(`  overall ${rf.overall.interviews}/${rf.overall.applied} reached interview (${rf.overall.ratePct}%)${rf.basis === 'ledger' ? '; status-log history folded' : ''}`);
    for (const f of rf.families) {
      const ci = f.ci95Pct ? ` 95% CI ${f.ci95Pct[0]}-${f.ci95Pct[1]}%` : '';
      const tail = f.verdict === 'suggest' ? 'focus here' : f.verdict;
      console.log(`  ${f.family.slice(0, 28).padEnd(28)} n=${String(f.applied).padStart(3)}  ${String(f.interviews).padStart(2)} interviews  ${String(f.ratePct).padStart(5)}%${ci}  ${tail}`);
    }
    for (const s of rf.suggestions) console.log(`  -> ${s}`);
    console.log(`  A suggestion needs n >= ${rf.minFamilyN} and a rate >= ${rf.minLiftRatio}x overall.`);
  }

  // Blockers
  if (blockerAnalysis.length > 0) {
    console.log(`\nTOP BLOCKERS (of ${result.blockerBase} entries carrying gaps)`);
    console.log('-'.repeat(40));
    for (const b of blockerAnalysis) {
      console.log(`  ${b.blocker.padEnd(20)} ${String(b.frequency).padStart(2)}x (${b.percentage}%)`);
    }
  }

  // Remote policy
  console.log('\nREMOTE POLICY');
  console.log('-'.repeat(40));
  for (const r of remotePolicy) {
    // A segment nobody has answered yet prints n/a, not 0%: silence is not a result.
    const decidedLabel = r.decidedRate === null ? 'n/a' : `${r.decidedRate}%`;
    console.log(`  ${r.policy.padEnd(20)} ${String(r.submitted).padStart(2)} sent, ${r.awaiting} awaiting, ${r.positive} positive of ${r.decided} decided (${decidedLabel})`);
  }

  // Tech gaps
  if (techStackGaps.length > 0) {
    console.log('\nTOP TECH STACK GAPS (negative / discarded outcomes)');
    console.log('-'.repeat(40));
    for (const g of techStackGaps.slice(0, 10)) {
      console.log(`  ${g.skill.padEnd(20)} ${g.frequency}x`);
    }
  }

  // Discard reasons
  if (discardReasonStats && discardReasonStats.length > 0) {
    console.log(`\nTOP DISCARD / SKIP REASONS (of ${result.discardReasonBase} self-filtered / discarded / negative entries)`);
    console.log('-'.repeat(40));
    for (const d of discardReasonStats.slice(0, 10)) {
      console.log(`  ${d.reason.padEnd(30)} ${String(d.frequency).padStart(2)}x (${d.percentage}%)`);
    }
  }

  const coverage = result.discardReasonCoverage;
  console.log(`\nPREDICTED DISCARD / SKIP REASONS (of ${result.predictedDiscardReasonBase} entries with prediction data)`);
  console.log('-'.repeat(40));
  console.log(`  Prediction data: ${coverage.entriesWithPredictionData}/${coverage.entriesWithReports} entries with linked reports; ${coverage.entriesWithPredictions} contain reasons.`);
  console.log(`  Recorded reasons: ${coverage.entriesWithRecordedReasons}/${result.discardReasonBase} eligible entries; ${coverage.entriesWithBothSources} entries have both sources.`);
  console.log('  Forecasts cover all statuses; recorded reasons cover skipped, discarded, and rejected entries.');
  console.log('  Predictions are not outcomes. Missing data is unknown; canonical ids are matched by spelling, not meaning.');
  const other = result.predictedDiscardReasonOther;
  if (!other.vocabularyLoaded) {
    console.log('  templates/discard-reasons.yml could not be read; every predicted reason is listed as other.');
  }
  if (result.predictedDiscardReasonBase === 0) {
    console.log('  No prediction data recorded yet.');
  } else if (result.predictedDiscardReasonStats.length === 0 && other.reasons.length === 0) {
    console.log('  No reasons predicted in the recorded data.');
  } else {
    for (const d of result.predictedDiscardReasonStats.slice(0, 10)) {
      console.log(`  ${d.reason.padEnd(30)} ${String(d.frequency).padStart(2)}x (${d.percentage}%)`);
    }
    if (other.entries > 0) {
      console.log(`  other (outside the vocabulary, not counted above): ${other.entries} entries (${other.percentage}%)`);
      for (const d of other.reasons.slice(0, 10)) {
        console.log(`    ${d.reason.padEnd(28)} ${String(d.frequency).padStart(2)}x`);
      }
    }
  }

  // ATS vendor / channel analysis
  const va = result.vendorAnalysis;
  if (va && va.breakdown.length > 0) {
    console.log('\nATS CHANNEL ANALYSIS (community ATS only)');
    console.log('-'.repeat(40));
    console.log(`  vendor identified for ${va.identified}/${va.submitted} submissions (${va.coveragePct}% coverage); overall advance rate ${va.overallAdvanceRate}%`);
    for (const v of va.breakdown) {
      const flag = v.sufficientSample ? '' : '  (n too small for a claim)';
      console.log(`  ${v.vendor.padEnd(12)} ${String(v.total).padStart(3)} apps  ${String(v.sharePct).padStart(3)}% share  ${String(v.advanceRate).padStart(3)}% advance${flag}`);
    }
    console.log('  Channel yield, not discrimination — see Bommasani et al., FAccT 2026.');
  }

  // Via channel analysis (#1596): which recruiter relationships convert
  const via = result.viaChannelAnalysis;
  if (via && (via.breakdown.length > 0 || via.directSubmitted > 0)) {
    console.log('\nVIA CHANNEL ANALYSIS (agency vs direct, #1596)');
    console.log('-'.repeat(40));
    // A channel with no submissions has no rate; "—" keeps the column aligned.
    const pct = (r) => (r == null ? '   —' : `${String(r).padStart(3)}%`);
    console.log(`  direct  ${String(via.directSubmitted).padStart(3)} apps  ${pct(via.directAdvanceRate)} advance`);
    console.log(`  agency  ${String(via.agencySubmitted).padStart(3)} apps  ${pct(via.agencyAdvanceRate)} advance`);
    if (via.unknownVia > 0) {
      console.log(`  unknown ${String(via.unknownVia).padStart(3)} apps  (no Via recorded — not counted in either channel)`);
    }
    for (const a of via.breakdown) {
      const flag = a.sufficientSample ? '' : '  (n too small for a claim)';
      console.log(`    ${a.agency.padEnd(16)} ${String(a.total).padStart(3)} apps  ${String(a.advanceRate).padStart(3)}% advance${flag}`);
    }
  }

  // Score threshold
  // A threshold is printed only when one was actually earned (see
  // scoreThresholdFrom); a sufficient sample with nothing rejected below the
  // floor is still an observation.
  if (scoreThreshold.recommended !== null) {
    console.log(`\nSCORE THRESHOLD: ${scoreThreshold.recommended}/5`);
  } else {
    console.log(`\nSCORE OBSERVATION: lowest positive ${scoreThreshold.observedMinimum ?? 'n/a'}/5 (n=${scoreThreshold.sampleSize}; not a threshold yet)`);
  }
  console.log(`  ${scoreThreshold.reasoning}`);

  // Recommendations
  if (recommendations.length > 0) {
    console.log(`\nRECOMMENDATIONS`);
    console.log('='.repeat(60));
    for (let i = 0; i < recommendations.length; i++) {
      const r = recommendations[i];
      console.log(`  ${i + 1}. [${r.impact.toUpperCase()}] ${r.action}`);
      console.log(`     ${r.reasoning}`);
    }
  }

  console.log('');
}

// --- Run (CLI only; guarded so the module is safely importable for tests) ---
if (isMainModule(import.meta.url)) {
  validateFlags(args, KNOWN_FLAGS, USAGE, {
    valueFlags: VALUE_FLAGS,
    requireOperand: true,
  });

  const rawMinThreshold = flagValue(args, '--min-threshold');
  const rawMinVendorN = flagValue(args, '--min-vendor-n');

  if (hasFlag(args, '--min-threshold')) {
    if (rawMinThreshold === undefined || !/^\d+$/.test(String(rawMinThreshold)) ||
        !Number.isSafeInteger(Number(rawMinThreshold))) {
      console.error(`Error: --min-threshold requires a non-negative integer, got "${rawMinThreshold ?? ''}"`);
      process.exit(1);
    }
    MIN_THRESHOLD = Number(rawMinThreshold);
  }

  if (hasFlag(args, '--min-family-n')) {
    const rawMinFamilyN = flagValue(args, '--min-family-n');
    if (rawMinFamilyN === undefined || !/^\d+$/.test(String(rawMinFamilyN)) ||
        !Number.isSafeInteger(Number(rawMinFamilyN)) || Number(rawMinFamilyN) < 1) {
      console.error(`Error: --min-family-n requires a positive integer, got "${rawMinFamilyN ?? ''}"`);
      process.exit(1);
    }
    MIN_FAMILY_N = Number(rawMinFamilyN);
  }

  if (hasFlag(args, '--min-vendor-n')) {
    if (rawMinVendorN === undefined || !/^\d+$/.test(String(rawMinVendorN)) ||
        !Number.isSafeInteger(Number(rawMinVendorN)) || Number(rawMinVendorN) < 1) {
      console.error(`Error: --min-vendor-n requires a positive integer, got "${rawMinVendorN ?? ''}"`);
      process.exit(1);
    }
    MIN_VENDOR_N = Number(rawMinVendorN);
  }

  if (args.includes('--self-test')) {
    runSelfTest();
  }

  const result = analyze();

  if (summaryMode) {
    printSummary(result);
  } else {
    console.log(JSON.stringify(result, null, 2));
  }

  // "No applications found" is the state of a NEW USER, not a failure. Every
  // other analysis script over the same tracker — stats, upskill, salary-gap,
  // process-quality, rejection-latency, detect-reposts, company-history,
  // calibrate, funnel-velocity, tracker-sync-check — reports it and exits 0.
  // This one exited 1, which breaks `&&` chaining and makes the batch runners
  // treat an empty tracker as a broken command.
  //
  // Still non-zero for a genuine failure: the check is on the KIND of error, so
  // a future `result.error` that is not "no data" keeps its exit 1. Written as
  // an allowlist of no-data codes rather than a message match, so the exit
  // status does not depend on prose.
  if (result.error && !result.noData) process.exit(1);
}