#!/usr/bin/env node /** * verify-pipeline.mjs — Health check for career-ops pipeline integrity * * Checks: * 1. All statuses are canonical (per states.yml) * 2. No duplicate company+role entries * 3. All report links point to existing files * 4. Scores match format X.XX/5 or N/A or DUP * 5. All rows have proper pipe-delimited format * 6. No pending TSVs in tracker-additions/ (only in merged/ or archived/) * 7. states.yml canonical IDs for cross-system consistency * 8. Stale report-number reservation sentinels are garbage-collected * 9. No two report files cover the same company+role (warning — see #1425) * 10. Every report file has a tracker row referencing it (warning — see #1425) * * Run: node career-ops/verify-pipeline.mjs */ import { readFileSync, readdirSync, existsSync, mkdirSync, unlinkSync, statSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const CAREER_OPS = dirname(fileURLToPath(import.meta.url)); // Support both layouts: data/applications.md (boilerplate) and applications.md (original). // CAREER_OPS_TRACKER overrides the path (used by tests and non-standard layouts). const APPS_FILE = process.env.CAREER_OPS_TRACKER ? process.env.CAREER_OPS_TRACKER : existsSync(join(CAREER_OPS, 'data/applications.md')) ? join(CAREER_OPS, 'data/applications.md') : join(CAREER_OPS, 'applications.md'); const ADDITIONS_DIR = join(CAREER_OPS, 'batch/tracker-additions'); // CAREER_OPS_REPORTS overrides the reports dir (used by tests, mirrors CAREER_OPS_TRACKER). const REPORTS_DIR = process.env.CAREER_OPS_REPORTS || join(CAREER_OPS, 'reports'); const STATES_FILE = existsSync(join(CAREER_OPS, 'templates/states.yml')) ? join(CAREER_OPS, 'templates/states.yml') : join(CAREER_OPS, 'states.yml'); // Ensure required directories exist (fresh setup) mkdirSync(join(CAREER_OPS, 'data'), { recursive: true }); mkdirSync(REPORTS_DIR, { recursive: true }); const CANONICAL_STATUSES = [ 'evaluated', 'applied', 'responded', 'interview', 'offer', 'rejected', 'discarded', 'skip', ]; const ALIASES = { 'evaluada': 'evaluated', 'condicional': 'evaluated', 'hold': 'evaluated', 'evaluar': 'evaluated', 'verificar': 'evaluated', 'aplicado': 'applied', 'enviada': 'applied', 'aplicada': 'applied', 'applied': 'applied', 'sent': 'applied', 'respondido': 'responded', 'entrevista': 'interview', 'oferta': 'offer', 'rechazado': 'rejected', 'rechazada': 'rejected', 'descartado': 'discarded', 'descartada': 'discarded', 'cerrada': 'discarded', 'cancelada': 'discarded', 'no aplicar': 'skip', 'no_aplicar': 'skip', 'monitor': 'skip', 'geo blocker': 'skip', }; let errors = 0; let warnings = 0; function error(msg) { console.log(`❌ ${msg}`); errors++; } function warn(msg) { console.log(`⚠️ ${msg}`); warnings++; } function ok(msg) { console.log(`✅ ${msg}`); } // --- Read applications.md --- if (!existsSync(APPS_FILE)) { console.log('\n📊 No applications.md found. This is normal for a fresh setup.'); console.log(' The file will be created when you evaluate your first offer.\n'); process.exit(0); } const content = readFileSync(APPS_FILE, 'utf-8'); const lines = content.split('\n'); // Map columns by header name so the checks work whether the tracker uses the // original 9-column layout or a customized one with an extra column (e.g. a // Location column after Role). Fixed-position indexing would otherwise read // Location where Score is expected and flag false errors. Falls back to the // legacy fixed layout when no recognizable header row is found. const LEGACY_COLMAP = { num: 1, date: 2, company: 3, role: 4, score: 5, status: 6, pdf: 7, report: 8, notes: 9 }; const HEADER_ALIASES = { '#': 'num', 'num': 'num', 'date': 'date', 'company': 'company', 'empresa': 'company', 'role': 'role', 'puesto': 'role', 'location': 'location', 'score': 'score', 'status': 'status', 'pdf': 'pdf', 'report': 'report', 'notes': 'notes', }; function detectColumns(allLines) { for (const line of allLines) { if (!line.startsWith('|')) continue; const cells = line.split('|').map(s => s.trim().toLowerCase()); if (!cells.includes('company') || !cells.includes('role')) continue; const map = {}; cells.forEach((c, i) => { if (HEADER_ALIASES[c] != null) map[HEADER_ALIASES[c]] = i; }); if (['num', 'company', 'role', 'score', 'status'].every(k => map[k] != null)) return map; } return null; } const COLMAP = detectColumns(lines) || LEGACY_COLMAP; const MAX_IDX = Math.max(...Object.values(COLMAP)); const entries = []; for (const line of lines) { if (!line.startsWith('|')) continue; const parts = line.split('|').map(s => s.trim()); if (parts.length <= MAX_IDX) continue; const num = parseInt(parts[COLMAP.num]); if (isNaN(num)) continue; entries.push({ num, date: parts[COLMAP.date], company: parts[COLMAP.company], role: parts[COLMAP.role], location: COLMAP.location != null ? parts[COLMAP.location] : '', score: parts[COLMAP.score], status: parts[COLMAP.status], pdf: parts[COLMAP.pdf], report: parts[COLMAP.report], notes: COLMAP.notes != null ? (parts[COLMAP.notes] || '') : '', }); } console.log(`\n📊 Checking ${entries.length} entries in applications.md\n`); // --- Check 1: Canonical statuses --- let badStatuses = 0; for (const e of entries) { const clean = e.status.replace(/\*\*/g, '').trim().toLowerCase(); // Strip trailing dates const statusOnly = clean.replace(/\s+\d{4}-\d{2}-\d{2}.*$/, '').trim(); if (!CANONICAL_STATUSES.includes(statusOnly) && !ALIASES[statusOnly]) { error(`#${e.num}: Non-canonical status "${e.status}"`); badStatuses++; } // Check for markdown bold in status if (e.status.includes('**')) { error(`#${e.num}: Status contains markdown bold: "${e.status}"`); badStatuses++; } // Check for dates in status if (/\d{4}-\d{2}-\d{2}/.test(e.status)) { error(`#${e.num}: Status contains date: "${e.status}" — dates go in date column`); badStatuses++; } } if (badStatuses === 0) ok('All statuses are canonical'); // --- Check 2: Duplicates --- const companyRoleMap = new Map(); let dupes = 0; for (const e of entries) { const key = e.company.toLowerCase().replace(/[^a-z0-9]/g, '') + '::' + e.role.toLowerCase().replace(/[^a-z0-9 ]/g, ''); if (!companyRoleMap.has(key)) companyRoleMap.set(key, []); companyRoleMap.get(key).push(e); } for (const [key, group] of companyRoleMap) { if (group.length > 1) { warn(`Possible duplicates: ${group.map(e => `#${e.num}`).join(', ')} (${group[0].company} — ${group[0].role})`); dupes++; } } if (dupes === 0) ok('No exact duplicates found'); // --- Check 3: Report links --- // Markdown links resolve relative to the file that contains them, so report // links must resolve against the tracker's own directory (see #760). For the // transition we also accept legacy root-relative links: try the tracker dir // first, then fall back to the repo root before flagging a link broken. const TRACKER_DIR = dirname(APPS_FILE); let brokenReports = 0; for (const e of entries) { const match = e.report.match(/\]\(([^)]+)\)/); if (!match) continue; const link = match[1]; if (!existsSync(join(TRACKER_DIR, link)) && !existsSync(join(CAREER_OPS, link))) { error(`#${e.num}: Report not found: ${link}`); brokenReports++; } } if (brokenReports === 0) ok('All report links valid'); // --- Check 4: Score format --- let badScores = 0; for (const e of entries) { const s = e.score.replace(/\*\*/g, '').trim(); if (!/^\d+\.?\d*\/5$/.test(s) && s !== 'N/A' && s !== 'DUP') { error(`#${e.num}: Invalid score format: "${e.score}"`); badScores++; } } if (badScores === 0) ok('All scores valid'); // --- Check 5: Row format --- let badRows = 0; for (const line of lines) { if (!line.startsWith('|')) continue; if (line.includes('---') || line.includes('Empresa')) continue; const parts = line.split('|'); if (parts.length <= MAX_IDX) { error(`Row with too few columns (need ${MAX_IDX} data cols): ${line.substring(0, 80)}...`); badRows++; } } if (badRows === 0) ok('All rows properly formatted'); // --- Check 6: Pending TSVs --- let pendingTsvs = 0; if (existsSync(ADDITIONS_DIR)) { const files = readdirSync(ADDITIONS_DIR).filter(f => f.endsWith('.tsv')); pendingTsvs = files.length; if (pendingTsvs > 0) { warn(`${pendingTsvs} pending TSVs in tracker-additions/ (not merged)`); } } if (pendingTsvs === 0) ok('No pending TSVs'); // --- Check 7: Bold in scores --- let boldScores = 0; for (const e of entries) { if (e.score.includes('**')) { warn(`#${e.num}: Score has markdown bold: "${e.score}"`); boldScores++; } } if (boldScores === 0) ok('No bold in scores'); // --- Check 8: Stale report-number sentinels (GC) --- // reserve-report-num.mjs drops NNN-RESERVED.md files in reports/ when a // number is claimed. If the process crashed before writing the real report // and deleting the sentinel it will linger. Sentinels older than 4 h are // stale; remove them here so they don't skew the next slot allocation. const SENTINEL_MAX_AGE_MS = 4 * 60 * 60 * 1000; let staleSentinels = 0; if (existsSync(REPORTS_DIR)) { const now = Date.now(); for (const name of readdirSync(REPORTS_DIR)) { if (!name.endsWith('-RESERVED.md')) continue; const full = join(REPORTS_DIR, name); try { const { mtimeMs } = statSync(full); if (now - mtimeMs > SENTINEL_MAX_AGE_MS) { unlinkSync(full); warn(`Removed stale reservation sentinel: ${name}`); staleSentinels++; } } catch { // Already gone between readdir and stat — fine. } } } if (staleSentinels === 0) ok('No stale reservation sentinels'); // --- Check 9: Duplicate reports for the same company+role (#1425) --- // Two concurrent evaluators can each write a report for the same role. // merge-tracker dedups the TRACKER, but nothing watched reports/ itself. // Warning-level, not error: duplicates can be legitimate (re-evaluation // after a JD change). const REPORT_FILE_RE = /^(\d+)-(.+)-\d{4}-\d{2}-\d{2}\.md$/; const normalizeKey = s => s.toLowerCase().replace(/[^a-z0-9]/g, ''); // Role comes from the report body: the Machine Summary YAML fence when // present (field names are exact by contract), else the title line // "# Evaluación: {Company} — {Role}". Reports where neither parses are // skipped rather than grouped by company alone, which would false-positive // on two different roles at the same company. function extractRole(reportContent) { const fence = reportContent.match(/##\s*Machine Summary\s*\n+```(?:yaml|yml|json)?\s*\n([\s\S]*?)\n```/i); if (fence) { const m = fence[1].match(/^role:\s*["']?(.+?)["']?\s*$/m); if (m && m[1].trim()) return m[1].trim(); } const title = reportContent.split('\n').find(l => l.startsWith('# ')); if (title) { const parts = title.split(/[—–]/); if (parts.length >= 2 && parts[parts.length - 1].trim()) return parts[parts.length - 1].trim(); } return null; } const reportFiles = existsSync(REPORTS_DIR) ? readdirSync(REPORTS_DIR).filter(f => REPORT_FILE_RE.test(f)) : []; let dupReports = 0; const reportsByRole = new Map(); for (const name of reportFiles) { const companySlug = name.match(REPORT_FILE_RE)[2]; let role = null; try { role = extractRole(readFileSync(join(REPORTS_DIR, name), 'utf-8')); } catch { // Unreadable report — the orphan check below still sees it. } if (!role) continue; const key = normalizeKey(companySlug) + '::' + normalizeKey(role); if (!reportsByRole.has(key)) reportsByRole.set(key, []); reportsByRole.get(key).push(name); } for (const group of reportsByRole.values()) { if (group.length > 1) { warn(`Duplicate reports for same company+role: ${group.join(', ')}`); dupReports++; } } if (dupReports === 0) ok('No duplicate reports for the same company+role'); // --- Check 10: Orphan reports with no tracker row (#1425) --- // Every reports/NNN-*.md should be referenced by a tracker row — by the row's // own number, the [NNN] link text, or the NNN- prefix of the linked filename. // A report none of them reference is usually the loser of a tracker dedup. const referencedNums = new Set(); for (const e of entries) { referencedNums.add(e.num); const linkText = e.report.match(/\[(\d+)\]/); if (linkText) referencedNums.add(parseInt(linkText[1], 10)); const linkTarget = e.report.match(/\]\(([^)]+)\)/); if (linkTarget) { const m = linkTarget[1].split('/').pop().match(/^(\d+)-/); if (m) referencedNums.add(parseInt(m[1], 10)); } } let orphanReports = 0; for (const name of reportFiles) { const num = parseInt(name.match(REPORT_FILE_RE)[1], 10); if (!referencedNums.has(num)) { warn(`Orphan report — no tracker row references #${num}: reports/${name}`); orphanReports++; } } if (orphanReports === 0) ok('No orphan reports'); // --- Summary --- console.log('\n' + '='.repeat(50)); console.log(`📊 Pipeline Health: ${errors} errors, ${warnings} warnings`); if (errors === 0 && warnings === 0) { console.log('🟢 Pipeline is clean!'); } else if (errors === 0) { console.log('🟡 Pipeline OK with warnings'); } else { console.log('🔴 Pipeline has errors — fix before proceeding'); } process.exit(errors > 0 ? 1 : 0);