feat(16-02): add check-outdated.mjs advisory-only tiered report
- 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)
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user