/** * check-outdated.mjs — pnpm outdated advisory-only tiered report (D-06 / OQ-01). * * Classifies all outdated packages into four tiers in priority order: * 1. OUTDATED-WITH-ADVISORY — an outdated DIRECT dep whose name also appears as * an advisory subject. NOTE (WR-04): `pnpm outdated` lists only direct/top- * level deps, while most advisories are on TRANSITIVE deps (e.g. esbuild), so * the two sets rarely intersect and this tier usually reports "(none)". It is * a best-effort flag for the case where a *direct* dependency you control is * both outdated and carries an advisory — NOT a full advisory cross-check of * the dependency tree. The authoritative advisory gate is check-audit.mjs. * 2. MAJOR-BEHIND-INTENTIONAL — latest major > current major, pin reason exists in outdated-pins.json * 3. MAJOR-BEHIND-UNPINNED — latest major > current major, no pin reason (potential liability) * 4. ROUTINE-DRIFT — same major, minor/patch behind (low priority) * * This script ALWAYS exits 0 — it is advisory-only and never gates the build (D-06). */ import { execSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { resolve, dirname } from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** * Run a command and return stdout, tolerating non-zero exit codes. * pnpm outdated exits non-zero when any package is outdated — we need the output anyway. * * @param {string} cmd * @returns {string} */ function captureOutput(cmd) { try { return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], }); } catch (err) { // Non-zero exit — pnpm outdated does this when packages are outdated return err.stdout ?? ''; } } /** * Parse the major version integer from a semver string. * Returns 0 if parsing fails. * * @param {string} version * @returns {number} */ function majorOf(version) { const n = parseInt((version ?? '').split('.')[0], 10); return isNaN(n) ? 0 : n; } // ── Load pin reasons ───────────────────────────────────────────────────────── const pinsPath = resolve(__dirname, 'outdated-pins.json'); let pins = {}; try { pins = JSON.parse(readFileSync(pinsPath, 'utf8')); } catch { // Gracefully degrade — no pins means everything is treated as unpinned console.warn( '[check-outdated] Warning: could not read outdated-pins.json; treating all pins as unknown', ); } // ── Run pnpm audit to collect vulnerable module names ─────────────────────── let vulnerableModules = new Set(); try { const auditOutput = captureOutput('pnpm audit --json'); const auditData = JSON.parse(auditOutput); const advisories = auditData.advisories ?? {}; for (const adv of Object.values(advisories)) { if (adv.module_name) { vulnerableModules.add(adv.module_name); } } } catch { // Audit parse failure is non-fatal for the outdated report console.warn( '[check-outdated] Warning: could not parse pnpm audit output; OUTDATED-WITH-ADVISORY cross-check skipped', ); } // ── Run pnpm outdated ──────────────────────────────────────────────────────── const outdatedOutput = captureOutput('pnpm outdated --format json -r'); let outdatedData = {}; if (outdatedOutput.trim()) { try { outdatedData = JSON.parse(outdatedOutput); } catch { console.warn('[check-outdated] Warning: could not parse pnpm outdated JSON output'); } } // ── Classify entries into tiers ────────────────────────────────────────────── const tiers = { auditAdvisory: [], majorBehindIntentional: [], majorBehindUnpinned: [], routineDrift: [], }; for (const [pkgName, info] of Object.entries(outdatedData)) { const current = info.current ?? ''; const latest = info.latest ?? ''; const currentMajor = majorOf(current); const latestMajor = majorOf(latest); const isMajorBehind = latestMajor > currentMajor; const pinReason = pins[pkgName]; const hasAdvisory = vulnerableModules.has(pkgName); const entry = { name: pkgName, current, latest, dependencyType: info.dependencyType ?? '', dependentPackages: info.dependentPackages, }; // Priority 1: this outdated DIRECT dep also appears as an advisory subject. // Rarely fires — most advisories are on transitive deps (see WR-04 note in the // file header); the authoritative advisory gate is check-audit.mjs. if (hasAdvisory) { tiers.auditAdvisory.push(entry); // Priority 2: major behind + intentional pin } else if (isMajorBehind && pinReason) { tiers.majorBehindIntentional.push({ ...entry, reason: pinReason }); // Priority 3: major behind without a pin reason — possible liability } else if (isMajorBehind) { tiers.majorBehindUnpinned.push(entry); // Priority 4: same major, minor/patch drift } else { tiers.routineDrift.push(entry); } } // ── Print human-readable report ────────────────────────────────────────────── console.log(''); console.log('=== DEPENDENCY HEALTH REPORT ==='); console.log(''); // Tier 1: OUTDATED-WITH-ADVISORY (direct deps only — see WR-04 note in header) console.log( '[OUTDATED-WITH-ADVISORY] Outdated direct deps that also appear as an advisory subject', ); console.log( ' (best-effort; most advisories are on transitive deps — authoritative gate is check-audit.mjs):', ); if (tiers.auditAdvisory.length === 0) { console.log(' (none)'); } else { for (const pkg of tiers.auditAdvisory) { console.log( ` ${pkg.name} ${pkg.current} → ${pkg.latest} (${pkg.dependencyType}) *** ADVISORY ON CURRENT VERSION ***`, ); } } console.log(''); // Tier 2: MAJOR-BEHIND / INTENTIONAL PIN console.log('[MAJOR-BEHIND / INTENTIONAL PIN] Packages behind due to a known constraint:'); if (tiers.majorBehindIntentional.length === 0) { console.log(' (none)'); } else { for (const pkg of tiers.majorBehindIntentional) { console.log(` ${pkg.name} ${pkg.current} → ${pkg.latest} (${pkg.dependencyType})`); console.log(` reason: ${pkg.reason}`); } } console.log(''); // Tier 3: MAJOR-BEHIND / UNPINNED console.log('[MAJOR-BEHIND / UNPINNED] Packages >1 major behind without a pin reason:'); if (tiers.majorBehindUnpinned.length === 0) { console.log(' (none)'); } else { for (const pkg of tiers.majorBehindUnpinned) { console.log(` ${pkg.name} ${pkg.current} → ${pkg.latest} (${pkg.dependencyType})`); } } console.log(''); // Tier 4: ROUTINE-DRIFT console.log('[ROUTINE-DRIFT] Patch/minor updates (low priority):'); if (tiers.routineDrift.length === 0) { console.log(' (none)'); } else { const items = tiers.routineDrift.map((p) => `${p.name} ${p.current} → ${p.latest}`); console.log(' ' + items.join(', ')); } console.log(''); // Advisory-only — NEVER gates the build (D-06) process.exit(0);