#!/usr/bin/env node /** * company-history.mjs — Per-Company Evidence-Card Aggregator for career-ops * * READ-ONLY. Never writes a file. Joins the tracker (data/applications.md), * follow-ups (data/follow-ups.md), and scan-history (data/scan-history.tsv) * per company, and renders an evidence card per company covering two * independent axes: * * - responsiveness: has THIS company ever responded to you, or gone silent * on an Applied row past the silence window? A rejection counts as a * response — it is an answer, not silence. * - postingChurn: does this company repost the same role repeatedly * (evergreen requisition / re-opened search), per detect-reposts.mjs? * "The same role, later" is the whole claim: a company running several * openings side by side — one per city, country, language or segment — is * not reposting, and detect-reposts.mjs enforces that with a minimum span * and a title-identity rule. Before it did, this axis reported * `reposts-detected` for companies that had never reposted anything. * * This script deliberately reports FACTS, not verdicts. It never uses the * words "ghost"/"ghosted" or "risk" — high-volume inboxes, evergreen * requisitions, re-opened searches, and the candidate's own unlogged * responses all produce the same raw signals as genuine silence, so the * card lists evidence and lets the human judge it. * * Sources (each optional; a missing file degrades gracefully, never crashes): * - tracker: resolveTrackerPath() -> data/applications.md * - follow-ups: data/follow-ups.md * - scan-history: data/scan-history.tsv (-> detectReposts clusters) * - status-log: ./funnel-velocity.mjs, loaded ONLY via a dynamic * `await import(...)` in try/catch. The module ships on * main, but the optional applied-date/median helpers this * hook probes for are not part of its export surface, so * the statusLog source still reports false. The try-path is * written defensively so it degrades the same way whether * the module is absent OR present-but-shaped differently * than expected: every extraction from it is guarded by * `typeof x === 'function'`. * * Run: node company-history.mjs (JSON to stdout) * node company-history.mjs --summary (human-readable cards) * node company-history.mjs --company "Acme" (single-card lookup) * node company-history.mjs --silence-window 21 (override default window) * node company-history.mjs --include-stale (include >365d-old facts in labels) * node company-history.mjs --self-test */ import { readFileSync, existsSync, writeFileSync, mkdtempSync, rmSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { createHash } from 'crypto'; import { tmpdir } from 'os'; import { loadYaml } from './lib/yaml-document.mjs'; import { parseScanHistory, detectReposts, loadAggregatorCompanies, isKeyLookup } from './detect-reposts.mjs'; import { normalizeCompanyName } from './invite-match.mjs'; import { normalizeCompany, resolveTrackerPath } from './tracker-utils.mjs'; import { resolveColumns, parseTrackerRow } from './tracker-parse.mjs'; import { localToday } from './lib/local-today.mjs'; import { validateFlags, flagValue } from './lib/cli-flags.mjs'; import { parseFollowups, parseAppliedDate, parseDate, daysBetween, addDays, normalizeStatus, } from './followup-cadence.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; import { getCareerOpsRoot, getProfilePath } from './path-resolver.mjs'; const CAREER_OPS = dirname(fileURLToPath(import.meta.url)); const DATA_ROOT = getCareerOpsRoot(); const PROFILE_FILE = getProfilePath(); const PACKAGE_JSON = join(CAREER_OPS, 'package.json'); const DEFAULT_STALE_AFTER_DAYS = 365; const DEFAULT_SILENCE_WINDOW_DAYS = 28; // Statuses that count as "the company answered" — a rejection IS an answer, and // a hire is the strongest answer of all (omitting it once labelled a company // that hired you 'no-history', or 'silent-on-you' against other quiet rows). const RESPONDED_STATUSES = new Set(['responded', 'assessment', 'interview', 'offer', 'hired', 'rejected']); const OUTCOME_LABELS = { responded: 'Responded', assessment: 'Assessment', interview: 'Interview', offer: 'Offer', hired: 'Hired', rejected: 'Rejected' }; const EXPLANATION_LINE = 'high-volume inboxes, evergreen requisitions, re-opened searches, and your own unlogged responses ' + 'all produce these patterns — facts, not verdicts'; // Why the churn axis is empty here. Without this the card would look like a // company that was checked and came back clean, which is a different claim. const AGGREGATOR_LINE = 'marked `aggregator: true` in portals.yml — a multi-employer board, so the same title is a ' + 'different employer\'s job; posting churn was not evaluated for this company, not found absent'; // Locale-independent company ordering. A bare String.localeCompare() sorts by // the host's default locale, so emitted ordering could differ between machines; // pin the collator to 'en' so the output is byte-for-byte deterministic // everywhere. Reused by both the JSON card ordering and the summary tiebreaker. const companyCollator = new Intl.Collator('en'); const compareCompany = (a, b) => companyCollator.compare(String(a), String(b)); // --- CLI args --- const KNOWN_FLAGS = ['--summary', '--self-test', '--company', '--silence-window', '--include-stale', '--scan-history', '--followups', '--emit-signal', '--help', '-h']; const VALUE_FLAGS = ['--company', '--silence-window', '--scan-history', '--followups']; const USAGE = `Usage: node company-history.mjs # full JSON evidence cards to stdout node company-history.mjs --summary # human-readable cards node company-history.mjs --company "Acme" # single-card lookup node company-history.mjs --silence-window 21 # override the default silence window (days) node company-history.mjs --include-stale # include facts older than 365d in label computation node company-history.mjs --self-test # run the in-memory test suite node company-history.mjs --summary --emit-signal # human-readable cards, then RFC #1506 schema-v1 no-response-friction # records as JSON Lines (one record per line) on stdout. --emit-signal # REQUIRES --summary: without it, default/--company mode already prints # a single parsable JSON document, and appending JSON Lines after it # would make stdout un-parsable as one JSON value. --summary's output is # not JSON to begin with, so appending JSON Lines after it stays clean — # bare --emit-signal (no --summary) exits 1 with this same explanation. node company-history.mjs --help # print this usage block and exit`; function parseArgs(argv) { const args = argv.slice(2); // Unknown flags (and a missing operand) are rejected BEFORE --help is // answered, so `--help --bogus` still fails. See lib/cli-flags.mjs. validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true }); // A present-but-empty operand (`--company ""`, `--company=`) would filter to // a company that does not exist; the shared guard only covers a missing one. for (let idx = 0; idx < args.length; idx += 1) { const flag = args[idx]; if (VALUE_FLAGS.includes(flag) && args[idx + 1] === '') { console.error(`Error: ${flag} expects a value.`); console.error(USAGE); process.exit(1); } if (VALUE_FLAGS.some(valueFlag => flag === `${valueFlag}=`)) { console.error(`Error: ${flag.slice(0, -1)} expects a non-empty value.`); console.error(USAGE); process.exit(1); } } // --silence-window must be a positive integer, validated before anything // runs: a NaN silently falling back to the default hides the user's typo, // and a zero/negative window would label every application silent. const silenceWindowArg = flagValue(args, '--silence-window'); if (silenceWindowArg !== undefined && !/^[1-9]\d*$/.test(silenceWindowArg)) { console.error(`Error: --silence-window expects a positive integer number of days, got "${silenceWindowArg}"`); console.error(USAGE); process.exit(1); } const summaryMode = args.includes('--summary'); const company = flagValue(args, '--company'); const emitSignal = args.includes('--emit-signal'); // --emit-signal REQUIRES --summary (and is incompatible with --company). // Default mode and --company mode both print a single parsable JSON // document on stdout; appending JSON Lines signal records after that // document would make stdout un-parsable as one JSON value. --summary's // output is human-readable text, not JSON, so appending JSON Lines after it // is the one combination that stays clean — fail fast here rather than // silently corrupting a piped consumer's parse. `--company` wins over // `--summary` in the output-selection branch below (see the CLI run body), // so `--company --summary --emit-signal` would otherwise still print a bare // JSON object followed by JSON Lines; reject that combination too. if (emitSignal && (!summaryMode || company)) { console.error('Error: --emit-signal requires --summary and cannot be combined with --company (default and --company mode already print a single JSON document; appending JSON Lines after it would break JSON parsing of stdout). Use: node company-history.mjs --summary --emit-signal'); console.error(USAGE); process.exit(1); } return { summaryMode, selfTestMode: args.includes('--self-test'), company, silenceWindowArg, includeStale: args.includes('--include-stale'), scanHistoryOverride: flagValue(args, '--scan-history'), followupsOverride: flagValue(args, '--followups'), emitSignal, }; } // --- Default silence-window resolution --- // // templates/benchmarks.yml ships on main and a user install may also carry it // (days_first_response.range_days[1] * 2). Try it, fall back to the hardcoded // default. A missing file or any parse failure degrades silently to the default // — this is a nice-to-have default source, never a hard dependency. export function resolveDefaultSilenceWindow(rootDir = CAREER_OPS) { try { const path = join(rootDir, 'templates/benchmarks.yml'); if (!existsSync(path)) return DEFAULT_SILENCE_WINDOW_DAYS; const doc = loadYaml(readFileSync(path, 'utf-8')); const range = doc?.days_first_response?.range_days; const upper = Array.isArray(range) ? range[1] : undefined; if (typeof upper === 'number' && Number.isFinite(upper) && upper > 0) return upper * 2; return DEFAULT_SILENCE_WINDOW_DAYS; } catch { return DEFAULT_SILENCE_WINDOW_DAYS; } } // --- today() — injectable for deterministic tests --- export function today() { // LOCAL calendar day, UTC midnight anchor. toISOString() gives the UTC DAY, // so west of Greenwich an evening run answered "today" with tomorrow and // every age in the report came out one day high (#3070). Only WHICH day // this is moves; parseDate() still anchors at UTC midnight, so the // arithmetic downstream is unchanged. return new Date(localToday()); } function resolveNow(now) { if (now instanceof Date) return now; if (typeof now === 'string') return parseDate(now) || today(); return today(); } // --- Source loaders (each returns {rows|clusters, loaded}; missing file -> empty + loaded:false) --- export function loadTrackerRows(rootDir = DATA_ROOT) { const path = resolveTrackerPath(rootDir); if (!existsSync(path)) return { rows: [], loaded: false }; const content = readFileSync(path, 'utf-8'); const lines = content.split('\n'); const colmap = resolveColumns(lines); const rows = []; for (const line of lines) { const row = parseTrackerRow(line, colmap); if (row) rows.push(row); } return { rows, loaded: true }; } export function loadFollowupRows(rootDir = DATA_ROOT, overridePath) { const path = overridePath || join(rootDir, 'data/follow-ups.md'); if (!existsSync(path)) return { rows: [], loaded: false }; return { rows: parseFollowups(readFileSync(path, 'utf-8')), loaded: true }; } // Returns the clusters plus the aggregator key Set, because the card needs both: // the clusters to report, and the Set to explain why a given company has none. // Without the Set an aggregator would render as `none-detected`, which claims a // negative result from a check that never ran. export function loadRepostClusters(rootDir = DATA_ROOT, overridePath, portalsPath) { const path = overridePath || join(rootDir, 'data/scan-history.tsv'); // Same override scan.mjs and detect-reposts.mjs honour, so a sandboxed run // points all three at one config instead of silently reading the real one. const aggregators = loadAggregatorCompanies( portalsPath || process.env.CAREER_OPS_PORTALS || join(rootDir, 'portals.yml'), ); if (!existsSync(path)) return { clusters: [], aggregators, loaded: false }; const rows = parseScanHistory(readFileSync(path, 'utf-8')); return { clusters: detectReposts(rows, undefined, undefined, aggregators), aggregators, loaded: true }; } // Dynamic-only dependency: funnel-velocity.mjs ships on main, but the // applied-date/median helpers probed below are not part of its export surface. // Loaded exclusively through a try/catch'd dynamic import so a missing (or // differently-shaped) module never crashes this script. Every extraction // below is guarded with typeof checks for the same reason — the module may be // absent in a bare install, or present but without these optional helpers. export async function loadStatusLogSource() { let mod = null; try { mod = await import('./funnel-velocity.mjs'); } catch { return { loaded: false, appliedDateByNum: new Map(), medianResponseDays: null }; } if (!mod || typeof mod !== 'object') { return { loaded: false, appliedDateByNum: new Map(), medianResponseDays: null }; } // `loaded` reports whether this source can actually PRODUCE data, not merely // whether the import resolved. The merged funnel-velocity.mjs exports neither // optional helper, so when no usable helper is present the source is inert and // must report absent — otherwise callers treat empty enrichment as loaded. const hasAppliedHelper = typeof mod.getAppliedDateObservations === 'function'; const hasMedianHelper = typeof mod.computeMedianResponseDays === 'function'; if (!hasAppliedHelper && !hasMedianHelper) { return { loaded: false, appliedDateByNum: new Map(), medianResponseDays: null }; } const appliedDateByNum = new Map(); try { if (hasAppliedHelper) { const observations = mod.getAppliedDateObservations(); if (Array.isArray(observations)) { for (const obs of observations) { if (obs && Number.isFinite(obs.num) && obs.appliedDate) { appliedDateByNum.set(obs.num, obs.appliedDate); } } } } } catch { // Defensive: an unexpected shape must not crash aggregation. } let medianResponseDays = null; try { if (hasMedianHelper) { const value = mod.computeMedianResponseDays(); if (typeof value === 'number' && Number.isFinite(value)) medianResponseDays = value; } } catch { // Defensive: an unexpected shape must not crash aggregation. } return { loaded: true, appliedDateByNum, medianResponseDays }; } // --- Follow-up counts joined by appNum --- export function buildFollowupCountsByAppNum(followupRows) { const counts = new Map(); for (const fu of Array.isArray(followupRows) ? followupRows : []) { if (!fu || !Number.isFinite(fu.appNum)) continue; counts.set(fu.appNum, (counts.get(fu.appNum) || 0) + 1); } return counts; } // --- Applied-date resolution (priority: status-log -> notes -> tracker date column) --- export function resolveAppliedDate(row, statusLogAppliedByNum) { const fromLog = statusLogAppliedByNum instanceof Map ? statusLogAppliedByNum.get(row.num) : undefined; // Only let a status-log date take precedence when it actually parses — // otherwise one malformed optional observation would erase valid // notes/tracker evidence (computeResponsiveness skips unparsable dates). if (fromLog && parseDate(fromLog)) return { dateStr: fromLog, dateBasis: 'status-log' }; const fromNotes = parseAppliedDate(row.notes); if (fromNotes) return { dateStr: fromNotes, dateBasis: 'notes' }; return { dateStr: row.date, dateBasis: 'evaluation-date' }; } // --- Responsiveness axis (pure) --- // // rows: tracker rows already scoped to one company. // followupCountsByAppNum: Map (global — join key is appNum, not company). // opts: { now, silenceWindowDays, staleAfterDays, includeStale, statusLogAppliedByNum } export function computeResponsiveness(rows, followupCountsByAppNum, opts = {}) { const now = resolveNow(opts.now); const silenceWindowDays = Number.isFinite(opts.silenceWindowDays) ? opts.silenceWindowDays : DEFAULT_SILENCE_WINDOW_DAYS; const staleAfterDays = Number.isFinite(opts.staleAfterDays) ? opts.staleAfterDays : DEFAULT_STALE_AFTER_DAYS; const includeStale = !!opts.includeStale; const counts = followupCountsByAppNum instanceof Map ? followupCountsByAppNum : new Map(); const statusLogAppliedByNum = opts.statusLogAppliedByNum instanceof Map ? opts.statusLogAppliedByNum : null; const facts = []; for (const row of Array.isArray(rows) ? rows : []) { if (!row || typeof row !== 'object') continue; const normalized = normalizeStatus(String(row.status || '')); if (normalized === 'applied') { const { dateStr, dateBasis } = resolveAppliedDate(row, statusLogAppliedByNum); const appliedDate = parseDate(dateStr); if (!appliedDate) continue; // unusable date — no fact, no crash const silentDays = daysBetween(appliedDate, now); if (silentDays < silenceWindowDays) continue; // right-censored: pending, not silent const followupsSent = counts.get(row.num) || 0; facts.push({ num: row.num, appliedDate: dateStr, status: row.status, silentDays, followupsSent, confidence: followupsSent >= 1 ? 'confirmed-by-followups' : 'unconfirmed', stale: silentDays > staleAfterDays, // Carried through so buildNoResponseFrictionSignals() can derive // postingChannel (#3010) from the anchor fact without a second join // back to the tracker row — row.via is the tracker's own tagged // via={Agency} field (AGENTS.md's "Optional Via field", #1596), or // its em-dash "confirmed direct" convention. Never fabricated here: // absent/undefined stays null, exactly like resolveRegion()'s null // for "no data", not a guessed value. via: row.via ?? null, dateBasis, // Real set-status.mjs syntax (it rejects unknown flags, so the // instruction must only use flags that exist): --on records the real // response date in the status ledger — the file funnel-velocity.mjs // reads dates back from. A *response* date buried in --note free text // is parsed by nothing. (Applied dates are a different story: this same // file falls back to parseAppliedDate(row.notes) forty lines up, which // is why the qualifier matters — dropping it reads as "dates in notes // are dead", and that fallback is load-bearing.) clearInstruction: `if they actually responded, node set-status.mjs --row ${row.num} --on clears this`, }); } else if (RESPONDED_STATUSES.has(normalized)) { // row.date is the EVALUATION date, not the date the company replied. Use // it only to age the fact for staleness, and expose it through the same // dateBasis convention the silent branch uses — never as a claimed // response/contact date. const evalDate = parseDate(row.date) ? row.date : undefined; const ageBasisDate = evalDate ? parseDate(evalDate) : null; const fact = { num: row.num, outcome: OUTCOME_LABELS[normalized] || row.status, stale: ageBasisDate ? daysBetween(ageBasisDate, now) > staleAfterDays : false, }; if (evalDate) { fact.date = evalDate; fact.dateBasis = 'evaluation-date'; } if (normalized === 'rejected') fact.note = 'a rejection is an answer'; facts.push(fact); } } facts.sort((a, b) => a.num - b.num); const isSilent = f => 'silentDays' in f; const isResponded = f => 'outcome' in f; const activeFacts = includeStale ? facts : facts.filter(f => !f.stale); const hasSilent = activeFacts.some(isSilent); const hasResponded = activeFacts.some(isResponded); let label; if (hasSilent && hasResponded) label = 'mixed'; else if (hasSilent) label = 'silent-on-you'; else if (hasResponded) label = 'responded-before'; else label = 'no-history'; return { label, facts, medianResponseDays: Number.isFinite(opts.medianResponseDays) ? opts.medianResponseDays : null, }; } // --- Posting-churn axis (pure) --- // // `isAggregator` gets its own label rather than folding into `none-detected`. // The distinction already exists in this enum — `no-scan-data` means the check // could not run, `none-detected` means it ran and found nothing — and an // aggregator belongs on the first side: the detector deliberately skipped it, // so reporting "no reposts" would assert a negative result from a check that // never happened. That is the same conflation of silence with evidence this // whole axis was fixed for (#2703). export function computePostingChurn(clusters, scanHistoryLoaded, isAggregator = false) { if (!scanHistoryLoaded) return { label: 'no-scan-data', clusters: [] }; if (isAggregator) return { label: 'aggregator-not-evaluated', clusters: [] }; const mapped = (Array.isArray(clusters) ? clusters : []).map(c => ({ role: c.role, repostCount: c.repostCount, daysSpan: c.daysSpan, lastSeen: c.lastSeen, })); return { label: mapped.length > 0 ? 'reposts-detected' : 'none-detected', clusters: mapped }; } // --- Full aggregation (pure — takes already-loaded sources) --- // // sources: { // trackerRows, followupRows, repostClusters, // sourcesLoaded: { tracker, followups, scanHistory, statusLog }, // statusLogAppliedByNum, medianResponseDays, // } // opts: { now, silenceWindowDays, staleAfterDays, includeStale } export function buildCompanyCards(sources, opts = {}) { const trackerRows = Array.isArray(sources.trackerRows) ? sources.trackerRows : []; const followupRows = Array.isArray(sources.followupRows) ? sources.followupRows : []; const repostClusters = Array.isArray(sources.repostClusters) ? sources.repostClusters : []; const sourcesLoaded = sources.sourcesLoaded || { tracker: false, followups: false, scanHistory: false, statusLog: false }; const silenceWindowDays = Number.isFinite(opts.silenceWindowDays) ? opts.silenceWindowDays : DEFAULT_SILENCE_WINDOW_DAYS; const staleAfterDays = Number.isFinite(opts.staleAfterDays) ? opts.staleAfterDays : DEFAULT_STALE_AFTER_DAYS; let unjoinable = 0; // Group tracker rows by normalized company key. const trackerByKey = new Map(); for (const row of trackerRows) { const key = normalizeCompany(String(row?.company || '')); if (!key) { unjoinable += 1; continue; } if (!trackerByKey.has(key)) trackerByKey.set(key, { company: row.company, rows: [] }); trackerByKey.get(key).rows.push(row); } // Follow-ups without a resolvable company key still count towards // dataQuality (they contribute to the join universe even though counts are // joined by appNum, not company). for (const fu of followupRows) { const key = normalizeCompany(String(fu?.company || '')); if (!key) unjoinable += 1; } // Group repost clusters by normalized company key (re-key raw cluster strings). const clustersByKey = new Map(); for (const cluster of repostClusters) { const key = normalizeCompany(String(cluster?.company || '')); if (!key) { unjoinable += 1; continue; } if (!clustersByKey.has(key)) clustersByKey.set(key, { company: cluster.company, clusters: [] }); clustersByKey.get(key).clusters.push(cluster); } const followupCountsByAppNum = buildFollowupCountsByAppNum(followupRows); const keys = new Set([...trackerByKey.keys(), ...clustersByKey.keys()]); // A flagged aggregator normally has neither tracker rows nor clusters — the // detector skipped it, and you rarely apply to a board — so it would fall out // of the union above and vanish from --summary entirely. That is the exact // failure the aggregator-not-evaluated label exists to prevent: a company // that is missing from the report reads as "nothing to say about it", which // is indistinguishable from a clean result. Worse, it is a REGRESSION — // before the flag the company appeared, with (wrong) clusters. // // So seed the key set from the flagged companies too, keyed the way the cards // are keyed (normalizeCompany), while membership is still tested with the // function that built the lookup (see isAggregatorCompany). const aggregatorNameByCardKey = new Map(); if (sources.aggregators instanceof Map) { for (const rawName of sources.aggregators.values()) { const cardKey = normalizeCompany(String(rawName || '')); if (!cardKey) continue; aggregatorNameByCardKey.set(cardKey, rawName); keys.add(cardKey); } } const cards = []; const hygieneAgedApplied = []; for (const key of keys) { const trackerGroup = trackerByKey.get(key); const clusterGroup = clustersByKey.get(key); const companyName = trackerGroup?.company || clusterGroup?.company || aggregatorNameByCardKey.get(key) || key; const responsiveness = computeResponsiveness(trackerGroup?.rows || [], followupCountsByAppNum, { now: opts.now, silenceWindowDays, staleAfterDays, includeStale: opts.includeStale, statusLogAppliedByNum: sources.statusLogAppliedByNum, medianResponseDays: sources.medianResponseDays, }); const isAggregator = isAggregatorCompany(companyName, sources.aggregators); const postingChurn = computePostingChurn(clusterGroup?.clusters || [], sourcesLoaded.scanHistory, isAggregator); const hasAnySilent = responsiveness.facts.some(f => 'silentDays' in f); const explanations = hasAnySilent ? [EXPLANATION_LINE] : []; if (isAggregator && sourcesLoaded.scanHistory) explanations.push(AGGREGATOR_LINE); for (const f of responsiveness.facts) { if ('silentDays' in f) { hygieneAgedApplied.push({ num: f.num, company: companyName, silentDays: f.silentDays }); } } cards.push({ company: companyName, key, responsiveness, postingChurn, explanations }); } cards.sort((a, b) => compareCompany(a.company, b.company)); hygieneAgedApplied.sort((a, b) => b.silentDays - a.silentDays); return { metadata: { silenceWindowDays, staleAfterDays, companies: cards.length, // Surfaced so "no aggregators configured" is distinguishable from // "configured and applied" — a flag that matches nothing otherwise looks // exactly like a working one. aggregatorCompanies: isKeyLookup(sources.aggregators) ? sources.aggregators.size : 0, sources: sourcesLoaded, }, hygiene: { agedApplied: hygieneAgedApplied }, companies: cards, // Carried on the result so getCompanyCard can answer for a company that has // no card at all (an aggregator never applied to). aggregators: isKeyLookup(sources.aggregators) ? sources.aggregators : new Map(), dataQuality: { unjoinable }, }; } // Is this company one the user marked `aggregator: true`? // // The Set is keyed with normalizeCompanyName (invite-match), while the cards are // keyed with normalizeCompany (tracker-utils). Those are DIFFERENT functions and // they disagree on most real names — "joinup.ch" becomes "joinupch" under one // and "joinup ch" under the other. Testing a card key against this Set would // therefore match almost nothing, silently: the detector would skip the company // correctly while the card still claimed `none-detected`. So re-derive the key // from the raw display name with the SAME function that built the Set. export function isAggregatorCompany(companyName, aggregators) { if (!isKeyLookup(aggregators) || aggregators.size === 0) return false; const raw = String(companyName || '').trim(); if (!raw) return false; return aggregators.has(normalizeCompanyName(raw) || raw.toLowerCase()); } // --- Single-company lookup --- export function getCompanyCard(result, companyName, aggregators) { const key = normalizeCompany(String(companyName || '')); const found = result.companies.find(c => c.key === key); if (found) return found; // An aggregator the user never applied to has no tracker rows and no // clusters, so it lands here. It must still not claim `none-detected` — the // check was skipped for it, exactly as on the full card. const scanHistoryLoaded = !!result.metadata?.sources?.scanHistory; const isAggregator = isAggregatorCompany(companyName, aggregators ?? result.aggregators); const churnLabel = !scanHistoryLoaded ? 'no-scan-data' : (isAggregator ? 'aggregator-not-evaluated' : 'none-detected'); return { company: companyName, key, responsiveness: { label: 'no-history', facts: [] }, postingChurn: { label: churnLabel, clusters: [] }, explanations: isAggregator && scanHistoryLoaded ? [AGGREGATOR_LINE] : [], }; } // --- no-response-friction signal emission (RFC #1506 schema v1, --emit-signal only) --- // // Opt-in, local-only. Reuses the `silent-on-you` responsiveness fact // company-history.mjs already computes above (computeResponsiveness()) rather // than deriving a second silence detector: the 28-day DEFAULT_SILENCE_WINDOW_DAYS // (or its --silence-window override) IS this signal's threshold. RFC #1506's // original discussion floated a 14-day baseline before this script's 28-day // window shipped in #1712; per #2787 we standardize on the one window this // script already has tested rather than adding a second, undocumented cutoff. // // Schema v1 (verbatim from RFC #1506, ratified 2026-07-16 — full 10-field // shape, per @Schlaflied's converged-schema comment that @santifer ratified): // { schemaVersion: 1, companyKey, region, signalType: 'no-response-friction', // detail: null, severity: 'single'|'pattern'|null, // sourceDetector: 'company-history', sourceHash: 'sha256:...', // observedAt: 'YYYY-MM', emittedBy: 'career-ops vX.Y.Z' } // // `sourceDetector` enum so far: 'interview-redflag' | 'process-friction' // (named in the RFC thread) | 'company-history' (this script — the third, // established here per artemtrofymenko's PR #2788 review). Originally set to // 'no-response-friction' (the signal name); renamed to 'company-history' per // a follow-up (weak-preference, non-blocking) suggestion from the same // reviewer: the existing precedent is interview-redflag.mjs, which emits 4 // different signal types under the RFC's enum but always tags // `sourceDetector: 'interview-redflag'` — the TOOL name, not any one signal // name. sourceDetector identifies the producing script, not the specific // signal type it happened to emit. This also future-proofs the value: // company-history.mjs already computes two independent axes // (responsiveness and postingChurn); only responsiveness feeds a signal // today (no-response-friction), but if postingChurn ever feeds a second // signal type later, a value scoped to this one signal would be wrong for // that record — 'company-history' stays correct regardless of how many // signal types this script eventually produces. Renaming an enum value // already present in emitted records is expensive, so this was worth fixing // before anything shipped with the old value. // `detail` is null here — this signal is derived purely from tracker dates, // with no free text to carry (unlike e.g. process-friction's // "interviewer no-show, no reschedule notice" example in the RFC). // // Privacy: enum/hash/date fields only — no candidate name, no free-text notes, // no verbatim quotes anywhere in an emitted record (same discipline as // interview-redflag / process-friction). // // ADDITIVE field, not part of the ratified v1 shape above: `postingChannel` // ('direct-employer' | 'staffing-agency' | 'unknown'), proposed in #3010 and // pending its own review — kept separate from the RFC-ratified 10 fields // because it has not itself been ratified. Per @strelov1's (freehire.me) // production measurement cited in #3010, a single-criterion structural // signal like this one systematically misfires on staffing/recruiting // agencies, which legitimately post client roles that never appear on their // own careers page — conflating "agency-mediated posting" with "shady // posting". No new detection: it wires in the tracker's own existing tagged // `via={Agency}` field (#1596) via resolvePostingChannel() below, the same // "never fabricate, degrade to unknown" discipline resolveRegion() already // uses for the `region` field above. // Best-effort country -> region-slug mapping for the RFC's `region` field // (e.g. "north-america/canada"). Deliberately small: covers the markets this // repo already has explicit support for (see modes// market sets) plus // a few common others. An unmapped or missing country degrades to a slug of // the raw country string (still useful for grouping) rather than crashing or // guessing a continent — 'unknown' when there is no country at all. const COUNTRY_REGION_MAP = { canada: 'north-america/canada', 'united states': 'north-america/united-states', 'united states of america': 'north-america/united-states', usa: 'north-america/united-states', us: 'north-america/united-states', mexico: 'north-america/mexico', 'united kingdom': 'europe/united-kingdom', uk: 'europe/united-kingdom', germany: 'europe/germany', austria: 'europe/austria', switzerland: 'europe/switzerland', france: 'europe/france', belgium: 'europe/belgium', luxembourg: 'europe/luxembourg', japan: 'asia/japan', india: 'asia/india', turkey: 'asia/turkey', china: 'asia/china', 'saudi arabia': 'middle-east/saudi-arabia', uae: 'middle-east/united-arab-emirates', 'united arab emirates': 'middle-east/united-arab-emirates', qatar: 'middle-east/qatar', australia: 'oceania/australia', 'new zealand': 'oceania/new-zealand', }; function slugify(value) { return String(value || '') .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } // Reads config/profile.yml -> location.country the same way followup-cadence.mjs // reads config/profile.yml (existsSync guard, try/catch parse, silent // degradation — a missing or unparsable profile must never crash signal // emission). Returns null when a region genuinely cannot be resolved (no // profile, unparsable profile, no country field) — per artemtrofymenko's // review on PR #2788, a literal 'unknown' string would imply a real // `unknown/` shared-layer directory if this schema were ever published, so // callers must treat null as "skip emission, don't fabricate a region" // rather than emitting a misleading placeholder value. A country that IS // present but unmapped in COUNTRY_REGION_MAP still resolves (to // `unmapped/`) — that is real information, not a placeholder. export function resolveRegion(profilePath = PROFILE_FILE) { if (!profilePath || !existsSync(profilePath)) return null; let raw; try { raw = loadYaml(readFileSync(profilePath, 'utf-8')) || {}; } catch { return null; } const country = raw?.location?.country; if (!country || typeof country !== 'string') return null; const mapped = COUNTRY_REGION_MAP[country.trim().toLowerCase()]; if (mapped) return mapped; const slug = slugify(country); return slug ? `unmapped/${slug}` : null; } // Distinguishes who actually posted the role — the direct employer, or a // third-party staffing/recruiting agency filling a client req — for the // additive `postingChannel` field (#3010; see the schema comment block // above). Reuses the tracker's own existing `via` convention (AGENTS.md's // "Optional Via field", #1596) rather than adding new detection: a non-empty // tagged value that isn't the em-dash sentinel means agency-mediated; the // tracker's own em-dash convention means confirmed-direct; and — matching // resolveRegion()'s "never fabricate, degrade to a real absence" discipline // — an undefined, null, or blank/whitespace-only `via` means no data was // ever recorded either way, so this returns 'unknown' rather than guessing // 'direct-employer'. `unknown` here is a real, RFC-sanctioned enum value // (unlike resolveRegion()'s rejected 'unknown' literal), so no null-vs-string // caller-side handling is needed the way region emission requires. export function resolvePostingChannel(via) { if (typeof via !== 'string') return 'unknown'; const trimmed = via.trim(); if (!trimmed) return 'unknown'; return trimmed === '—' ? 'direct-employer' : 'staffing-agency'; } // package.json version -> "career-ops vX.Y.Z". Missing/unparsable package.json // degrades to a version-less emittedBy rather than crashing. export function resolveEmittedBy(packagePath = PACKAGE_JSON) { try { const pkg = JSON.parse(readFileSync(packagePath, 'utf-8')); if (pkg && typeof pkg.version === 'string' && pkg.version) { return `career-ops v${pkg.version}`; } } catch { // fall through } return 'career-ops'; } // Deterministic (not random) so repeated runs against the same underlying // fact produce the same sourceHash — required for downstream dedup. The // hashed input is already fully reconstructable from the record's own // plaintext fields (companyKey/observedAt/signalType), so the hash adds no // additional linkable information beyond what those fields already expose; // it is opaque only in the sense that it cannot be reversed back into // anything MORE sensitive (raw applicant identity, notes text, etc.) than is // already in plaintext. // // `severity` is deliberately EXCLUDED from the hash (fixed per // artemtrofymenko's PR #2788 review, which caught this against a real // tracker): severity can legitimately change over time for the SAME // underlying company-month fact (e.g. a candidate applies once and goes // silent -> 'single'; applies again later and also goes silent -> 'pattern'). // Hashing severity in would make the same companyKey+observedAt fact produce // a different sourceHash depending on when the record was emitted, so a // downstream dedup pool couldn't tell two emissions describe the same // evolving fact — both would coexist with contradictory severity instead of // the later one superseding the earlier. Keying the hash on // signalType|companyKey|observedAt only (what is being described, not values // that can accumulate) makes companyKey|observedAt the stable identity. export function computeSourceHash({ companyKey, observedAt }) { const raw = `no-response-friction|${companyKey}|${observedAt}`; return `sha256:${createHash('sha256').update(raw).digest('hex')}`; } // One silent fact -> its observedAt month. Anchored on fact.appliedDate's own // month directly — deliberately NOT on appliedDate + silenceWindowDays. The // window is configurable (--silence-window, or resolveDefaultSilenceWindow()) // so a window-derived observedAt would shift month (and therefore shift // sourceHash, see computeSourceHash above) for the SAME underlying silent // fact depending on what window happened to be in effect on a given run — // which breaks the dedup that sourceHash exists to support. appliedDate never // changes for a given fact, so anchoring on it keeps the hash stable. function silentFactObservedAt(fact) { const appliedDate = parseDate(fact.appliedDate); // parseDate() tolerates surrounding whitespace when validating, but the // schema's observedAt (and sourceHash, which is computed over it) must be // a clean YYYY-MM slice — so trim before slicing, not just before // validating, or a whitespace-padded appliedDate (e.g. ' 2026-06-01 ') // would slice out the leading space plus wrong characters instead of the // real year-month. const source = appliedDate ? fact.appliedDate.trim() : null; return typeof source === 'string' && source.length >= 7 ? source.slice(0, 7) : null; } // Builds schema-v1 no-response-friction records for every `silent-on-you` // company card. Only `silent-on-you` cards ever produce a record — 'mixed' // (silent on some, responded on others), 'responded-before', and 'no-history' // cards never do, since the point of this signal is companies that have // NEVER answered this candidate. // // severity: 'single' for exactly one silent fact on the card, 'pattern' for // 2+ (the candidate applied more than once and went unanswered more than // once at the same company). // // opts.includeStale mirrors the CLI's --include-stale flag: pass true to // count stale (>365d, or --staleAfterDays override) silent facts towards // severity, matching whatever includeStale value the caller used to compute // `result`'s labels. Left false (the default), a stale silent fact never // contributes to severity, keeping severity consistent with the label it // rode in on. // // Returns { records, warnings } (matching this repo's established // discover-ats.mjs / check-table-freshness.mjs shape), not a bare array. // region is resolved once per run (it is a property of the candidate's own // profile, not of any individual company) — when it cannot be resolved, // per artemtrofymenko's PR #2788 review, this "says nothing" rather than // emitting a misleading 'unknown' literal: no records are emitted for this // run and a warning explains why, instead of hard-erroring the whole // command over one unresolvable field. export function buildNoResponseFrictionSignals(result, opts = {}) { const region = opts.region ?? resolveRegion(opts.profilePath); const emittedBy = opts.emittedBy ?? resolveEmittedBy(opts.packagePath); const includeStale = !!opts.includeStale; const records = []; const warnings = []; for (const card of Array.isArray(result?.companies) ? result.companies : []) { if (card?.responsiveness?.label !== 'silent-on-you') continue; // region is a property of the candidate's own profile, so it is the // same (or the same absence) for every company in a given run — but the // skip is expressed per-company (rather than as one early return for the // whole call) so the warning names exactly which company's signal was // dropped, matching the shape a future per-company region override could // reuse without a rewrite here. if (!region) { warnings.push(`region could not be resolved from config/profile.yml — signal not emitted for ${card.key}.`); continue; } // Mirror computeResponsiveness()'s own stale filter: the label was // computed from activeFacts (stale facts excluded unless --include-stale), // but card.responsiveness.facts still carries every fact, stale or not. // Without this filter a card with one active + one stale silent fact // would report severity 'pattern' even though only one fact actually // contributed to the 'silent-on-you' label. const activeFacts = includeStale ? card.responsiveness.facts : card.responsiveness.facts.filter(f => !f.stale); // A fact with an unusable appliedDate must be excluded before anchor // selection and severity counting, not just skipped later when computing // observedAt from the already-chosen anchor: left in, a malformed date // could win the lexicographic reduce below (producing no usable // observedAt at all) or, even when a valid fact wins the anchor, still // inflate severity to 'pattern' despite not actually contributing a // usable silent-application fact (CodeRabbit, PR #2788). const silentFacts = activeFacts.filter(f => 'silentDays' in f && silentFactObservedAt(f) !== null); if (silentFacts.length === 0) continue; // defensive; label implies >=1 // Most-recent APPLICATION anchors observedAt — compare appliedDate, not // tracker row num. num is just row-insertion order; a backfilled or // out-of-order row can carry a larger num with an OLDER appliedDate, which // would silently anchor on a stale application despite the "most-recent" // intent. ISO date strings sort correctly with plain string comparison — // but only once trimmed: a whitespace-padded value (e.g. ' 2026-06-01 ') // compares incorrectly against an untrimmed neighbor, which would select // the wrong anchor and emit a wrong observedAt/sourceHash (CodeRabbit, // PR #2788). const anchorFact = silentFacts.reduce((a, b) => ( String(b.appliedDate).trim() > String(a.appliedDate).trim() ? b : a )); const observedAt = silentFactObservedAt(anchorFact); if (!observedAt) continue; // unusable date — no record, no crash const severity = silentFacts.length >= 2 ? 'pattern' : 'single'; // postingChannel rides on the same anchor fact as observedAt — it is a // property of that specific application (the tracker row's own `via`), // not of the company card as a whole, so it must not be recomputed from // a different fact than the one that decided observedAt/sourceHash. const postingChannel = resolvePostingChannel(anchorFact.via); records.push({ schemaVersion: 1, companyKey: card.key, region, signalType: 'no-response-friction', detail: null, severity, sourceDetector: 'company-history', sourceHash: computeSourceHash({ companyKey: card.key, observedAt }), observedAt, emittedBy, postingChannel, }); } records.sort((a, b) => compareCompany(a.companyKey, b.companyKey)); return { records, warnings }; } // --- Summary rendering (pure) --- const LABEL_ORDER = { 'silent-on-you': 0, mixed: 1, 'responded-before': 2, 'no-history': 3 }; export function renderSummary(result) { const lines = []; lines.push(''); lines.push('='.repeat(78)); lines.push(' Company History — career-ops'); lines.push(` companies: ${result.companies.length} | silence window: ${result.metadata.silenceWindowDays}d`); lines.push('='.repeat(78)); lines.push(''); const aged = result.hygiene?.agedApplied || []; if (aged.length > 0) { lines.push(` ${aged.length} aged-Applied row(s) look silent — confirm real or update (node set-status.mjs --row --on ).`); lines.push(''); } lines.push(' silence window: ' + result.metadata.silenceWindowDays + 'd — slow to reply is common; silence within the window is expected.'); lines.push(''); const sorted = [...result.companies].sort((a, b) => { const rank = (LABEL_ORDER[a.responsiveness.label] ?? 9) - (LABEL_ORDER[b.responsiveness.label] ?? 9); return rank !== 0 ? rank : compareCompany(a.company, b.company); }); if (sorted.length === 0) { lines.push(' No company evidence available (no tracker, follow-up, or scan-history data found).'); lines.push(''); } for (const card of sorted) { lines.push(` ${card.company} [${card.responsiveness.label}] — postings: ${card.postingChurn.label}`); for (const f of card.responsiveness.facts) { if ('silentDays' in f) { const staleTag = f.stale ? ' (stale)' : ''; lines.push(` #${f.num} silent ${f.silentDays}d since ${f.appliedDate} — ${f.followupsSent} follow-up(s), ${f.confidence}${staleTag}`); } else { const note = f.note ? ` (${f.note})` : ''; lines.push(` #${f.num} ${f.outcome}${f.date ? ` (evaluated ${f.date})` : ''}${note}`); } } for (const c of card.postingChurn.clusters) { lines.push(` repost: "${c.role}" seen ${c.repostCount}x over ${c.daysSpan}d, last ${c.lastSeen}`); } // Every explanation, not just the first: a company can be both silent on // you AND an aggregator, and printing only [0] would drop the second. for (const explanation of card.explanations) { lines.push(` note: ${explanation}`); } lines.push(''); } return lines.join('\n'); } // --- Self-test --- async function runSelfTest() { let pass = 0; let fail = 0; const check = (cond, label) => { if (cond) { pass += 1; } else { fail += 1; console.error(` FAIL: ${label}`); } }; const NOW = new Date('2026-07-09T00:00:00Z'); const row = (num, company, status, date, notes = '') => ({ num, date, company, role: 'Engineer', score: '4/5', status, pdf: '✅', report: `reports/${num}.md`, notes }); // --- join fixtures: case/punct variants meet under one key; only a genuinely // keyless company is unjoinable. A non-Latin name is NOT keyless (#2429): // normalizeCompany() folds script-preserving, so 株式会社 keys as itself and // gets a real card instead of being discarded as unparseable data. --- { const rows = [ row(1, 'Acme Inc.', 'Applied', '2026-01-01'), row(2, 'ACME, INC', 'Applied', '2026-01-02'), row(3, '?', 'Applied', '2026-01-03'), // punctuation only -> genuinely keyless ]; const result = buildCompanyCards( { trackerRows: rows, followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); check(result.companies.length === 1, 'case/punct company variants join under one key'); check(result.companies[0].responsiveness.facts.length === 2, 'both joined rows contribute facts to the single card'); check(result.dataQuality.unjoinable === 1, 'a punctuation-only company key is excluded and counted as unjoinable'); } // --- non-Latin companies are first-class, and distinct from each other (#2429) --- { const rows = [ row(4, 'アクメ株式会社', 'Applied', '2026-01-01'), row(5, 'グロベックス合同会社', 'Applied', '2026-01-02'), row(6, 'Яндекс', 'Applied', '2026-01-03'), ]; const result = buildCompanyCards( { trackerRows: rows, followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); check(result.companies.length === 3, 'three different non-Latin companies produce three cards, not one'); check(result.dataQuality.unjoinable === 0, 'a non-Latin company name is joinable, not a data-quality defect'); } // --- label goldens --- { // silent-on-you: one Applied row well past the window, nothing else. const silentOnly = buildCompanyCards( { trackerRows: [row(10, 'SilentCo', 'Applied', '2026-05-01')], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); check(silentOnly.companies[0].responsiveness.label === 'silent-on-you', 'silent-only fixture labels silent-on-you'); // responded-before: a Rejected row only. const respondedOnly = buildCompanyCards( { trackerRows: [row(11, 'RespondedCo', 'Rejected', '2026-06-01')], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); check(respondedOnly.companies[0].responsiveness.label === 'responded-before', 'responded-only fixture labels responded-before'); // mixed: one silent-old Applied row + one later Rejected row (different app). const mixed = buildCompanyCards( { trackerRows: [ row(12, 'MixedCo', 'Applied', '2026-05-01'), row(13, 'MixedCo', 'Rejected', '2026-06-20'), ], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false }, }, { now: NOW, silenceWindowDays: 28 }, ); check(mixed.companies[0].responsiveness.label === 'mixed', 'silent-old + responded-later fixture labels mixed (both retained in facts)'); check(mixed.companies[0].responsiveness.facts.length === 2, 'mixed card retains both the silent and the responded fact'); // no-history: an Evaluated row only (never applied, never responded). const noHistory = buildCompanyCards( { trackerRows: [row(14, 'NoHistoryCo', 'Evaluated', '2026-06-01')], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); check(noHistory.companies[0].responsiveness.label === 'no-history', 'Evaluated-only fixture labels no-history'); check(noHistory.companies[0].responsiveness.facts.length === 0, 'no-history card carries no facts'); } // --- right-censoring: Applied row younger than window -> pending, not silent --- { const pending = buildCompanyCards( { trackerRows: [row(20, 'PendingCo', 'Applied', '2026-07-05')], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); check(pending.companies[0].responsiveness.facts.length === 0, 'Applied row younger than the window produces no fact (pending, right-censored)'); check(pending.companies[0].responsiveness.label === 'no-history', 'a lone pending row labels no-history, not silent-on-you'); } // --- rejection-is-an-answer --- { const rejected = computeResponsiveness([row(30, 'RejCo', 'Rejected', '2026-06-01')], new Map(), { now: NOW, silenceWindowDays: 28 }); check(rejected.facts.length === 1 && rejected.facts[0].outcome === 'Rejected', 'Rejected row produces a responded fact with outcome Rejected'); check(rejected.facts[0].note === 'a rejection is an answer', 'Rejected fact carries the rejection-is-an-answer note'); } // --- a hire is the strongest answer (must not fall through to no-history) --- { const hired = computeResponsiveness([row(31, 'HireCo', 'Hired', '2026-06-01')], new Map(), { now: NOW, silenceWindowDays: 28 }); check(hired.facts.length === 1 && hired.facts[0].outcome === 'Hired', 'Hired row produces a responded fact with outcome Hired'); check(hired.label === 'responded-before', 'a company that hired you labels responded-before, never no-history'); } // --- pin-line exclusion (parseFollowups already handles this; assert via fixture) --- { const followupsMd = [ '| # | App# | Date | Company | Role | Channel | Contact | Notes |', '|---|------|------|---------|------|---------|---------|-------|', '| 1 | 40 | 2026-06-01 | PinCo | Engineer | Email | jane@pinco.com | first nudge |', '- next #40 2026-07-15 (set 2026-07-01)', ].join('\n'); const followupRows = parseFollowups(followupsMd); check(followupRows.length === 1, 'pin-directive line is not parsed as a sent follow-up row'); check(followupRows[0].appNum === 40, 'the one real follow-up row parses with the expected appNum'); } // --- confidence-not-label: same label, different confidence --- { const followupRows = [ { num: 1, appNum: 50, date: '2026-06-01', company: 'ConfA', role: 'x', channel: 'Email', contact: 'a@a.com', notes: '' }, { num: 2, appNum: 50, date: '2026-06-08', company: 'ConfA', role: 'x', channel: 'Email', contact: 'a@a.com', notes: '' }, ]; const result = buildCompanyCards( { trackerRows: [row(50, 'ConfA', 'Applied', '2026-05-01'), row(51, 'ConfB', 'Applied', '2026-05-01')], followupRows, repostClusters: [], sourcesLoaded: { tracker: true, followups: true, scanHistory: false, statusLog: false }, }, { now: NOW, silenceWindowDays: 28 }, ); const confA = getCompanyCard(result, 'ConfA'); const confB = getCompanyCard(result, 'ConfB'); check(confA.responsiveness.label === 'silent-on-you' && confB.responsiveness.label === 'silent-on-you', 'both rows share the silent-on-you label regardless of follow-up count'); check(confA.responsiveness.facts[0].confidence === 'confirmed-by-followups', '2 follow-ups sent -> confirmed-by-followups'); check(confB.responsiveness.facts[0].confidence === 'unconfirmed', '0 follow-ups sent -> unconfirmed'); } // --- stale: fact >365d old excluded from label by default; --include-stale includes --- { const staleRows = [row(60, 'StaleCo', 'Applied', '2025-01-01')]; // ~554 days before NOW const defaultResult = computeResponsiveness(staleRows, new Map(), { now: NOW, silenceWindowDays: 28 }); check(defaultResult.facts[0].stale === true, 'a >365d-old Applied row is flagged stale'); check(defaultResult.label === 'no-history', 'a stale-only fact is excluded from label computation by default'); const includeStaleResult = computeResponsiveness(staleRows, new Map(), { now: NOW, silenceWindowDays: 28, includeStale: true }); check(includeStaleResult.label === 'silent-on-you', '--include-stale includes the stale fact in label computation'); } // --- absent-file degradation: each source absent -> false, no crash, other axes still work --- { const bogusRoot = join(CAREER_OPS, '__does-not-exist__'); const tracker = loadTrackerRows(bogusRoot); check(tracker.loaded === false && tracker.rows.length === 0, 'loadTrackerRows against a nonexistent root degrades gracefully'); const followups = loadFollowupRows(bogusRoot); check(followups.loaded === false && followups.rows.length === 0, 'loadFollowupRows against a nonexistent root degrades gracefully'); const scanHistory = loadRepostClusters(bogusRoot); check(scanHistory.loaded === false && scanHistory.clusters.length === 0, 'loadRepostClusters against a nonexistent root degrades gracefully'); const result = buildCompanyCards( { trackerRows: [row(70, 'AloneCo', 'Applied', '2026-05-01')], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false }, }, { now: NOW, silenceWindowDays: 28 }, ); check(result.companies[0].responsiveness.label === 'silent-on-you', 'responsiveness axis still computes when other sources are absent'); check(result.companies[0].postingChurn.label === 'no-scan-data', 'churn axis reports no-scan-data when scan-history is absent'); } // --- funnel-velocity loader is honestly inert against the merged module --- { // The merged funnel-velocity.mjs imports fine but exports neither optional // helper (getAppliedDateObservations / computeMedianResponseDays), so the // loader must report the statusLog source ABSENT — no applied-date // observations, null median — rather than claiming loaded just because the // import resolved. Assert the ACTUAL loader result against the real module. const statusLog = await loadStatusLogSource(); check(statusLog.loaded === false, 'loadStatusLogSource reports statusLog absent when the merged module exports no usable helper'); check(statusLog.appliedDateByNum instanceof Map && statusLog.appliedDateByNum.size === 0, 'loadStatusLogSource yields an empty appliedDateByNum against the merged module'); check(statusLog.medianResponseDays === null, 'loadStatusLogSource yields null medianResponseDays against the merged module'); // Downstream contract: computeResponsiveness surfaces a null median when the // loader supplied none. const result = computeResponsiveness([row(80, 'NoStatusLogCo', 'Applied', '2026-05-01')], new Map(), { now: NOW, silenceWindowDays: 28 }); check(result.medianResponseDays === null, 'medianResponseDays is null when not supplied (statusLog absent)'); } // --- --company lookup: known company returns card; unknown returns no-history shape --- { const result = buildCompanyCards( { trackerRows: [row(90, 'KnownCo', 'Rejected', '2026-06-01')], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); const known = getCompanyCard(result, 'KnownCo'); check(known.responsiveness.label === 'responded-before', 'known company lookup returns its real card'); const unknown = getCompanyCard(result, 'NeverHeardOfThemCo'); check(unknown.responsiveness.label === 'no-history' && unknown.responsiveness.facts.length === 0, 'unknown company lookup returns the minimal no-history shape'); check(unknown.postingChurn.label === 'no-scan-data', 'unknown company lookup reports no-scan-data churn when scan-history never loaded'); } // --- distribution-sanity: 5 companies, not everything is silent-on-you --- { const rows = [ row(100, 'RespondedA', 'Rejected', '2026-06-01'), row(101, 'RespondedB', 'Interview', '2026-06-10'), row(102, 'SilentC', 'Applied', '2026-05-01'), row(103, 'PendingOnlyD', 'Applied', '2026-07-05'), row(104, 'NoHistoryE', 'Evaluated', '2026-06-01'), ]; const result = buildCompanyCards( { trackerRows: rows, followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); const labels = result.companies.map(c => c.responsiveness.label); check(labels.filter(l => l === 'responded-before').length === 2, 'distribution fixture: 2 companies responded-before'); check(labels.filter(l => l === 'silent-on-you').length === 1, 'distribution fixture: 1 company silent-on-you'); check(labels.filter(l => l === 'no-history').length === 2, 'distribution fixture: 2 companies no-history (pending-only + evaluated-only)'); check(!labels.every(l => l === 'silent-on-you'), 'distribution fixture: NOT everything is silent-on-you'); } // --- aggregator companies (#2703) --- { // "joinup.ch" is chosen deliberately: normalizeCompany (which keys the // cards) gives "joinupch" while normalizeCompanyName (which keys the // aggregator Set) gives "joinup ch". If the membership test is ever // "simplified" to compare the card key against the Set, this fixture fails // — a single-word name, where both normalizers agree, would pass and prove // nothing. It is also the entry portals.example.yml ships. const aggregators = new Set([normalizeCompanyName('joinup.ch')]); const sourcesLoaded = { tracker: true, followups: false, scanHistory: true, statusLog: false }; const result = buildCompanyCards( { trackerRows: [row(200, 'joinup.ch', 'Evaluated', '2026-06-01'), row(201, 'RegularCo', 'Evaluated', '2026-06-01')], followupRows: [], repostClusters: [{ company: 'RegularCo', role: 'Engineer', repostCount: 2, daysSpan: 30, lastSeen: '2026-06-20' }], aggregators, sourcesLoaded, }, { now: NOW, silenceWindowDays: 28 }, ); const board = getCompanyCard(result, 'joinup.ch'); check(board.postingChurn.label === 'aggregator-not-evaluated', 'an aggregator reports aggregator-not-evaluated, never none-detected'); check(board.postingChurn.clusters.length === 0, 'an aggregator card carries no clusters'); check( board.explanations.some(e => /aggregator/i.test(e) && /not evaluated/i.test(e)), 'the aggregator card says the axis was not evaluated rather than leaving it bare', ); const regular = getCompanyCard(result, 'RegularCo'); check(regular.postingChurn.label === 'reposts-detected', 'a non-aggregator company is unaffected'); check(result.metadata.aggregatorCompanies === 1, 'metadata reports how many aggregators were configured'); // A flagged board with NO tracker rows and NO clusters — the normal case, // since the detector skips it and you rarely apply to a board. It must // still get a card and still appear in --summary. It fell out of the // tracker-union-clusters key set and vanished from the report entirely, // which is the same silence the label exists to prevent, and a regression // against the unflagged behaviour where the company did appear. const boardOnly = buildCompanyCards( { trackerRows: [], followupRows: [], repostClusters: [], aggregators: new Map([[normalizeCompanyName('joinup.ch'), 'joinup.ch']]), sourcesLoaded, }, { now: NOW, silenceWindowDays: 28 }, ); check(boardOnly.companies.length === 1, 'a flagged board with no tracker rows and no clusters still gets a card'); check( boardOnly.companies[0]?.company === 'joinup.ch', 'that card is titled with the name from portals.yml, not the normalized key', ); check( boardOnly.companies[0]?.postingChurn.label === 'aggregator-not-evaluated', 'the card-less board still reports aggregator-not-evaluated', ); check(/joinup\.ch/.test(renderSummary(boardOnly)), 'the flagged board appears in --summary rather than vanishing from it'); // The loader hands back a Map; a hand-built Set must keep working. const viaSet = buildCompanyCards( { trackerRows: [], followupRows: [], repostClusters: [], aggregators: new Set([normalizeCompanyName('joinup.ch')]), sourcesLoaded, }, { now: NOW, silenceWindowDays: 28 }, ); check(viaSet.metadata.aggregatorCompanies === 1, 'a Set of keys is still accepted alongside the loader\'s Map'); // Same fixture with no aggregators configured: the ONLY difference is the // Set, so this proves the label above comes from the flag and not from the // company simply having no clusters. const unflagged = buildCompanyCards( { trackerRows: [row(200, 'joinup.ch', 'Evaluated', '2026-06-01')], followupRows: [], repostClusters: [], aggregators: new Set(), sourcesLoaded, }, { now: NOW, silenceWindowDays: 28 }, ); check(getCompanyCard(unflagged, 'joinup.ch').postingChurn.label === 'none-detected', 'the same company unflagged reports none-detected'); check(unflagged.metadata.aggregatorCompanies === 0, 'metadata reports zero when nothing is flagged'); // An aggregator never applied to has no card at all and falls through the // unknown-company path — it must not claim none-detected there either. const unknownBoard = getCompanyCard(result, 'joinup.ch — Never Tracked', aggregators); check( unknownBoard.postingChurn.label === 'none-detected', 'an unrelated unknown company still reports none-detected', ); const unknownFlagged = getCompanyCard( { ...result, companies: [] }, 'joinup.ch', ); check( unknownFlagged.postingChurn.label === 'aggregator-not-evaluated', 'an aggregator with no card still reports aggregator-not-evaluated via result.aggregators', ); // Both notes must survive together — a board you also applied to. const both = buildCompanyCards( { trackerRows: [row(202, 'joinup.ch', 'Applied', '2026-05-01')], followupRows: [], repostClusters: [], aggregators, sourcesLoaded, }, { now: NOW, silenceWindowDays: 28 }, ); const bothCard = getCompanyCard(both, 'joinup.ch'); check(bothCard.explanations.length === 2, 'a silent aggregator keeps BOTH the silence note and the aggregator note'); check(renderSummary(both).split('note:').length - 1 === 2, 'renderSummary prints every note, not only the first'); } // --- vocabulary assertions --- { const rows = [row(110, 'VocabCo', 'Applied', '2026-01-01')]; const result = buildCompanyCards( { trackerRows: rows, followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); const output = renderSummary(result); check(!/risk/i.test(output), 'rendered summary never contains the word "risk"'); check(!/ghost/i.test(output), 'rendered summary never contains the word "ghost"'); } // --- every silent fact has date + clearInstruction with real set-status syntax --- { const result = computeResponsiveness([row(120, 'ClearCo', 'Applied', '2026-01-01')], new Map(), { now: NOW, silenceWindowDays: 28 }); const fact = result.facts[0]; check(!!fact.appliedDate, 'silent fact carries an appliedDate'); check(typeof fact.clearInstruction === 'string' && fact.clearInstruction.includes('set-status'), 'silent fact clearInstruction references set-status.mjs'); check(fact.clearInstruction.includes('--row'), 'silent fact clearInstruction selects the tracker row explicitly via --row'); check(fact.clearInstruction.includes('--on'), 'silent fact clearInstruction records the response date via --on, not --note free text'); check(!fact.clearInstruction.includes('--note'), 'silent fact clearInstruction does not smuggle the response date into --note free text'); } // --- no-response-friction signal emission (RFC #1506 schema v1, #2787) --- { const fixedOpts = { region: 'north-america/canada', emittedBy: 'career-ops vTEST' }; // single: exactly one silent fact on the card. const singleResult = buildCompanyCards( { trackerRows: [row(200, 'SingleSilentCo', 'Applied', '2026-05-01')], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); const { records: singleSignals, warnings: singleWarnings } = buildNoResponseFrictionSignals(singleResult, fixedOpts); check(singleWarnings.length === 0, 'resolvable region: no warnings emitted'); check(singleSignals.length === 1, 'exactly one no-response-friction record emitted for a single silent-on-you card'); check(singleSignals[0]?.severity === 'single', 'one silent fact on the card -> severity single'); check(singleSignals[0]?.signalType === 'no-response-friction', 'emitted record carries signalType no-response-friction'); check(singleSignals[0]?.companyKey === singleResult.companies[0].key, 'emitted companyKey reuses company-history.mjs\'s own normalized key, not a new format'); check(singleSignals[0]?.region === 'north-america/canada', 'emitted region matches the resolved region'); check(singleSignals[0]?.emittedBy === 'career-ops vTEST', 'emitted emittedBy matches the resolved version string'); check(/^\d{4}-\d{2}$/.test(singleSignals[0]?.observedAt || ''), 'observedAt is strictly month-only (YYYY-MM, no day)'); check(typeof singleSignals[0]?.sourceHash === 'string' && singleSignals[0].sourceHash.startsWith('sha256:'), 'sourceHash is present and sha256-prefixed'); check(singleSignals[0]?.schemaVersion === 1, 'emitted record carries schemaVersion 1 (ratified RFC #1506 shape)'); check(singleSignals[0]?.detail === null, 'emitted record carries detail: null — this signal is derived purely from dates, no free text'); check(singleSignals[0]?.sourceDetector === 'company-history', 'emitted record carries sourceDetector: company-history (the producing tool, not the signal name — matches interview-redflag.mjs precedent)'); // pattern: two SEPARATE applications to the same company, both silent. const patternResult = buildCompanyCards( { trackerRows: [ row(201, 'PatternSilentCo', 'Applied', '2026-01-01'), row(202, 'PatternSilentCo', 'Applied', '2026-02-01'), ], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false }, }, { now: NOW, silenceWindowDays: 28 }, ); check(patternResult.companies[0].responsiveness.label === 'silent-on-you', 'two-application silent fixture still labels silent-on-you (precondition for the pattern check below)'); const { records: patternSignals } = buildNoResponseFrictionSignals(patternResult, fixedOpts); check(patternSignals.length === 1, 'still exactly one record per company card, even with 2 silent facts'); check(patternSignals[0]?.severity === 'pattern', 'two silent facts on the same card -> severity pattern'); // artemtrofymenko's PR #2788 review scenario, verbatim: the SAME // companyKey + observedAt (same company, same month) must produce the // SAME sourceHash whether the card carries 1 silent fact (severity // 'single') or 2 (severity 'pattern') — severity is explicitly excluded // from the hash so a later emission with updated severity supersedes // the earlier one in a dedup pool instead of coexisting as a phantom // duplicate. const sameMonthSingleResult = buildCompanyCards( { trackerRows: [row(240, 'SeverityHashCo', 'Applied', '2026-05-03')], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); const sameMonthPatternResult = buildCompanyCards( { trackerRows: [ row(241, 'SeverityHashCo', 'Applied', '2026-05-03'), row(242, 'SeverityHashCo', 'Applied', '2026-05-10'), ], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false }, }, { now: NOW, silenceWindowDays: 28 }, ); const { records: sameMonthSingleSignals } = buildNoResponseFrictionSignals(sameMonthSingleResult, fixedOpts); const { records: sameMonthPatternSignals } = buildNoResponseFrictionSignals(sameMonthPatternResult, fixedOpts); check(sameMonthSingleSignals[0]?.severity === 'single' && sameMonthPatternSignals[0]?.severity === 'pattern', 'severity/hash fixture: single vs pattern severity actually differs (precondition)'); check(sameMonthSingleSignals[0]?.observedAt === sameMonthPatternSignals[0]?.observedAt, 'severity/hash fixture: both anchor to the same observedAt month (precondition)'); check(sameMonthSingleSignals[0]?.sourceHash === sameMonthPatternSignals[0]?.sourceHash, 'sourceHash is identical for the same companyKey+observedAt despite different severity (single vs pattern) — fixes the dedup-breaking bug from PR #2788 review'); // no record for responded-before / mixed / no-history cards — only // silent-on-you triggers this signal. const noSignalResult = buildCompanyCards( { trackerRows: [ row(210, 'RespondedNoSignalCo', 'Rejected', '2026-06-01'), row(211, 'MixedNoSignalCo', 'Applied', '2026-01-01'), row(212, 'MixedNoSignalCo', 'Rejected', '2026-06-20'), row(213, 'NoHistoryNoSignalCo', 'Evaluated', '2026-06-01'), ], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false }, }, { now: NOW, silenceWindowDays: 28 }, ); const labelsPresent = noSignalResult.companies.map(c => c.responsiveness.label); check(labelsPresent.includes('responded-before') && labelsPresent.includes('mixed') && labelsPresent.includes('no-history'), 'no-signal fixture actually exercises responded-before, mixed, and no-history cards'); const { records: noSignals } = buildNoResponseFrictionSignals(noSignalResult, fixedOpts); check(noSignals.length === 0, 'responded-before, mixed, and no-history cards emit zero no-response-friction records'); // running WITHOUT --emit-signal semantics: buildNoResponseFrictionSignals // is only ever invoked by the CLI when the flag is passed (verified by // reading the CLI wiring above); at the pure-function level, confirm the // emitted records carry exactly the ratified 10-field schema-v1 shape — // no free text (notes), no candidate name, nothing extra, nothing // missing. const allEmitted = [...singleSignals, ...patternSignals]; for (const record of allEmitted) { const keys = Object.keys(record).sort(); check( JSON.stringify(keys) === JSON.stringify([ 'companyKey', 'detail', 'emittedBy', 'observedAt', 'postingChannel', 'region', 'schemaVersion', 'severity', 'signalType', 'sourceDetector', 'sourceHash', ]), 'emitted record carries the ratified schema-v1 10 fields plus the additive postingChannel field (#3010), nothing extra (no notes/name leakage), nothing missing', ); } // sourceHash is deterministic (dedup-safe): rebuilding the same fixture // twice must produce the same hash for the same underlying fact. const { records: singleSignalsAgain } = buildNoResponseFrictionSignals( buildCompanyCards( { trackerRows: [row(200, 'SingleSilentCo', 'Applied', '2026-05-01')], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ), fixedOpts, ); check(singleSignalsAgain[0]?.sourceHash === singleSignals[0]?.sourceHash, 'sourceHash is deterministic across repeated runs against the same fact (dedup-safe)'); // sourceHash must actually be sensitive to companyKey and observedAt — // determinism alone doesn't prove the hash isn't a constant or doesn't // silently drop one of its inputs (CodeRabbit, PR #2788). const { records: differentCompanySignals } = buildNoResponseFrictionSignals( buildCompanyCards( { trackerRows: [row(203, 'DifferentCompanyCo', 'Applied', '2026-05-01')], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ), fixedOpts, ); check(differentCompanySignals[0]?.sourceHash !== singleSignals[0]?.sourceHash, 'sourceHash differs when companyKey differs (same observedAt month) — companyKey is a real hash input, not dropped'); const { records: differentMonthSignals } = buildNoResponseFrictionSignals( buildCompanyCards( { trackerRows: [row(204, 'SingleSilentCo', 'Applied', '2026-06-01')], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ), fixedOpts, ); check(differentMonthSignals[0]?.sourceHash !== singleSignals[0]?.sourceHash, 'sourceHash differs when observedAt differs (same companyKey) — observedAt is a real hash input, not dropped'); // severity excludes stale facts from the count unless --include-stale is // passed — a card with one active + one stale silent fact must report // 'single', matching what the 'silent-on-you' label itself was computed // from (computeResponsiveness excludes stale facts from the label too). const staleRows = [ row(220, 'ActivePlusStaleCo', 'Applied', '2026-06-01'), // active: well within 365d row(221, 'ActivePlusStaleCo', 'Applied', '2024-01-01'), // stale: >365d before NOW ]; const activePlusStaleResult = buildCompanyCards( { trackerRows: staleRows, followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); check(activePlusStaleResult.companies[0].responsiveness.label === 'silent-on-you', 'active+stale fixture: label is silent-on-you (only the active fact drives it)'); const activePlusStaleFacts = activePlusStaleResult.companies[0].responsiveness.facts; check(activePlusStaleFacts.some(f => f.stale) && activePlusStaleFacts.some(f => !f.stale), 'active+stale fixture: card carries both a stale and an active silent fact'); const { records: activePlusStaleSignals } = buildNoResponseFrictionSignals(activePlusStaleResult, fixedOpts); check(activePlusStaleSignals.length === 1, 'active+stale fixture: still exactly one record for the card'); check(activePlusStaleSignals[0]?.severity === 'single', 'active+stale fixture: severity is single — the stale fact does not inflate it to pattern, matching the label'); // The includeStale:true emission path must actually be exercised // end-to-end (not just the default-exclusion path above), so a // regression in the opt-in branch would be caught (CodeRabbit, PR #2788). const activePlusStaleIncludeStaleResult = buildCompanyCards( { trackerRows: staleRows, followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28, includeStale: true }, ); const { records: activePlusStaleIncludeStaleSignals } = buildNoResponseFrictionSignals( activePlusStaleIncludeStaleResult, { ...fixedOpts, includeStale: true }, ); check(activePlusStaleIncludeStaleSignals[0]?.severity === 'pattern', 'active+stale fixture with includeStale:true: severity is pattern — the stale fact now counts'); // silentFactObservedAt returning null (unusable appliedDate) must skip the // card entirely — no record, no crash. Constructed directly rather than // through buildCompanyCards, since computeResponsiveness() never produces // a fact with an unparsable appliedDate in the first place. const unusableDateResult = { companies: [{ key: 'unusable-date-co', responsiveness: { label: 'silent-on-you', facts: [{ num: 1, appliedDate: 'not-a-real-date', silentDays: 40, stale: false }], }, }], }; const { records: unusableDateSignals } = buildNoResponseFrictionSignals(unusableDateResult, fixedOpts); check(unusableDateSignals.length === 0, 'a card whose anchor fact has an unusable appliedDate is skipped entirely — no record emitted'); // Mixed valid/invalid facts: the unusable-date fact must be filtered out // BEFORE anchor selection and severity counting, not merely tolerated by // whichever fact happens to win the reduce — otherwise it could win the // anchor outright, or (even when the valid fact wins) still inflate // severity to 'pattern' despite contributing no usable observation // (CodeRabbit, PR #2788). const mixedValidityResult = { companies: [{ key: 'mixed-validity-co', responsiveness: { label: 'silent-on-you', facts: [ { num: 1, appliedDate: '2026-06-01', silentDays: 40, stale: false }, { num: 2, appliedDate: 'not-a-real-date', silentDays: 40, stale: false }, ], }, }], }; const { records: mixedValiditySignals } = buildNoResponseFrictionSignals(mixedValidityResult, fixedOpts); check(mixedValiditySignals.length === 1, 'mixed valid/invalid facts: still exactly one record — the invalid fact does not block emission'); check(mixedValiditySignals[0]?.observedAt === '2026-06', 'mixed valid/invalid facts: observedAt anchors on the valid fact'); check(mixedValiditySignals[0]?.severity === 'single', 'mixed valid/invalid facts: severity is single — the invalid fact does not count toward pattern'); // silentFactObservedAt must trim appliedDate before slicing, not just // before validating: parseDate() tolerates surrounding whitespace, but a // whitespace-padded appliedDate sliced without trimming would produce a // malformed observedAt (leading space, wrong characters) instead of the // clean YYYY-MM the schema requires. const paddedDateResult = { companies: [{ key: 'padded-date-co', responsiveness: { label: 'silent-on-you', facts: [{ num: 1, appliedDate: ' 2026-06-01 ', silentDays: 40, stale: false }], }, }], }; const { records: paddedDateSignals } = buildNoResponseFrictionSignals(paddedDateResult, fixedOpts); check(paddedDateSignals[0]?.observedAt === '2026-06', 'a whitespace-padded appliedDate still produces a clean YYYY-MM observedAt'); // anchor selection compares appliedDate, not tracker row num: a lower-num // row with a MORE RECENT appliedDate (a backfilled/out-of-order entry) // must still win the anchor, keeping observedAt tied to the true // most-recent application. const outOfOrderRows = [ row(230, 'OutOfOrderCo', 'Applied', '2026-06-01'), // lower num, more recent applied date row(231, 'OutOfOrderCo', 'Applied', '2026-01-01'), // higher num, older applied date ]; const outOfOrderResult = buildCompanyCards( { trackerRows: outOfOrderRows, followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); const { records: outOfOrderSignals } = buildNoResponseFrictionSignals(outOfOrderResult, fixedOpts); check(outOfOrderSignals[0]?.observedAt === '2026-06', 'anchor fact is chosen by appliedDate (2026-06-01), not by the larger tracker row num (231, dated 2026-01-01)'); // anchor selection must trim before comparing: an untrimmed whitespace- // padded date can sort incorrectly against a clean neighbor and select // the wrong (older) anchor (CodeRabbit, PR #2788). const paddedAnchorRows = [ row(240, 'PaddedAnchorCo', 'Applied', ' 2026-06-01 '), // padded, actually most recent row(241, 'PaddedAnchorCo', 'Applied', '2026-05-01'), // clean, actually older ]; const paddedAnchorResult = buildCompanyCards( { trackerRows: paddedAnchorRows, followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); const { records: paddedAnchorSignals } = buildNoResponseFrictionSignals(paddedAnchorResult, fixedOpts); check(paddedAnchorSignals[0]?.observedAt === '2026-06', 'anchor selection trims before comparing, so a whitespace-padded but more-recent appliedDate still wins over a clean but older one'); // resolveRegion / resolveEmittedBy degrade gracefully against a missing file. // Per artemtrofymenko's PR #2788 review, an unresolvable region no longer // returns the literal string 'unknown' (which would imply a real // `unknown/` shared-layer directory) — it returns null instead, and the // caller (buildNoResponseFrictionSignals, tested below) is responsible // for turning that into "skip emission + warn", not a placeholder value. check(resolveRegion(join(CAREER_OPS, '__does-not-exist__.yml')) === null, 'resolveRegion degrades to null (not the string "unknown") when profile.yml is absent'); check(resolveEmittedBy(join(CAREER_OPS, '__does-not-exist__.json')) === 'career-ops', 'resolveEmittedBy degrades to a version-less string when package.json is unreadable'); // resolveRegion: a mapped country goes through COUNTRY_REGION_MAP, and an // unmapped country degrades to a slugified `unmapped/` rather than // crashing or falling all the way back to null. const regionTmpDir = mkdtempSync(join(tmpdir(), 'company-history-region-test-')); try { const mappedProfilePath = join(regionTmpDir, 'mapped-profile.yml'); writeFileSync(mappedProfilePath, 'location:\n country: Canada\n'); check(resolveRegion(mappedProfilePath) === 'north-america/canada', 'resolveRegion maps a known country via COUNTRY_REGION_MAP'); const unmappedProfilePath = join(regionTmpDir, 'unmapped-profile.yml'); writeFileSync(unmappedProfilePath, 'location:\n country: Narnia\n'); check(resolveRegion(unmappedProfilePath) === 'unmapped/narnia', 'resolveRegion degrades an unmapped country to unmapped/, not null'); // Region-resolution-failure -> skip-emission-with-warning (Finding 3, // PR #2788 review): a profile with no resolvable region must not crash // the tool and must not emit a misleading 'unknown' record — it emits // zero records for that run plus a warning naming the skipped company. // A second company/report with a resolvable region (fixedOpts, used // throughout this test block) still gets its record — proven by every // other assertion in this section, which all pass a resolvable region. const noRegionProfilePath = join(regionTmpDir, 'no-region-profile.yml'); writeFileSync(noRegionProfilePath, 'someOtherKey: true\n'); const noRegionResult = buildCompanyCards( { trackerRows: [row(250, 'NoRegionCo', 'Applied', '2026-05-01')], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); const { records: noRegionSignals, warnings: noRegionWarnings } = buildNoResponseFrictionSignals( noRegionResult, { profilePath: noRegionProfilePath, emittedBy: 'career-ops vTEST' }, ); check(noRegionSignals.length === 0, 'unresolvable region: no no-response-friction record emitted, no crash'); check(noRegionWarnings.length === 1 && noRegionWarnings[0].includes(noRegionResult.companies[0].key), 'unresolvable region: exactly one warning naming the skipped companyKey'); } finally { rmSync(regionTmpDir, { recursive: true, force: true }); } } // --- postingChannel field (#3010): derived from the tracker's own `via` --- { // agency-tagged via -> staffing-agency. const agencyResult = buildCompanyCards( { trackerRows: [{ ...row(260, 'AgencyPostedCo', 'Applied', '2026-05-01'), via: 'Hays' }], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); check(agencyResult.companies[0].responsiveness.facts[0].via === 'Hays', 'row.via survives onto the silent fact'); const { records: agencySignals } = buildNoResponseFrictionSignals(agencyResult, { region: 'north-america/canada', emittedBy: 'career-ops vTEST' }); check(agencySignals[0]?.postingChannel === 'staffing-agency', 'a tagged agency via value emits postingChannel: staffing-agency'); // em-dash via (tracker's own "confirmed direct" convention) -> direct-employer. const directResult = buildCompanyCards( { trackerRows: [{ ...row(261, 'DirectConfirmedCo', 'Applied', '2026-05-01'), via: '—' }], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); const { records: directSignals } = buildNoResponseFrictionSignals(directResult, { region: 'north-america/canada', emittedBy: 'career-ops vTEST' }); check(directSignals[0]?.postingChannel === 'direct-employer', 'an em-dash via value (confirmed direct, no agency) emits postingChannel: direct-employer'); // absent/blank via -> unknown (never guessed as direct-employer). const noDataResult = buildCompanyCards( { trackerRows: [row(262, 'NoViaDataCo', 'Applied', '2026-05-01')], followupRows: [], repostClusters: [], sourcesLoaded: { tracker: true, followups: false, scanHistory: false, statusLog: false } }, { now: NOW, silenceWindowDays: 28 }, ); const { records: noDataSignals } = buildNoResponseFrictionSignals(noDataResult, { region: 'north-america/canada', emittedBy: 'career-ops vTEST' }); check(noDataSignals[0]?.postingChannel === 'unknown', 'a row with no recorded via emits postingChannel: unknown, never a guessed direct-employer'); check(resolvePostingChannel(' ') === 'unknown', 'resolvePostingChannel: whitespace-only via is unknown, not staffing-agency'); check(resolvePostingChannel(null) === 'unknown', 'resolvePostingChannel: null via is unknown'); check(resolvePostingChannel(undefined) === 'unknown', 'resolvePostingChannel: undefined via is unknown'); check(resolvePostingChannel('Acme Staffing') === 'staffing-agency', 'resolvePostingChannel: any non-em-dash tagged agency name is staffing-agency'); check(resolvePostingChannel('—') === 'direct-employer', 'resolvePostingChannel: exact em-dash is direct-employer'); } console.log(`\n company-history self-test: ${pass} passed, ${fail} failed\n`); process.exit(fail > 0 ? 1 : 0); } // --- Run (CLI only; guarded so the module is safely importable for tests) --- if (isMainModule(import.meta.url)) { const { summaryMode, selfTestMode, company, silenceWindowArg, includeStale, scanHistoryOverride, followupsOverride, emitSignal } = parseArgs(process.argv); if (selfTestMode) { runSelfTest().catch(err => { console.error(`company-history.mjs: self-test error: ${err?.message || err}`); process.exit(1); }); } else { const run = async () => { // DATA_ROOT, not CAREER_OPS: these three read data/applications.md, // data/follow-ups.md and data/scan-history.tsv, which are USER layer and // move with CAREER_OPS_ROOT / CAREER_OPS_DATA_DIR / .career-ops-data. // resolveDefaultSilenceWindow() below stays on CAREER_OPS on purpose — // templates/benchmarks.yml is SYSTEM layer and ships with the code. const tracker = loadTrackerRows(DATA_ROOT); const followups = loadFollowupRows(DATA_ROOT, followupsOverride); const scanHistory = loadRepostClusters(DATA_ROOT, scanHistoryOverride); const statusLog = await loadStatusLogSource(); // parseArgs already validated the flag as a positive integer. const silenceWindowDays = silenceWindowArg !== undefined ? parseInt(silenceWindowArg, 10) : resolveDefaultSilenceWindow(CAREER_OPS); const result = buildCompanyCards( { trackerRows: tracker.rows, followupRows: followups.rows, repostClusters: scanHistory.clusters, aggregators: scanHistory.aggregators, sourcesLoaded: { tracker: tracker.loaded, followups: followups.loaded, scanHistory: scanHistory.loaded, statusLog: statusLog.loaded, }, statusLogAppliedByNum: statusLog.appliedDateByNum, medianResponseDays: statusLog.medianResponseDays, }, { silenceWindowDays, includeStale }, ); if (company) { console.log(JSON.stringify({ ...getCompanyCard(result, company), metadata: { sources: result.metadata.sources } }, null, 2)); } else if (summaryMode) { console.log(renderSummary(result)); } else { console.log(JSON.stringify(result, null, 2)); } // --emit-signal is additive and opt-in only: without the flag, output // above is byte-for-byte identical to pre-#2787 behavior. parseArgs() // already rejects --emit-signal unless it's paired with --summary (and // without --company), so by the time we get here `summaryMode` is true // and the human-readable renderSummary() text — not a JSON document — // is what the signal records get appended after; stdout stays a single // parsable unit for whichever format was actually printed. When passed, // RFC #1506 schema-v1 no-response-friction records print as JSON Lines // (one record per line) after the normal output — nothing is written to // disk and nothing is published anywhere. if (emitSignal) { const { records: signals, warnings } = buildNoResponseFrictionSignals(result, { includeStale }); for (const warning of warnings) { console.error(`company-history.mjs: ${warning}`); } for (const record of signals) { console.log(JSON.stringify(record)); } } }; run().catch(err => { console.error(`company-history.mjs: unexpected error: ${err?.message || err}`); process.exit(1); }); } }