Files
santifer--career-ops/salary-gap.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

635 lines
32 KiB
JavaScript

#!/usr/bin/env node
/**
* salary-gap.mjs — Desired vs Advertised vs Actual compensation analyzer
*
* Salary facts are append-only observations, never mutated:
* { tracker#, date, type: desired|advertised|actual, amount, currency, source, note }
*
* Sources folded on read (one write path per fact):
* 1. reports/{###}-*.md Machine Summary `advertised_comp` -> advertised (source: jd)
* 2. data/salary-observations.tsv (user-layer, append-only) -> desired/actual (+ corrections)
* 3. config/profile.yml compensation.target_range -> desired default (source: profile)
*
* Fold: per (tracker#, type), highest trust tier wins, then latest date.
* actual: contract > offer-letter > recruiter-verbal > user
* desired: user > profile
* advertised: user > recruiter-verbal > jd
*
* Aggregates grouped by (company, role) and per currency — no FX conversion.
* Unparseable amounts, unrecognized sources, orphaned observations (tracker#
* without report/tracker row), sample sizes, and staleness are reported loudly,
* never dropped silently.
*
* Run: node salary-gap.mjs (JSON)
* node salary-gap.mjs --summary (human-readable)
* node salary-gap.mjs --self-test
*/
import { readFileSync, existsSync, readdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import yaml from 'js-yaml';
const CAREER_OPS = dirname(fileURLToPath(import.meta.url));
const OBS_PATH = join(CAREER_OPS, 'data/salary-observations.tsv');
const REPORTS_DIR = join(CAREER_OPS, 'reports');
const args = process.argv.slice(2);
const summaryMode = args.includes('--summary');
const selfTestMode = args.includes('--self-test');
const TRUST = {
actual: { contract: 3, 'offer-letter': 2, 'recruiter-verbal': 1, user: 0 },
desired: { user: 1, profile: 0 },
advertised: { user: 2, 'recruiter-verbal': 1, jd: 0 },
};
// --- Amount parsing ---
export function parseAmount(raw) {
let s = String(raw ?? '').trim();
if (!s || s === '?' || s === '-' || /^(n\/?a|null)$/i.test(s)) return null;
// Strip a leading currency symbol and a trailing 3-letter ISO-4217-style alpha
// token (any case — "450k SEK", "80-90k eur"). Exactly three letters, so the
// lone "k" magnitude suffix ("80k") is never eaten, and prose ("competitive")
// still fails the numeric match below even after losing its last three letters.
s = s.replace(/^[€$£¥]\s*/, '').replace(/\s*[A-Za-z]{3}\s*$/, '').trim();
const toNum = (numStr, kFlag) => {
const n = parseFloat(numStr.replace(/,/g, ''));
return Number.isNaN(n) ? null : (kFlag ? n * 1000 : n);
};
const range = s.match(/^([\d.,]+)\s*(k)?\s*[-–—]\s*([\d.,]+)\s*(k)?$/i);
if (range) {
const lo = toNum(range[1], range[2] || range[4]); // "80-90k": k applies to both
const hi = toNum(range[3], range[4] || range[2]);
if (lo === null || hi === null) return null;
const min = Math.min(lo, hi), max = Math.max(lo, hi);
return { min, max, mid: (min + max) / 2 };
}
const single = s.match(/^([\d.,]+)\s*(k)?$/i);
if (single) {
const v = toNum(single[1], single[2]);
return v === null ? null : { min: v, max: v, mid: v };
}
return null;
}
const VALID_TYPES = new Set(['desired', 'advertised', 'actual']);
// --- Observation log parsing (TSV) ---
// line: {tracker#}\t{YYYY-MM-DD}\t{type}\t{amount}\t{currency}\t{source}\t{note}
export function parseObservations(content) {
const out = [];
for (const line of String(content || '').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const cells = t.split('\t');
if (cells.length < 6) continue;
const [num, date, type, amount, currency, source, note = ''] = cells.map(c => c.trim());
if (!VALID_TYPES.has(type)) continue;
// blank currency cell -> UNKNOWN, so the fold's currency guard excludes it from
// gap math ('' === '' would otherwise pass the strict-equality comparability check)
out.push({ num, date, type, amount, currency: currency ? currency.toUpperCase() : 'UNKNOWN', source, note, parsed: parseAmount(amount) });
}
return out;
}
// Like analyze-patterns.mjs:110 but WITHOUT `json`: analyze-patterns feeds the fence
// body to a real YAML parser (JSON is a YAML subset, so json fences parse fine there),
// while yamlStr below only extracts `key: value` lines — a json fence would "match"
// and silently yield null company/role/advertised_comp. Rejecting it outright means
// the report falls back to the legacy no-Machine-Summary path instead.
const FENCE_RE = /##\s*Machine Summary\s*\n+```(?:yaml|yml)?\s*\n([\s\S]*?)\n```/i;
const yamlStr = (body, key) => {
const m = body.match(new RegExp(`^${key}:\\s*(.*)$`, 'm'));
if (!m) return null;
const v = m[1].trim().replace(/^["']|["']$/g, '');
return v === 'null' || v === '' ? null : v;
};
// --- Report-derived advertised observations ---
// Extracts advertised_comp + company + role from one report's Machine Summary.
// num/date come from the filename ({###}-{slug}-{YYYY-MM-DD}.md).
export function reportToObservation(content, num, date) {
const fence = String(content || '').match(FENCE_RE);
if (!fence) return null;
const body = fence[1];
const company = yamlStr(body, 'company');
const role = yamlStr(body, 'role');
const adv = yamlStr(body, 'advertised_comp');
// Currency = first standalone UPPERCASE 3-letter token, case-SENSITIVE on
// purpose: lowercase 3-letter English words in sloppy values ("per", "and")
// must not register as currencies. Tradeoff: a lowercase "100k eur" yields
// UNKNOWN (excluded from gap math, surfaced in currencyMismatches) — acceptable;
// a corrective TSV observation with an explicit currency overrides it.
const currencyGuess = adv ? (adv.match(/\b[A-Z]{3}\b/)?.[0] ?? 'UNKNOWN') : null;
return {
company, role,
observation: adv === null ? null : {
num, date, type: 'advertised', amount: adv, currency: currencyGuess,
source: 'jd', note: 'from report Machine Summary', parsed: parseAmount(adv),
},
};
}
const pctDelta = (from, to) => ((to - from) / from) * 100;
const median = (nums) => {
const s = [...nums].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
function pickEffective(type, candidates) {
const tiers = TRUST[type];
// Object.hasOwn, not `in`: a TSV source like "toString" would pass an `in`
// check via Object.prototype and poison the trust sort with a function value.
const usable = candidates.filter(o => o.type === type && o.parsed !== null && Object.hasOwn(tiers, o.source));
if (!usable.length) return null;
usable.sort((a, b) => (tiers[b.source] - tiers[a.source]) || (a.date < b.date ? 1 : -1));
const top = usable[0];
return { value: top.parsed.mid, source: top.source, date: top.date, currency: top.currency, raw: top.amount };
}
// --- Fold + aggregates ---
export function fold(observations, apps, profileDesired) {
const byNum = new Map();
for (const o of observations) {
if (!byNum.has(o.num)) byNum.set(o.num, []);
byNum.get(o.num).push(o);
}
const applications = [];
const orphans = [];
const currencyMismatches = [];
const unparseable = observations
.filter(o => o.parsed === null && o.amount && o.amount !== '?')
.map(o => ({ num: o.num, type: o.type, raw: o.amount }));
// pickEffective only trusts sources in TRUST[type]'s vocabulary — anything else
// (e.g. the `recruiter_verbal` underscore typo) must be reported, not silently
// dropped. Report-derived obs are always `jd` and profile obs `profile`, so in
// practice only TSV lines can land here.
const invalidSources = observations
.filter(o => TRUST[o.type] && !Object.hasOwn(TRUST[o.type], o.source))
.map(o => ({ num: o.num, type: o.type, source: o.source }));
const profileObs = profileDesired?.amount ? {
num: '*', date: '0000-00-00', type: 'desired', amount: profileDesired.amount,
currency: (profileDesired.currency || 'UNKNOWN').toUpperCase(), source: 'profile', note: '', parsed: parseAmount(profileDesired.amount),
} : null;
// a garbage profile target (e.g. "competitive") would otherwise vanish silently:
// quality.unparseable above is built from TSV/report observations only
if (profileObs && profileObs.parsed === null && profileObs.amount !== '?') {
unparseable.push({ num: '*', type: 'desired', raw: profileObs.amount, source: 'profile' });
}
for (const [num, obs] of byNum) {
if (!apps[num]) { orphans.push({ num, count: obs.length }); continue; }
const trail = [...obs].sort((a, b) => (a.date < b.date ? -1 : 1));
const desired = pickEffective('desired', profileObs ? [...obs, profileObs] : obs);
const advertised = pickEffective('advertised', obs);
const actual = pickEffective('actual', obs);
// Gap pcts only compare like currencies — no FX conversion, so a cross-currency
// pct would be meaningless. Gap math requires a PROVEN shared currency: strict
// string equality AND neither side UNKNOWN (two UNKNOWNs could be different real
// currencies, so UNKNOWN is never comparable — not even with itself). Skips are
// reported in quality.currencyMismatches, never dropped silently.
const advComparable = advertised && actual && advertised.currency === actual.currency && advertised.currency !== 'UNKNOWN';
const desComparable = desired && actual && desired.currency === actual.currency && desired.currency !== 'UNKNOWN';
if (advertised && actual && !advComparable) currencyMismatches.push({ num, comparison: 'advertised-vs-actual', currencies: [advertised.currency, actual.currency] });
if (desired && actual && !desComparable) currencyMismatches.push({ num, comparison: 'desired-vs-actual', currencies: [desired.currency, actual.currency] });
applications.push({
num, company: apps[num].company, role: apps[num].role,
desired, advertised, actual, trail,
advToActPct: advComparable ? pctDelta(advertised.value, actual.value) : null,
desiredToActPct: desComparable ? pctDelta(desired.value, actual.value) : null,
});
}
// apps with no observations at all but a profile desired still get the default
for (const [num, meta] of Object.entries(apps)) {
if (!byNum.has(num) && profileObs) {
applications.push({
num, company: meta.company, role: meta.role,
desired: pickEffective('desired', [profileObs]), advertised: null, actual: null,
trail: [], advToActPct: null, desiredToActPct: null,
});
}
}
applications.sort((a, b) => a.num.localeCompare(b.num));
const byCurrency = {};
const byCompanyRole = {};
const today = applications.flatMap(a => a.trail.map(t => t.date)).sort().pop() ?? null;
for (const a of applications) {
if (!a.actual) continue;
const cur = a.actual.currency || 'UNKNOWN';
byCurrency[cur] ??= { confirmed: 0, advGaps: [], atOrAboveAdvertised: 0, atOrAboveDesired: 0, newestActual: null };
const agg = byCurrency[cur];
agg.confirmed += 1;
agg.newestActual = [agg.newestActual, a.actual.date].filter(Boolean).sort().pop();
// pct !== null implies both sides exist AND currencies match (see fold above),
// so cross-currency pairs are excluded from gap lists and at/above counts alike.
if (a.advToActPct !== null) {
agg.advGaps.push(a.advToActPct);
if (a.actual.value >= a.advertised.value) agg.atOrAboveAdvertised += 1;
}
if (a.desiredToActPct !== null && a.actual.value >= a.desired.value) agg.atOrAboveDesired += 1;
// Legacy reports (no Machine Summary) fold with null company/role — without
// a fallback every legacy row would collapse into one shared "null|null"
// bucket. Key those per application instead, with display fields that render
// meaningfully in printSummary.
const legacy = !a.company && !a.role;
const key = legacy ? `#${a.num}` : `${a.company}|${a.role}`;
byCompanyRole[key] ??= legacy
? { company: `report #${a.num}`, role: '(no Machine Summary)', confirmed: 0, advToActPcts: [] }
: { company: a.company, role: a.role, confirmed: 0, advToActPcts: [] };
byCompanyRole[key].confirmed += 1;
if (a.advToActPct !== null) byCompanyRole[key].advToActPcts.push(a.advToActPct);
}
for (const agg of Object.values(byCurrency)) {
agg.meanAdvToActPct = agg.advGaps.length ? agg.advGaps.reduce((s, v) => s + v, 0) / agg.advGaps.length : null;
agg.medianAdvToActPct = agg.advGaps.length ? median(agg.advGaps) : null;
delete agg.advGaps;
}
return {
applications,
aggregates: { byCurrency, byCompanyRole },
quality: {
orphans, unparseable, invalidSources,
currencyMismatches: currencyMismatches.sort((a, b) => a.num.localeCompare(b.num)),
withoutActual: applications.filter(a => !a.actual).length, latestObservation: today,
},
};
}
// --- Self-test ---
const OBS_FIXTURE = [
'001\t2026-06-20\tdesired\t95k\tEUR\tuser\tstated in eval chat',
'001\t2026-06-28\tactual\t90k\tEUR\trecruiter-verbal\tscreen call',
'001\t2026-07-03\tactual\t84k\tEUR\toffer-letter\t',
'001\t2026-07-05\tactual\t86k\tEUR\tcontract\tsigned',
'002\t2026-06-25\tactual\t88k\tEUR\trecruiter-verbal\t',
'002\t2026-06-29\tactual\t99k\tEUR\trecruiter_verbal\tunderscore typo on purpose',
'002\t2026-06-30\tactual\t120k\tEUR\ttoString\tprototype key on purpose',
'007\t2026-06-25\tactual\t70k\tEUR\trecruiter-verbal\torphan - no report',
'003\t2026-06-26\tactual\t9ok\tUSD\trecruiter-verbal\ttypo on purpose',
'005\t2026-07-01\tactual\t92k\tUNKNOWN\toffer-letter\tcurrency not stated',
].join('\n');
const REPORT_FIXTURE_001 = `# Eval: Acme — ML Eng
## Machine Summary
\`\`\`yaml
company: "Acme"
role: "ML Eng"
score: 4.2
advertised_comp: "80-90k EUR"
\`\`\`
`;
const REPORT_FIXTURE_002 = `# Eval: Globex — Data Eng
## Machine Summary
\`\`\`yaml
company: "Globex"
role: "Data Eng"
score: 3.9
advertised_comp: "100k EUR"
\`\`\`
`;
const REPORT_FIXTURE_004 = `# Eval: Umbrella — AI Eng
## Machine Summary
\`\`\`yaml
company: "Umbrella"
role: "AI Eng"
score: 4.1
advertised_comp: "100k USD"
\`\`\`
`;
const REPORT_FIXTURE_005 = `# Eval: Hooli — AI Lead
## Machine Summary
\`\`\`yaml
company: "Hooli"
role: "AI Lead"
score: 3.8
advertised_comp: "95k"
\`\`\`
`;
const REPORT_FIXTURE_003 = `# Eval: Initech — Platform
## Machine Summary
\`\`\`yaml
company: "Initech"
role: "Platform"
score: 4.0
advertised_comp: null
\`\`\`
`;
function selfTest() {
const assert = (cond, msg) => {
if (!cond) { console.error(`SELF-TEST FAIL: ${msg}`); process.exit(1); }
};
// parseAmount
assert(parseAmount('84k')?.mid === 84000, '84k -> 84000');
assert(parseAmount('84000')?.mid === 84000, 'plain number');
assert(parseAmount('80-90k')?.mid === 85000, 'range with trailing k');
assert(parseAmount('80k-90k')?.min === 80000, 'range with both k');
assert(parseAmount('$80k')?.mid === 80000, 'leading currency symbol tolerated');
assert(parseAmount('82.5k')?.mid === 82500, 'decimal k');
assert(parseAmount('90-80k')?.min === 80000, 'reversed range normalized');
assert(parseAmount('?') === null, '? -> null');
assert(parseAmount('') === null, 'blank -> null');
assert(parseAmount('competitive') === null, 'prose -> null');
assert(parseAmount('9ok') === null, 'typo -> null');
assert(parseAmount('450k SEK')?.mid === 450000, 'generic trailing ISO token stripped (450k SEK)');
assert(parseAmount('80-90k eur')?.mid === 85000, 'lowercase trailing ISO token stripped');
// parseObservations
const obs = parseObservations(OBS_FIXTURE);
assert(obs.length === 10, `10 observations, got ${obs.length}`);
assert(obs[0].num === '001' && obs[0].type === 'desired' && obs[0].source === 'user', 'fields mapped');
assert(parseObservations('').length === 0, 'empty log');
const blankCur = parseObservations('008\t2026-07-01\tactual\t91k\t\toffer-letter\tblank currency cell');
assert(blankCur.length === 1 && blankCur[0].currency === 'UNKNOWN', 'blank currency cell -> UNKNOWN (excluded from gap math by the UNKNOWN guard)');
// reportToObservation
const r1 = reportToObservation(REPORT_FIXTURE_001, '001', '2026-06-20');
assert(r1.company === 'Acme' && r1.role === 'ML Eng', 'report company/role');
assert(r1.observation.type === 'advertised' && r1.observation.source === 'jd', 'report -> advertised obs');
assert(r1.observation.parsed.mid === 85000, 'advertised_comp parsed');
assert(reportToObservation(REPORT_FIXTURE_003, '003', '2026-06-26').observation === null, 'null advertised_comp -> no obs');
assert(reportToObservation('no machine summary', '009', '2026-06-01') === null, 'no fence -> null');
const jsonReport = '# Eval: JsonCo — Eng\n\n## Machine Summary\n\n```json\n{"company": "JsonCo", "role": "Eng", "advertised_comp": "100k EUR"}\n```\n';
assert(reportToObservation(jsonReport, '010', '2026-06-30') === null, 'json fence rejected (yamlStr cannot extract from JSON — see FENCE_RE comment)');
// generic ISO detection: any uppercase 3-letter token, not a hardcoded allowlist
const plnReport = '# Eval: PlnCo — Eng\n\n## Machine Summary\n\n```yaml\ncompany: "PlnCo"\nrole: "Eng"\nadvertised_comp: "450-500k PLN"\n```\n';
const rPln = reportToObservation(plnReport, '011', '2026-07-01');
assert(rPln.observation.currency === 'PLN' && rPln.observation.parsed.min === 450000, 'non-allowlist currency PLN detected, amount parsed');
// case-SENSITIVE detection: lowercase "eur" is UNKNOWN (see currencyGuess comment)
const lowerReport = '# Eval: LowCo — Eng\n\n## Machine Summary\n\n```yaml\ncompany: "LowCo"\nrole: "Eng"\nadvertised_comp: "100k eur"\n```\n';
const rLow = reportToObservation(lowerReport, '012', '2026-07-01');
assert(rLow.observation.currency === 'UNKNOWN' && rLow.observation.parsed.mid === 100000, 'lowercase currency token -> UNKNOWN, amount still parsed');
// fold — golden test
const apps = {
'001': { company: 'Acme', role: 'ML Eng' },
'002': { company: 'Globex', role: 'Data Eng' },
'003': { company: 'Initech', role: 'Platform' },
'004': { company: 'Umbrella', role: 'AI Eng' },
'005': { company: 'Hooli', role: 'AI Lead' },
};
const reportObs = [
{ num: '001', ...reportToObservation(REPORT_FIXTURE_001, '001', '2026-06-20').observation },
{ num: '002', ...reportToObservation(REPORT_FIXTURE_002, '002', '2026-06-25').observation },
{ num: '004', ...reportToObservation(REPORT_FIXTURE_004, '004', '2026-06-27').observation },
{ num: '005', ...reportToObservation(REPORT_FIXTURE_005, '005', '2026-06-28').observation },
];
// cross-currency fixture: advertised USD (report) + actual GBP + desired EUR (profile)
const crossCurrencyObs = parseObservations('004\t2026-06-30\tactual\t88k\tGBP\toffer-letter\tcross-currency on purpose');
const result = fold([...obs, ...reportObs, ...crossCurrencyObs], apps, { amount: '90k', currency: 'EUR' });
const a1 = result.applications.find(a => a.num === '001');
// trust precedence: contract 86k (2026-07-05) wins over offer-letter 84k even though
// BOTH lose to nothing — and specifically contract beats recruiter-verbal 90k
assert(a1.actual.value === 86000 && a1.actual.source === 'contract', '001 actual = contract 86k');
assert(a1.desired.value === 95000 && a1.desired.source === 'user', '001 desired = user 95k');
assert(a1.advertised.value === 85000 && a1.advertised.source === 'jd', '001 advertised = jd 85k');
// gaps: adv 85000 -> act 86000 = +1.18% ; desired 95000 -> act 86000 = -9.47%
assert(Math.abs(a1.advToActPct - 1.18) < 0.01, `001 adv->act, got ${a1.advToActPct}`);
assert(Math.abs(a1.desiredToActPct - (-9.47)) < 0.01, `001 desired->act, got ${a1.desiredToActPct}`);
// trajectory preserved
assert(a1.trail.filter(t => t.type === 'actual').length === 3, '001 keeps full actual trail');
const a2 = result.applications.find(a => a.num === '002');
// the later recruiter_verbal (underscore typo) 99k and toString (prototype key)
// 120k must NOT become effective — unrecognized sources are excluded from
// pickEffective (Object.hasOwn, not `in`) and reported instead
assert(a2.actual.source === 'recruiter-verbal' && a2.actual.value === 88000, '002 actual from verbal 88k, typo + prototype-key sources ignored');
assert(a2.desired.source === 'profile' && a2.desired.value === 90000, '002 desired falls back to profile');
// cross-currency guard: advertised USD vs actual GBP, desired EUR vs actual GBP
const a4 = result.applications.find(a => a.num === '004');
assert(a4.advertised.currency === 'USD' && a4.actual.currency === 'GBP', '004 mixed currencies folded');
assert(a4.advToActPct === null, `004 cross-currency adv->act pct must be null, got ${a4.advToActPct}`);
assert(a4.desiredToActPct === null, `004 cross-currency desired->act pct must be null, got ${a4.desiredToActPct}`);
const gbp = result.aggregates.byCurrency.GBP;
assert(gbp.confirmed === 1 && gbp.meanAdvToActPct === null && gbp.atOrAboveAdvertised === 0 && gbp.atOrAboveDesired === 0,
'GBP aggregates exclude cross-currency comparisons');
// data quality
assert(result.quality.orphans.length === 1 && result.quality.orphans[0].num === '007', 'orphan 007 reported');
assert(result.quality.unparseable.length === 1 && result.quality.unparseable[0].raw === '9ok', 'typo 9ok reported');
assert(result.quality.invalidSources.length === 2, `2 invalid sources reported, got ${result.quality.invalidSources.length}`);
assert(result.quality.invalidSources.some(s => s.num === '002' && s.type === 'actual' && s.source === 'recruiter_verbal'),
'unrecognized source recruiter_verbal reported in invalidSources');
// prototype-key source: `'toString' in tiers` is true via Object.prototype, so an
// `in` check would let it through and poison the trust sort — must land here instead
assert(result.quality.invalidSources.some(s => s.num === '002' && s.type === 'actual' && s.source === 'toString'),
'prototype-key source toString reported in invalidSources, not treated as trusted');
// unparseable profile target must be reported, not silently dropped
const badProfile = fold([], {}, { amount: 'competitive', currency: 'EUR' });
assert(badProfile.quality.unparseable.some(u => u.num === '*' && u.type === 'desired' && u.raw === 'competitive' && u.source === 'profile'),
'unparseable profile target reported');
const mm = result.quality.currencyMismatches;
assert(mm.length === 4, `4 currency mismatches reported, got ${mm.length}`);
assert(mm.some(m => m.num === '004' && m.comparison === 'advertised-vs-actual' && m.currencies[0] === 'USD' && m.currencies[1] === 'GBP'),
'advertised-vs-actual mismatch reported for 004');
assert(mm.some(m => m.num === '004' && m.comparison === 'desired-vs-actual' && m.currencies[0] === 'EUR' && m.currencies[1] === 'GBP'),
'desired-vs-actual mismatch reported for 004');
// UNKNOWN guard: advertised_comp without a currency token (guess UNKNOWN) vs actual
// logged with currency UNKNOWN — never comparable, even though the strings match
const a5 = result.applications.find(a => a.num === '005');
assert(a5.advertised.currency === 'UNKNOWN' && a5.actual.currency === 'UNKNOWN', '005 both sides UNKNOWN currency');
assert(a5.advToActPct === null, `005 UNKNOWN-vs-UNKNOWN adv->act pct must be null, got ${a5.advToActPct}`);
assert(a5.desiredToActPct === null, `005 EUR-vs-UNKNOWN desired->act pct must be null, got ${a5.desiredToActPct}`);
assert(mm.some(m => m.num === '005' && m.comparison === 'advertised-vs-actual' && m.currencies[0] === 'UNKNOWN' && m.currencies[1] === 'UNKNOWN'),
'UNKNOWN-vs-UNKNOWN skip reported for 005');
assert(mm.some(m => m.num === '005' && m.comparison === 'desired-vs-actual' && m.currencies[0] === 'EUR' && m.currencies[1] === 'UNKNOWN'),
'known-vs-UNKNOWN skip reported for 005');
const unk = result.aggregates.byCurrency.UNKNOWN;
assert(unk.confirmed === 1 && unk.meanAdvToActPct === null && unk.atOrAboveAdvertised === 0 && unk.atOrAboveDesired === 0,
'UNKNOWN bucket carries confirmed count only — no gap math from unproven currency');
// aggregates (EUR: 001 +1.18%, 002 -12%) grouped per currency
const eur = result.aggregates.byCurrency.EUR;
assert(eur.confirmed === 2, 'EUR two confirmed actuals');
assert(Math.abs(eur.meanAdvToActPct - (-5.41)) < 0.01, `EUR mean, got ${eur.meanAdvToActPct}`);
assert(eur.atOrAboveAdvertised === 1, 'EUR one at/above advertised');
assert(eur.atOrAboveDesired === 0, 'EUR none at/above desired');
// (company, role) grouping exists
assert(result.aggregates.byCompanyRole['Acme|ML Eng'].confirmed === 1, 'company+role grouping');
// legacy reports (no Machine Summary -> null company/role) must NOT collapse
// into a single shared "null|null" bucket — each keys per application
const legacyApps = { '101': { company: null, role: null }, '102': { company: null, role: null } };
const legacyObs = parseObservations([
'101\t2026-07-01\tactual\t80k\tEUR\toffer-letter\tlegacy report',
'102\t2026-07-02\tactual\t85k\tEUR\tcontract\tlegacy report',
].join('\n'));
const legacy = fold(legacyObs, legacyApps, null);
const legacyKeys = Object.keys(legacy.aggregates.byCompanyRole);
assert(!legacyKeys.includes('null|null'), 'no null|null bucket for legacy reports');
assert(legacyKeys.includes('#101') && legacyKeys.includes('#102'), `legacy apps get per-application buckets, got ${legacyKeys.join(',')}`);
assert(legacy.aggregates.byCompanyRole['#101'].confirmed === 1 && legacy.aggregates.byCompanyRole['#102'].confirmed === 1,
'each legacy bucket counts its own confirmed actual');
assert(legacy.aggregates.byCompanyRole['#101'].company === 'report #101' && legacy.aggregates.byCompanyRole['#101'].role !== null,
'legacy bucket display fields render without null');
console.log('salary-gap self-test OK (parser + report extraction + fold + aggregates + currency guard)');
}
// --- Real sources ---
const REPORT_FILE_RE = /^(\d{3})-.*-(\d{4}-\d{2}-\d{2})\.md$/;
function collectSources() {
const apps = {};
const observations = [];
if (existsSync(REPORTS_DIR)) {
for (const file of readdirSync(REPORTS_DIR)) {
const m = file.match(REPORT_FILE_RE);
if (!m) continue;
const [, num, date] = m;
let content;
try { content = readFileSync(join(REPORTS_DIR, file), 'utf-8'); } catch { continue; }
const r = reportToObservation(content, num, date);
if (r) {
apps[num] = { company: r.company, role: r.role };
if (r.observation) observations.push(r.observation);
} else {
// report exists but has no Machine Summary (legacy) — still a valid
// tracker row, so log observations against it are NOT orphans
apps[num] = { company: null, role: null };
}
}
}
if (existsSync(OBS_PATH)) {
observations.push(...parseObservations(readFileSync(OBS_PATH, 'utf-8')));
}
return { apps, observations };
}
function loadProfileDesired() {
const profilePath = join(CAREER_OPS, 'config/profile.yml');
if (!existsSync(profilePath)) return null;
try {
const profile = yaml.load(readFileSync(profilePath, 'utf-8'));
const comp = profile?.compensation;
if (!comp?.target_range) return null;
return { amount: String(comp.target_range), currency: comp.currency ? String(comp.currency) : null };
} catch {
return null; // unreadable profile is a non-event here; doctor.mjs owns that complaint
}
}
// --- Output ---
const fmtVal = (v) => (v >= 1000 && v % 500 === 0 ? `${v / 1000}k` : String(v));
const fmtEff = (e) => (e ? `${fmtVal(e.value)} ${e.currency || ''} (${e.source}, ${e.date})`.replace(' ', ' ') : '—');
const fmtPct = (p) => (p === null || p === undefined ? '—' : `${p >= 0 ? '+' : ''}${p.toFixed(1)}%`);
const daysOld = (date) => Math.max(0, Math.round((Date.now() - Date.parse(date)) / 86400000));
function printSummary(result) {
const { applications, aggregates, quality } = result;
console.log('\nSALARY GAP — desired / advertised / actual\n');
if (!applications.length) {
console.log(' No compensation observations yet.');
console.log(' Sources: reports/*.md Machine Summary `advertised_comp`,');
console.log(' data/salary-observations.tsv, config/profile.yml compensation.target_range.');
} else {
console.log(' Per application:');
for (const a of applications) {
const who = [a.company, a.role].filter(Boolean).join(' — ') || '(unknown company/role)';
console.log(` #${a.num} ${who}`);
console.log(` desired ${fmtEff(a.desired)}`);
console.log(` advertised ${fmtEff(a.advertised)}`);
console.log(` actual ${fmtEff(a.actual)}`);
if (a.advToActPct !== null || a.desiredToActPct !== null) {
console.log(` gap: advertised→actual ${fmtPct(a.advToActPct)}, desired→actual ${fmtPct(a.desiredToActPct)}`);
}
}
const currencies = Object.entries(aggregates.byCurrency);
if (currencies.length) {
console.log('\n Aggregates (per currency — no FX conversion):');
for (const [cur, agg] of currencies) {
console.log(` ${cur}: mean advertised→actual ${fmtPct(agg.meanAdvToActPct)}, median ${fmtPct(agg.medianAdvToActPct)}, at/above advertised ${agg.atOrAboveAdvertised}, at/above desired ${agg.atOrAboveDesired}`);
}
}
const companyRoles = Object.entries(aggregates.byCompanyRole);
if (companyRoles.length) {
console.log('\n By (company, role):');
for (const [, agg] of companyRoles) {
const gaps = agg.advToActPcts.length ? `, advertised→actual ${agg.advToActPcts.map(fmtPct).join(' / ')}` : '';
console.log(` ${agg.company}${agg.role}: ${agg.confirmed} confirmed actual${agg.confirmed === 1 ? '' : 's'}${gaps}`);
}
}
}
// Data quality — always printed, never smoothed over
console.log('\n Data quality:');
if (quality.unparseable.length) {
console.log(` ⚠ ${quality.unparseable.length} unparseable amount${quality.unparseable.length === 1 ? '' : 's'} (excluded from all math):`);
for (const u of quality.unparseable) console.log(` #${u.num} ${u.type}: "${u.raw}"`);
} else {
console.log(' unparseable amounts: none');
}
if (quality.invalidSources.length) {
console.log(` ⚠ ${quality.invalidSources.length} observation${quality.invalidSources.length === 1 ? '' : 's'} with unrecognized source (excluded from effective values — check for typos, e.g. recruiter_verbal vs recruiter-verbal):`);
for (const s of quality.invalidSources) console.log(` #${s.num} ${s.type}: source "${s.source}"`);
} else {
console.log(' unrecognized sources: none');
}
if (quality.orphans.length) {
console.log(` ⚠ ${quality.orphans.length} orphaned tracker#${quality.orphans.length === 1 ? '' : 's'} (observations without a matching report — renumbering/dedup can strand them):`);
for (const o of quality.orphans) console.log(` #${o.num} (${o.count} observation${o.count === 1 ? '' : 's'})`);
} else {
console.log(' orphaned observations: none');
}
if (quality.currencyMismatches.length) {
console.log(` ⚠ ${quality.currencyMismatches.length} cross-currency comparison${quality.currencyMismatches.length === 1 ? '' : 's'} skipped (no FX conversion — excluded from all gap math):`);
for (const m of quality.currencyMismatches) console.log(` #${m.num} ${m.comparison}: ${m.currencies[0]} vs ${m.currencies[1]}`);
} else {
console.log(' cross-currency comparisons skipped: none');
}
const currencies = Object.entries(result.aggregates.byCurrency);
if (currencies.length) {
for (const [cur, agg] of currencies) {
console.log(` ${cur}: n=${agg.confirmed} confirmed actual${agg.confirmed === 1 ? '' : 's'}, newest ${agg.newestActual} (${daysOld(agg.newestActual)} days old)`);
}
} else {
console.log(' confirmed actuals: none in any currency');
}
console.log(` applications without a confirmed actual: ${quality.withoutActual} of ${applications.length}`);
console.log('');
}
function main() {
if (selfTestMode) { selfTest(); return; }
const { apps, observations } = collectSources();
const result = fold(observations, apps, loadProfileDesired());
if (summaryMode) {
printSummary(result);
} else {
console.log(JSON.stringify(result, null, 2));
}
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
main();
}