Files
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

331 lines
13 KiB
JavaScript

#!/usr/bin/env node
/**
* process-quality.mjs — Recruiting-Process Friction Aggregator for career-ops
*
* Parses data/active-interviews.md, extracts inline `[process-friction]` tags
* from the Notes column, and aggregates them per company into a friction
* signal — independent of company/role fit (#960) and interviewer red-flag
* behavior (#1232). This tracks a third, distinct axis: is the *recruiting
* process itself* (scheduling, communication clarity, tooling) well-run?
*
* Tagging convention (candidate-authored, in the Notes column of
* data/active-interviews.md):
* [process-friction] — friction occurred, no detail
* [process-friction: <short reason>] — friction occurred, with detail
*
* This is a company/process-level signal only — never tied to a named
* individual recruiter. See issue #1466.
*
* Run: node process-quality.mjs (JSON to stdout)
* node process-quality.mjs --summary (human-readable table)
* node process-quality.mjs --min-threshold 2 (min total interviews per company to report)
* node process-quality.mjs --file path/to/active-interviews.md (override the data path; test isolation)
* node process-quality.mjs --self-test
*
* Issue #1466 — github.com/santifer/career-ops
*/
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
const CAREER_OPS = dirname(fileURLToPath(import.meta.url));
const DEFAULT_ACTIVE_INTERVIEWS_PATH = existsSync(join(CAREER_OPS, 'data/active-interviews.md'))
? join(CAREER_OPS, 'data/active-interviews.md')
: join(CAREER_OPS, 'active-interviews.md');
const FRICTION_TAG = /\[process-friction(?::\s*([^\]]+))?\]/i;
// --- CLI args ---
const args = process.argv.slice(2);
const summaryMode = args.includes('--summary');
const selfTestMode = args.includes('--self-test');
// --file overrides the data path — mirrors validate-portals.mjs / verify-portals.mjs's
// --file convention. Primarily for test isolation: it lets tests point at a
// controlled temp path instead of depending on whatever data/active-interviews.md
// happens to exist (or not) in the caller's real workspace.
const fileIdx = args.indexOf('--file');
const ACTIVE_INTERVIEWS_PATH = fileIdx !== -1 && args[fileIdx + 1] !== undefined
? args[fileIdx + 1]
: DEFAULT_ACTIVE_INTERVIEWS_PATH;
const minThresholdIdx = args.indexOf('--min-threshold');
const rawMinThreshold = minThresholdIdx !== -1 && args[minThresholdIdx + 1] !== undefined
? parseInt(args[minThresholdIdx + 1], 10)
: 1;
// Clamped here (not just inside aggregateProcessQuality) so printSummary's
// displayed threshold always matches the threshold actually applied.
const MIN_THRESHOLD = Number.isFinite(rawMinThreshold) && rawMinThreshold >= 0 ? rawMinThreshold : 1;
// Case-insensitive column lookup shared by extractFriction and
// aggregateProcessQuality — the header wording comes from a candidate-edited
// markdown file, so "Notes" vs "notes" vs " Notes " must all resolve the
// same way. Centralized here so both call sites can never drift apart.
function findColumn(row, name) {
const key = Object.keys(row || {}).find(k => k.trim().toLowerCase() === name);
return key ? String(row[key] ?? '') : '';
}
// --- Parse data/active-interviews.md ---
//
// Table format: `| Company | Role | Round | Date/Time | Interviewer | Status | Notes |`
// A separator row (all dashes/colons/pipes) follows the header and is skipped.
// Only well-formed rows (same column count as the header) are kept; malformed
// rows are silently dropped rather than crashing the parser.
//
// Only the FIRST contiguous block of pipe-formatted lines is parsed as the
// table. This intentionally stops at the first non-table line rather than
// collecting every pipe-formatted line in the file — a document with more
// than one markdown table (e.g. a second table added later, or Landmines-
// style tables from other files if ever concatenated) must not have its
// unrelated rows merged into the active-interviews result.
//
// Exported so external tests can call parseActiveInterviews() directly on a
// markdown string.
export function parseActiveInterviews(content) {
if (typeof content !== 'string' || !content.trim()) return [];
const lines = content.split('\n');
const isTableLine = line => /^\s*\|.*\|\s*$/.test(line);
const startIdx = lines.findIndex(isTableLine);
if (startIdx === -1) return [];
const tableLines = [];
for (let i = startIdx; i < lines.length; i++) {
if (!isTableLine(lines[i])) break;
tableLines.push(lines[i]);
}
if (tableLines.length < 2) return [];
const splitRow = line =>
line
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map(cell => cell.trim());
const isSeparatorRow = cells => cells.every(cell => /^:?-+:?$/.test(cell));
const header = splitRow(tableLines[0]);
const colCount = header.length;
if (colCount === 0) return [];
const rows = [];
for (const line of tableLines.slice(1)) {
const cells = splitRow(line);
if (isSeparatorRow(cells)) continue;
if (cells.length !== colCount) continue;
const row = {};
header.forEach((col, i) => {
row[col] = cells[i];
});
rows.push(row);
}
return rows;
}
// --- Friction extraction ---
//
// Reads the Notes column (case-insensitive lookup, since header wording is
// candidate-editable free text) and pulls out the [process-friction] tag if
// present. Returns { hasFriction, reason } — reason is '' when the tag is
// present without a detail string.
export function extractFriction(row) {
if (!row || typeof row !== 'object') return { hasFriction: false, reason: '' };
const notes = findColumn(row, 'notes');
const match = notes.match(FRICTION_TAG);
if (!match) return { hasFriction: false, reason: '' };
return { hasFriction: true, reason: (match[1] || '').trim() };
}
// --- Core aggregation ---
//
// Groups rows by company (case-insensitive, trimmed), counts total
// interview rows and friction-tagged rows per company, and returns only
// companies with totalInterviews >= minThreshold. Companies are sorted by
// frictionCount descending, then frictionRate descending, then company name
// ascending (stable, deterministic ordering for tests and CLI output).
//
// Exported so external tests can call aggregateProcessQuality() directly on
// a parsed row list.
export function aggregateProcessQuality(rows, minThreshold = 1) {
if (!Array.isArray(rows)) return [];
const threshold = Number.isFinite(minThreshold) && minThreshold >= 0 ? minThreshold : 1;
const companyKey = row => findColumn(row, 'company').trim();
const byCompany = new Map();
for (const row of rows) {
if (!row || typeof row !== 'object') continue;
const company = companyKey(row);
if (!company) continue;
const dedupeKey = company.toLowerCase();
if (!byCompany.has(dedupeKey)) {
byCompany.set(dedupeKey, { company, total: 0, frictionCount: 0, reasons: [] });
}
const entry = byCompany.get(dedupeKey);
entry.total += 1;
const { hasFriction, reason } = extractFriction(row);
if (hasFriction) {
entry.frictionCount += 1;
if (reason) entry.reasons.push(reason);
}
}
const results = [...byCompany.values()]
.filter(entry => entry.total >= threshold)
.map(entry => ({
company: entry.company,
totalInterviews: entry.total,
frictionCount: entry.frictionCount,
frictionRate: entry.total > 0 ? Math.round((entry.frictionCount / entry.total) * 100) / 100 : 0,
reasons: entry.reasons,
}));
results.sort((a, b) => {
if (b.frictionCount !== a.frictionCount) return b.frictionCount - a.frictionCount;
if (b.frictionRate !== a.frictionRate) return b.frictionRate - a.frictionRate;
return a.company.localeCompare(b.company);
});
return results;
}
function loadActiveInterviews(path = ACTIVE_INTERVIEWS_PATH) {
if (!existsSync(path)) return [];
return parseActiveInterviews(readFileSync(path, 'utf-8'));
}
// --- Summary mode ---
function printSummary(signals) {
console.log(`\n${'='.repeat(78)}`);
console.log(' Process Quality Signal — career-ops');
console.log(` min threshold: ${MIN_THRESHOLD} interview(s) | companies: ${signals.length}`);
console.log(`${'='.repeat(78)}\n`);
if (signals.length === 0) {
console.log(' No process-friction signal found (or no companies met the threshold).\n');
return;
}
const header =
' ' +
'Company'.padEnd(26) +
'Interviews'.padEnd(12) +
'Friction'.padEnd(10) +
'Rate';
console.log(header);
console.log(' ' + '-'.repeat(70));
for (const s of signals) {
const company = (s.company || '').substring(0, 24).padEnd(26);
const total = String(s.totalInterviews).padEnd(12);
const friction = String(s.frictionCount).padEnd(10);
const rate = `${Math.round(s.frictionRate * 100)}%`;
console.log(' ' + company + total + friction + rate);
for (const reason of s.reasons) {
console.log(` ↳ ${reason}`);
}
}
console.log('');
}
// --- Self-test ---
function runSelfTest() {
const md = [
'# Active Interviews',
'',
'| Company | Role | Round | Date/Time | Interviewer | Status | Notes |',
'|---------|------|-------|-----------|-------------|--------|-------|',
'| Acme | Backend Engineer | Prescreen | 2026-06-01 | Jane (recruiter) | Scheduled | Clean process, no issues |',
'| Acme | Backend Engineer | Round 1 | 2026-06-08 | Panel | Scheduled | Confirmed quickly, no friction |',
'| Beta Corp | Coordinator | Prescreen | 2026-06-10 | Sasha (bot) | Scheduled | [process-friction: stated availability overridden twice before confirming] |',
'| Beta Corp | Coordinator | Round 1 | 2026-06-17 | HM | Scheduled | Went smoothly this round |',
'| Gamma Inc | Analyst | Prescreen | 2026-06-12 | Recruiter | Rejected | [process-friction] |',
'| Malformed row with too few cells |',
].join('\n');
const rows = parseActiveInterviews(md);
const signals = aggregateProcessQuality(rows, 1);
let pass = 0;
let fail = 0;
const check = (cond, label) => {
if (cond) { pass += 1; } else { fail += 1; console.error(` FAIL: ${label}`); }
};
check(rows.length === 5, 'parses 5 well-formed rows, drops the malformed one');
const beta = signals.find(s => s.company === 'Beta Corp');
check(!!beta, 'Beta Corp appears in aggregated signals');
if (beta) {
check(beta.totalInterviews === 2, 'Beta Corp has 2 total interview rows');
check(beta.frictionCount === 1, 'Beta Corp has 1 friction-tagged row');
check(beta.frictionRate === 0.5, 'Beta Corp friction rate is 0.5');
check(beta.reasons.length === 1 && beta.reasons[0].includes('overridden'), 'Beta Corp friction reason captured');
}
const gamma = signals.find(s => s.company === 'Gamma Inc');
check(!!gamma, 'Gamma Inc appears in aggregated signals');
if (gamma) {
check(gamma.frictionCount === 1, 'Gamma Inc has 1 friction-tagged row (bare tag, no reason)');
check(gamma.reasons.length === 0, 'bare [process-friction] tag produces no reason string');
}
const acme = signals.find(s => s.company === 'Acme');
check(!!acme, 'Acme appears in aggregated signals');
if (acme) {
check(acme.frictionCount === 0, 'Acme has 0 friction (no tags present)');
check(acme.frictionRate === 0, 'Acme friction rate is 0');
}
check(aggregateProcessQuality([]).length === 0, 'empty input returns no signals');
check(aggregateProcessQuality(null).length === 0, 'null input returns no signals (no crash)');
check(parseActiveInterviews('').length === 0, 'empty markdown returns no rows');
check(parseActiveInterviews(null).length === 0, 'non-string input returns no rows (no crash)');
// A second, unrelated table later in the same document must NOT be merged
// into the parsed rows — only the first contiguous table block is parsed.
const twoTablesMd = md + '\n\nSome other section.\n\n' +
'| Foo | Bar |\n|-----|-----|\n| unrelated | table |\n';
const twoTablesRows = parseActiveInterviews(twoTablesMd);
check(twoTablesRows.length === 5, 'a second markdown table later in the document is not merged in');
check(!twoTablesRows.some(r => r.Company === undefined && 'Foo' in r), 'second-table columns (Foo/Bar) never appear in the result');
// Threshold filtering: companies with fewer interviews than minThreshold are excluded.
const highThreshold = aggregateProcessQuality(rows, 2);
check(highThreshold.some(s => s.company === 'Beta Corp'), 'threshold=2: Beta Corp (2 rows) included');
check(highThreshold.some(s => s.company === 'Acme'), 'threshold=2: Acme (2 rows) included');
check(!highThreshold.some(s => s.company === 'Gamma Inc'), 'threshold=2: Gamma Inc (1 row) excluded');
console.log(`\n process-quality 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 (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
if (selfTestMode) {
runSelfTest();
}
const rows = loadActiveInterviews();
const signals = aggregateProcessQuality(rows, MIN_THRESHOLD);
if (summaryMode) {
printSummary(signals);
} else {
console.log(JSON.stringify({
metadata: {
minThreshold: MIN_THRESHOLD,
totalRows: rows.length,
companies: signals.length,
},
signals,
}, null, 2));
}
}