#!/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 # minimum submitted applications required (default: 5) node analyze-patterns.mjs --min-vendor-n # minimum sample per vendor/channel (default: 8) node analyze-patterns.mjs --min-family-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'); // (? 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'; // (? 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} 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); }