audit-portals.mjs
Rama main · réplica de lectura del repositorio original.
#!/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 <path> # 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<object>, 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<object>} jobs
* @param {number} [n]
* @returns {Array<string>} "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<object>} current - rows from auditCompanies().
* @param {Array<object>} 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<object>} companies - tracked_companies entries.
* @param {{providers?: Map, httpCtx?: object, smallThreshold?: number, concurrency?: number}} [deps]
* @returns {Promise<Array<object>>} 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<object>} entries - tracked_companies and/or boards entries.
* @param {Map} providers - from loadProviders().
* @returns {{silent: Array<object>, handoff: Array<object>, unknownProvider: Array<object>}}
*/
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 <path> # 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: <plugin-id>` 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()
});
}