company-history.mjs
Rama main · réplica de lectura del repositorio original.
#!/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<appNum, count> (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} <state> --on <response-date> 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/<lang>/ 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/<slug>`) — 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 <num> <state> --on <response-date>).`);
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/<slug>` 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/<slug>, 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);
});
}
}