From baf2e3ad1bf30e44953f347d4f2a06836d275de7 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 05:19:06 -0400 Subject: [PATCH] feat(16-02): add check-outdated.mjs advisory-only tiered report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Classifies outdated packages into four tiers: AUDIT-ADVISORY, MAJOR-BEHIND-INTENTIONAL, MAJOR-BEHIND-UNPINNED, ROUTINE-DRIFT - Reads outdated-pins.json for intentional pin reasons (eslint, @eslint/js, zod, @types/node) - Cross-checks pnpm audit --json to flag pinned versions with active advisories - Always exits 0 — never gates the build (D-06) --- scripts/check-outdated.mjs | 180 +++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 scripts/check-outdated.mjs diff --git a/scripts/check-outdated.mjs b/scripts/check-outdated.mjs new file mode 100644 index 0000000..280817b --- /dev/null +++ b/scripts/check-outdated.mjs @@ -0,0 +1,180 @@ +/** + * check-outdated.mjs — pnpm outdated advisory-only tiered report (D-06 / OQ-01). + * + * Classifies all outdated packages into four tiers in priority order: + * 1. AUDIT-ADVISORY — the package's current version carries a known advisory + * 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; AUDIT-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: the package has an active advisory on the pinned version + 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: AUDIT-ADVISORY +console.log('[AUDIT-ADVISORY] Packages with active advisories on the pinned version:'); +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);