CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m23s
CI / api (pull_request) Successful in 1m0s
CI / harness (pull_request) Successful in 3m54s
CI / security (pull_request) Successful in 40s
CI / gate (pull_request) Successful in 1s
200 lines
7.2 KiB
JavaScript
200 lines
7.2 KiB
JavaScript
/**
|
|
* 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);
|