#!/usr/bin/env node /** * audit-portals.mjs — content audit for portals.yml. * * WHY THIS IS NOT verify-portals.mjs. That script asks "does this endpoint * answer with postings?" and is the right gate for a broken slug. It cannot ask * the question that actually costs coverage: *whose* postings are these? * * Two failures found on 2026-08-26 that verify-portals reported as healthy: * * - `Booking Holdings` pointed at the Workday board of the PARENT company — * 21 finance/legal roles in Norwalk. It answered 200 with postings, so it * passed. Booking.com's own board (145 product/engineering roles, a * different ATS entirely) was never scanned at all. * - Twelve entries carried `enabled: true` with a `careers_url` no provider * claims. `scan.mjs` skips those silently on every run, so they read as * coverage while contributing nothing. Talkdesk was one: its real board is * Greenhouse `talkdesk2`, 48 roles, one character off the slug nobody had. * * So this script fetches each enabled board through the SAME providers/ modules * the scanner uses and reports the evidence — provider, posting count, and * sample titles/locations — next to a verdict: * * ok board answers with a healthy number of postings * small answers, but under --small-threshold (default 5). Not an error: * a quiet board and a wrong board look identical from here, which * is exactly why the samples are printed. * empty answers with zero postings * no-provider entry is enabled but no provider claims it — scan.mjs skips it * error the fetch itself failed * * HONEST LIMIT: no heuristic reliably detects "right company, wrong entity". * A parent-company board is well-formed and full of real jobs. What this script * does is surface count + samples compactly enough that a human or an agent can * see it, and — with --baseline — flag the collapse that usually follows an ATS * migration. Treat `small` and a large negative drift as prompts to look, not * as verdicts. * * Usage: * node audit-portals.mjs # audit every enabled company * node audit-portals.mjs --summary # one line per company * node audit-portals.mjs --json # machine-readable, for --baseline * node audit-portals.mjs --company Adyen # audit a single company * node audit-portals.mjs --file # use a specific portals file * node audit-portals.mjs --baseline prev.json # flag boards that lost postings * node audit-portals.mjs --small-threshold 10 # what counts as a small board * node audit-portals.mjs --strict # exit 1 on any non-ok verdict * node audit-portals.mjs --queries # offline search-query drift advice * node audit-portals.mjs --help * * Network: only main() and auditCompanies() hit the network, and every call * goes through the injected provider layer, so the classification logic is * unit-testable offline. main() is guarded — importing this module runs nothing. */ import { existsSync, readFileSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { loadYaml } from './lib/yaml-document.mjs'; import { makeHttpCtx } from './providers/_http.mjs'; import { loadProviders, resolveProvider } from './providers/_registry.mjs'; import { mergeProviderPlugins } from './plugins/_engine.mjs'; import { flagValue, hasFlag, validateFlags } from './lib/cli-flags.mjs'; import { getCareerOpsRoot } from './path-resolver.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; import { applyEntryResponseCap } from './lib/entry-response-cap.mjs'; import { applyEntryTimeout } from './lib/entry-timeout.mjs'; import { DEFAULT_SMALL_THRESHOLD, isSmallBoard, smallBoardDetail } from './lib/small-board.mjs'; // Anchored, not cwd-relative. Both paths below used to be bare relative // strings, which silently audited nothing the moment the script was invoked // from anywhere but the repo root — `node /path/to/audit-portals.mjs` from a // home directory would throw on portals.yml and, worse, load zero providers // and report every board as `no-provider`. // // The two anchors are different roots and must stay that way (#4171). // `portals.yml` is user layer and follows the DATA root, which is wherever // CAREER_OPS_ROOT / CAREER_OPS_DATA_DIR or the `.career-ops-data` marker // points. `providers/` is system layer: it ships with the checkout and never // travels to a user's data root. Anchoring it to the data root reproduced the // exact failure the paragraph above describes — loadProviders() reads a // directory that does not exist, returns an empty registry, and the audit // reports `0 audited` and exits 0. scan.mjs already splits the two this way. const ROOT = getCareerOpsRoot(); const CODE_ROOT = dirname(fileURLToPath(import.meta.url)); const DEFAULT_PORTALS_PATH = process.env.CAREER_OPS_PORTALS || join(ROOT, 'portals.yml'); export const PROVIDERS_DIR = join(CODE_ROOT, 'providers'); // Shared with discover-ats.mjs so the two tools agree on what a small board is. export { DEFAULT_SMALL_THRESHOLD }; /** * A board that loses this fraction of its postings since the baseline is * flagged. An ATS migration usually reads as a near-total collapse (the old * board keeps answering, empty), not a gentle decline, so this is set well * above normal hiring churn to keep the signal quiet. */ export const DEFAULT_DRIFT_FRACTION = 0.5; /** How many postings to keep as evidence per board. Enough to recognise a company. */ const SAMPLE_SIZE = 3; /** * Only page 1 per board. This is an audit, not a scan: one page is enough to * answer "is anything here, and does it look like this company", and a full * sweep of 170 enterprise boards would be both slow and rude. */ const AUDIT_MAX_PAGES = 1; export const VERDICTS = ['no-provider', 'error', 'empty', 'small', 'ok']; /** * Classify one board's fetch result. * * Pure — no network, no clock. `jobs` is whatever the provider returned; * `error` is set instead when the fetch threw. * * @param {{jobs?: Array, error?: string, provider?: string|null}} result * @param {{smallThreshold?: number}} [opts] * @returns {{verdict: string, count: number|null, detail: string}} */ export function classifyBoard(result, { smallThreshold = DEFAULT_SMALL_THRESHOLD } = {}) { if (!result || typeof result !== 'object') { return { verdict: 'error', count: null, detail: 'no result' }; } if (!result.provider) { return { verdict: 'no-provider', count: null, // Two different defects wear this verdict, and the distinction decides how // urgent it is. Without a scan_method the entry vanishes into scan.mjs's // "N skipped — no provider matched" counter and nothing names it. With // `scan_method: websearch` it is at least announced in the agent-handoff // list — deliberate, but it costs agent tokens and returns nothing // zero-token, so it is still worth a board if one exists. detail: result.scanMethod ? `no provider claims this entry — scan.mjs hands it to the agent (scan_method: ${result.scanMethod})` : 'no provider claims this entry and it declares no scan_method — scan.mjs drops it into an unnamed skip count', }; } if (result.error) { return { verdict: 'error', count: null, detail: String(result.error).slice(0, 120) }; } const jobs = Array.isArray(result.jobs) ? result.jobs : []; if (jobs.length === 0) return { verdict: 'empty', count: 0, detail: 'board answers with zero postings' }; // A threshold of 0 disables the tier rather than flagging every board: the // `<=` below would otherwise never fire, which is the same thing, but saying // so here keeps `--small-threshold 0` from reading as a bug. if (isSmallBoard(jobs.length, smallThreshold)) { return { verdict: 'small', count: jobs.length, detail: smallBoardDetail(jobs.length) }; } return { verdict: 'ok', count: jobs.length, detail: `${jobs.length} postings` }; } /** * Compact evidence rows a reader can use to recognise the employer. * * @param {Array} jobs * @param {number} [n] * @returns {Array} "Title · Location" strings. */ export function sampleJobs(jobs, n = SAMPLE_SIZE) { if (!Array.isArray(jobs)) return []; return jobs.slice(0, n).map((j) => { const title = String(j?.title || '(sin título)').replace(/\s+/g, ' ').trim().slice(0, 60); const loc = String(j?.location || '').replace(/\s+/g, ' ').trim().slice(0, 34); return loc ? `${title} · ${loc}` : title; }); } /** * Compare a fresh audit against a previous --json run. * * Only DROPS are reported. A board that grew, appeared, or vanished from the * config is not a coverage regression, and reporting those would bury the one * signal worth acting on. A baseline count of 0 is skipped too: every recovery * from zero would otherwise register as an infinite gain and, more importantly, * a 0→0 board is already reported as `empty` on its own. * * @param {Array} current - rows from auditCompanies(). * @param {Array} baseline - rows from a previous run. * @param {{fraction?: number}} [opts] * @returns {Array<{name: string, before: number, after: number, lost: number}>} */ export function diffAgainstBaseline(current, baseline, { fraction = DEFAULT_DRIFT_FRACTION } = {}) { const before = new Map(); for (const row of Array.isArray(baseline) ? baseline : []) { if (row && typeof row.name === 'string' && Number.isFinite(row.count)) before.set(row.name, row.count); } const drops = []; for (const row of Array.isArray(current) ? current : []) { if (!row || typeof row.name !== 'string' || !Number.isFinite(row.count)) continue; const was = before.get(row.name); if (!Number.isFinite(was) || was <= 0) continue; const lost = was - row.count; if (lost > 0 && lost / was >= fraction) { drops.push({ name: row.name, before: was, after: row.count, lost }); } } return drops.sort((a, b) => b.lost - a.lost); } /** * Audit each enabled tracked company through the scanner's own provider layer. * * `local-parser` is skipped for the same reason verify-portals skips it: an * audit must stay network-only and never execute a command configured in a * portals file it did not write. * * @param {Array} companies - tracked_companies entries. * @param {{providers?: Map, httpCtx?: object, smallThreshold?: number, concurrency?: number}} [deps] * @returns {Promise>} One row per enabled company. */ export async function auditCompanies(companies, { providers = null, httpCtx = null, smallThreshold = DEFAULT_SMALL_THRESHOLD, concurrency = 6, } = {}) { const list = (Array.isArray(companies) ? companies : []).filter( (c) => c && typeof c === 'object' && c.enabled !== false, ); const rows = []; const queue = [...list]; const worker = async () => { while (queue.length > 0) { const entry = queue.shift(); const name = typeof entry.name === 'string' ? entry.name : '(unnamed)'; let resolved = null; if (providers && providers.size > 0) { resolved = resolveProvider(entry, providers, { skipIds: ['local-parser'] }); } const careersUrl = typeof entry.careers_url === 'string' ? entry.careers_url : ''; // An explicit provider id that does not exist used to fall through the // null-provider check, so a typo and a URL nothing claims printed the // same "unnamed skip" line. The error is already on the resolve result. if (resolved?.error) { const providerLabel = typeof entry.provider === 'string' ? entry.provider : 'unknown'; const { verdict, count, detail } = classifyBoard({ provider: providerLabel, error: resolved.error, scanMethod: entry.scan_method || null, }, { smallThreshold }); rows.push({ name, provider: providerLabel, careers_url: careersUrl, verdict, count, detail, samples: [], }); continue; } // local-parser is claimed by scan.mjs. This audit still must not run the // command. Reporting it as no-provider said scan would drop a board scan // actually reads. if ((!resolved || !resolved.provider) && providers && providers.size > 0) { const full = resolveProvider(entry, providers); if (full?.provider?.id === 'local-parser') { rows.push({ name, provider: 'local-parser', careers_url: careersUrl, verdict: 'ok', count: null, detail: 'local parser; this audit does not run it. scan.mjs will.', samples: [], }); continue; } } const providerId = resolved?.provider?.id || null; let result = { provider: providerId, scanMethod: entry.scan_method || null }; if (providerId) { try { // Same per-entry response cap scan.mjs applies, so a board that sets // max_response_mb is audited with the limit it is scanned with. The ctx is // copied because the cap and the timeout replace its fetchers, and httpCtx is shared. const ctx = applyEntryTimeout(applyEntryResponseCap({ ...(httpCtx || { ...makeHttpCtx(), maxPages: AUDIT_MAX_PAGES }) }, entry), entry); result.jobs = (await resolved.provider.fetch(entry, ctx)) || []; } catch (err) { result.error = err?.message || String(err); } } const { verdict, count, detail } = classifyBoard(result, { smallThreshold }); rows.push({ name, provider: providerId || '—', careers_url: careersUrl, verdict, count, detail, samples: sampleJobs(result.jobs || []), }); } }; await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker)); rows.sort((a, b) => VERDICTS.indexOf(a.verdict) - VERDICTS.indexOf(b.verdict) || a.name.localeCompare(b.name)); return rows; } /** * The offline half of this audit: which enabled entries does no provider claim? * * Split out from auditCompanies() because it needs no network at all — provider * resolution is pure config matching — which is what makes it safe to run inside * verify-pipeline.mjs's health check, where 170 live board fetches would not be. * It is also the higher-yield half: a board that answers with the wrong * company's jobs still needs a human to notice, but an entry nothing claims is * unambiguously dead config, provable without leaving the machine. * * Three buckets, because they are three different bugs with three different * fixes: * * silent no provider, no scan_method. The real defect — scan.mjs * folds it into "N skipped" and never names it. * handoff declares `scan_method`, so scan.mjs prints it in the * agent-handoff list. Deliberate, but it costs agent tokens * and yields nothing zero-token, so it is still worth a board. * unknownProvider `provider:` names an id no module exports — almost always a * typo. scan.mjs does report this one loudly, but it is dead * config until someone fixes the string. * * @param {Array} entries - tracked_companies and/or boards entries. * @param {Map} providers - from loadProviders(). * @returns {{silent: Array, handoff: Array, unknownProvider: Array}} */ export function findUnclaimedEntries(entries, providers) { const silent = []; const handoff = []; const unknownProvider = []; for (const entry of Array.isArray(entries) ? entries : []) { if (!entry || typeof entry !== 'object' || entry.enabled === false) continue; if (typeof entry.name !== 'string' || !entry.name.trim()) continue; // No skipIds here, unlike auditCompanies(): that one refuses to RUN a // configured local-parser command, but scan.mjs does claim those entries, so // excluding them would report a working parser as dead config. The question // this function answers is "would scan.mjs claim it?", so it resolves // exactly the way scan.mjs does. const resolved = providers && providers.size > 0 ? resolveProvider(entry, providers) : null; if (resolved?.provider) continue; const row = { name: entry.name, scanMethod: entry.scan_method || null, careers_url: typeof entry.careers_url === 'string' ? entry.careers_url : '', error: resolved?.error || null, }; if (row.error) unknownProvider.push(row); else if (row.scanMethod) handoff.push(row); else silent.push(row); } return { silent, handoff, unknownProvider }; } /** Read tracked_companies out of a portals file. Throws on a missing file. */ export function loadCompanies(filePath = DEFAULT_PORTALS_PATH) { if (!existsSync(filePath)) throw new Error(`portals file not found: ${filePath}`); const cfg = loadYaml(readFileSync(filePath, 'utf-8')) || {}; return Array.isArray(cfg.tracked_companies) ? cfg.tracked_companies : []; } // This is lexical advice, not semantic validation: synonyms and another // language can legitimately share no words. Never rewrite a user's query. function queryWords(text) { return String(text).normalize('NFKC').toLowerCase() .match(/[\p{L}\p{N}][\p{L}\p{M}\p{N}+#]*/gu) || []; } export function findStaleScanQueries(companies, positiveKeywords) { const keywords = Array.isArray(positiveKeywords) ? positiveKeywords.filter((v) => typeof v === 'string' && v.trim()) : []; const targets = new Set(keywords.flatMap(queryWords)); if (!targets.size) return []; const findings = []; for (const company of Array.isArray(companies) ? companies : []) { if (!company || company.enabled === false || company.scan_method !== 'websearch') continue; // Follow scan.mjs's handoff precedence, but a careers URL alone isn't a query. const query = company.scan_query || company.search_query; if (typeof query !== 'string' || !query.trim()) continue; const terms = query .replace(/\bNOT\s+(?:"[^"]*"|\([^)]*\)|\S+)/g, ' ') .replace(/(?:^|[\s(])-(?:"[^"]*"|\([^)]*\)|[^\s)]+)/g, ' ') .replace(/\b(?:site|inurl|filetype):(?:"[^"]*"|\S+)/gi, ' ') .replace(/https?:\/\/\S+/gi, ' ') .replace(/\b(?:OR|AND|NOT)\b/g, ' '); const words = queryWords(terms); if (!words.length || words.some((word) => targets.has(word))) continue; findings.push({ name: typeof company.name === 'string' ? company.name : '(unnamed)', query, detail: 'possibly stale — no positive-keyword overlap with current targeting', }); } return findings; } const ICON = { ok: '✅', small: '🟡', empty: '⚪', 'no-provider': '🚨', error: '❌' }; const KNOWN_FLAGS = [ '--summary', '--json', '--strict', '--queries', '--company', '--file', '--baseline', '--small-threshold', '--help', '-h', ]; const VALUE_FLAGS = ['--company', '--file', '--baseline', '--small-threshold']; const USAGE = `Usage: node audit-portals.mjs # audit every enabled company node audit-portals.mjs --summary # one line per company node audit-portals.mjs --json # machine-readable, for --baseline node audit-portals.mjs --company Adyen # audit a single company node audit-portals.mjs --file # use a specific portals file node audit-portals.mjs --baseline prev.json # flag boards that lost postings node audit-portals.mjs --small-threshold 10 # what counts as a small board node audit-portals.mjs --strict # exit 1 on any non-ok verdict node audit-portals.mjs --queries # offline advisory; never rewrites queries node audit-portals.mjs --help # print this usage block and exit`; async function main() { const args = process.argv.slice(2); // validateFlags handles --help itself, and handles it LAST — after the // unrecognized-flag and missing-operand checks. The manual pre-check that used // to sit here inverted that order, which is the bug CodeRabbit caught on // #2745/#2746: `--help --bogus` exited 0 having never looked at `--bogus`, // and `--file --help` printed usage instead of reporting the missing operand. // // requireOperand: nothing here rejects a missing operand. A trailing `--file` // fell back to the default portals.yml, a trailing `--company` audited every // board instead of one, and a trailing `--baseline` skipped the comparison // entirely — each of them silently, at exit 0, reporting on inputs nobody // asked for. `--small-threshold` keeps its own SHAPE check below (a // non-negative number); this only adds the presence check it lacked. validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true }); const filePath = flagValue(args, '--file') || DEFAULT_PORTALS_PATH; const only = flagValue(args, '--company'); const asJson = hasFlag(args, '--json'); const summary = hasFlag(args, '--summary'); const strict = hasFlag(args, '--strict'); const rawThreshold = flagValue(args, '--small-threshold'); const smallThreshold = rawThreshold === undefined ? DEFAULT_SMALL_THRESHOLD : Number(rawThreshold); if (!Number.isFinite(smallThreshold) || smallThreshold < 0) { console.error(`Error: --small-threshold expects a non-negative number, got "${rawThreshold}"`); process.exit(1); } let companies = loadCompanies(filePath); if (only) { const needle = only.toLowerCase(); companies = companies.filter((c) => String(c?.name || '').toLowerCase().includes(needle)); if (companies.length === 0) { console.error(`Error: no tracked company matches "${only}" in ${filePath}`); process.exit(1); } } if (hasFlag(args, '--queries')) { const cfg = loadYaml(readFileSync(filePath, 'utf-8')) || {}; const warnings = findStaleScanQueries(companies, cfg.title_filter?.positive); if (asJson) console.log(JSON.stringify({ warnings }, null, 2)); else { console.log('Search-query drift advice (lexical overlap only; synonyms may differ):'); for (const warning of warnings) { console.log(` ${warning.name}: ${warning.detail}\n ${warning.query}`); } console.log(`${warnings.length} query warning(s). Review manually; no files changed.`); } return; // advisory even with --strict; no providers loaded or network calls } const providers = await loadProviders(PROVIDERS_DIR); // Fold in enabled provider plugins as scan.mjs does (#4026). Without them // every `provider: ` entry audits as `no-provider` while the // scanner scans it. No-op when config/plugins.yml is absent. await mergeProviderPlugins(providers, { root: CODE_ROOT, dataRoot: ROOT }); const rows = await auditCompanies(companies, { providers, smallThreshold }); // From here on, a failure sets process.exitCode and returns instead of // calling process.exit(1). On Windows, Node 24 aborts when process.exit() // runs right after a request (nodejs/node#56645): it prints a libuv assertion // and exits with a crash code instead of 1. Measured 2026-11-05 on Node // 24.14.1: --strict on a Greenhouse, Ashby, SmartRecruiters or Workday board // that does not exist aborted 4 times out of 4, and so did a missing // --baseline file. Returning lets the process exit 1 once the event loop drains. let drops = []; const baselinePath = flagValue(args, '--baseline'); if (baselinePath) { if (!existsSync(baselinePath)) { console.error(`Error: baseline file not found: ${baselinePath}`); process.exitCode = 1; return; } try { const prev = JSON.parse(readFileSync(baselinePath, 'utf-8')); drops = diffAgainstBaseline(rows, Array.isArray(prev) ? prev : prev.rows, {}); } catch (err) { console.error(`Error: could not read baseline ${baselinePath}: ${err.message}`); process.exitCode = 1; return; } } if (asJson) { console.log(JSON.stringify(drops.length ? { rows, drops } : rows, null, 2)); } else { console.log(`audit-portals: ${filePath}\n`); for (const r of rows) { if (summary) { console.log(` ${ICON[r.verdict] || '·'} ${r.name} — ${r.provider} (${r.detail})`); continue; } if (r.verdict === 'ok') continue; // full mode reports only what needs a look console.log(` ${ICON[r.verdict] || '·'} ${r.name} — ${r.provider} (${r.detail})`); for (const s of r.samples) console.log(` ${s}`); if (r.careers_url) console.log(` ${r.careers_url}`); } const tally = {}; for (const r of rows) tally[r.verdict] = (tally[r.verdict] || 0) + 1; const parts = VERDICTS.filter((v) => tally[v]).map((v) => `${tally[v]} ${v}`); console.log(`\n${rows.length} audited: ${parts.join(', ')}`); if (!summary && tally.ok) console.log(`(${tally.ok} healthy boards not listed — use --summary to see them)`); if (drops.length > 0) { console.log(`\n🚨 ${drops.length} board(s) lost postings since the baseline:`); for (const d of drops) console.log(` ${d.name}: ${d.before} → ${d.after} (-${d.lost})`); } } if (strict) { const bad = rows.filter((r) => r.verdict !== 'ok').length; if (bad > 0 || drops.length > 0) { // Name whichever condition actually fired. A drift-only failure once // printed "0 board(s) need attention" right after listing the drop — // technically true of the verdicts, and useless to the reader. if (!asJson) { const reasons = []; if (bad > 0) reasons.push(`${bad} board(s) with a non-ok verdict`); if (drops.length > 0) reasons.push(`${drops.length} board(s) below baseline`); console.log(`\n🔴 ${reasons.join(' and ')} (--strict).`); } process.exitCode = 1; } } } // Only run main() when invoked directly, not when imported by tests. // isMainModule canonicalizes BOTH sides (#3170); the hand-rolled comparison // this replaces compared a realpath-resolved import.meta.url against a raw // argv[1], so reaching the script through a symlink skipped the CLI tail and // exited 0 having audited nothing. tests/main-guard-convention.test.mjs enforces // the helper — it caught this file. if (isMainModule(import.meta.url)) { main().catch((err) => { console.error(`audit-portals failed: ${err.message}`); process.exitCode = 1; // not process.exit(1), for the reason given after auditCompanies() in main() }); }