analyze-patterns.mjs
Rama main · réplica de lectura del repositorio original.
#!/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);
}