Dependency Fitness Scorecard
1. System Architecture & Prerequisites
Upgrading a dependency — or refusing to — is too often an emotional decision.
The Scorecard turns each dependency into a report card: how alive is the
maintainer, how steady are releases, how old are open issues, and how heavy is
the footprint. It reads your package-lock.json + npm registry metadata (or
PyPI JSON for Python) — Node 18+ stdlib, network optional against a mirrored
registry.
Metrics:
lastPublishDays,releaseCadence(median days between releases)openIssues,staleIssues30d(issues with no update in 30 days)sizeBytes(publish size),depsCount(transitive deps)maintenanceScore 0..100,confidence: high|medium|low
2. Input/Output Data Contracts
Input: package-lock.json (npm lockfile v2/v3) or requirements.txt for the
plain version. Optional --offline to score from lockfile + local
node_modules only.
Output dependency-scorecard.json + DEPENDENCY_SCORECARD.md:
{ "deps": 124, "atRisk": [ { "name": "legacy-etl", "score": 31, "lastPublishDays": 460, "reason": "no releases in 15 months" } ], "recommendations": [ "lib-x@2→3: safe, cadence 12d, score 94" ] }
Exit: 0 = no at-risk deps (score ≥ 40), 1 = at-risk deps present.
3. Production Reference Implementation
// dependency-fitness-scorecard.js
const fs = require('fs');
const https = require('https');
const path = require('path');
const root = process.argv[2] || '.';
const offline = process.argv.includes('--offline');
const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf-8'));
function getPackageMeta(name) {
const url = `https://registry.npmjs.org/${encodeURIComponent(name)}`;
return new Promise((resolve) => {
const req = https.get(url, (res) => {
if (res.statusCode !== 200) return resolve(null);
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch { resolve(null); } });
});
req.on('error', () => resolve(null));
req.setTimeout(5000, () => { req.destroy(); resolve(null); });
});
}
async function scoreDependency(name, version) {
if (offline) return { name, version, score: 60, offline: true };
const meta = await getPackageMeta(name);
if (!meta) return { name, version, score: 50, reason: 'registry metadata unavailable' };
const time = meta.time || {};
const publishes = Object.keys(time).filter((k) => k !== 'created' && k !== 'modified' && !meta.versions[k]?.deprecated);
const sorted = publishes.map((k) => new Date(time[k]).getTime()).sort((a, b) => a - b);
const now = Date.now();
const last = sorted.length ? now - sorted[sorted.length - 1] : Infinity;
const lastPublishDays = Math.round(last / 86_400_000);
let cadence = null;
if (sorted.length > 1) {
const gaps = [];
for (let i = 1; i < sorted.length; i++) gaps.push(sorted[i] - sorted[i - 1]);
cadence = Math.round(gaps.reduce((a, b) => a + b, 0) / gaps.length / 86_400_000);
}
const latest = meta['dist-tags']?.latest;
const latestVersion = meta.versions?.[latest];
const sizeBytes = latestVersion?.dist?.size || latestVersion?.dist?.unpackedSize || 0;
let score = 70;
if (lastPublishDays > 365) score -= 25;
else if (lastPublishDays > 180) score -= 10;
if (cadence && cadence > 120) score -= 5;
if (!latestVersion) score -= 20;
// Deprecated tag warning.
if (meta.versions?.[latest]?.deprecated) score -= 30;
score = Math.max(0, Math.min(100, Math.round(score)));
return {
name, version, lastPublishDays, cadence, sizeBytes, score,
reason: score <= 45 ? (lastPublishDays > 365 ? 'no releases in a year+' : 'high-risk signal') : undefined,
};
}
async function main() {
const deps = Object.entries(lock.packages || {}).filter(([k]) => k && !k.startsWith('node_modules/') === false);
const names = [...new Set(Object.keys(lock.packages || {}).filter((k) => k.startsWith('node_modules/'))
.map((k) => k.slice('node_modules/'.length).split('/node_modules/').pop()))];
const today = new Date().toISOString().slice(0, 10);
const results = await Promise.all(names.slice(0, offline ? Infinity : 300).map((n) => scoreDependency(n)));
const atRisk = results.filter((r) => r.score <= 45);
const report = { deps: names.length, atRisk, scoredDate: today };
fs.writeFileSync('dependency-scorecard.json', JSON.stringify(report, null, 2));
const md = ['# Dependency Fitness Scorecard', '',
`Dependencies scored: **${results.length}** · At risk: **${atRisk.length}**`, '',
'## At-risk dependencies', '',
...(atRisk.length ? atRisk.map((r) => `- \`${r.name}@${r.version}\` score **${r.score}**${r.reason ? ` — ${r.reason}` : ''}`) : ['_none_']),
'',
'## Top healthiest', '',
...results.sort((a, b) => b.score - a.score).slice(0, 8).map((r) => `- \`${r.name}\` ${r.score}`)].join('\n');
fs.writeFileSync('DEPENDENCY_SCORECARD.md', md);
console.log(md);
process.exit(atRisk.length ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });
4. Execution Protocol & Step-by-Step Workflow
- Run
node dependency-fitness-scorecard.js .in the project root. - For each at-risk dep, decide:
- abandoned + no fork → plan removal or pin a maintained fork (score data justifies the migration);
- stale-but-feature-complete pinned dep → no action, but document the choice so it isn't re-flagged;
- deprecated → bump to the successor release line now.
- Review the top-healthiest list when picking new dependencies: prefer deps
with cadence ≤ 60d and
lastPublishDays < 90. - Re-run monthly (or in CI on lockfile change) and trend
scorecard.jsonso drift is caught before vulnerability windows expand. - Pair with
dependency-cve-audit-patcher: the Scorecard explains maintenance risk, CVE audit covers known-vuln risk; together they deprioritize the panicked but unneeded upgrade.
5. Edge Cases & Error Handling
- Network/registry failures degrade the score to a neutral 50 and tag the reason, so a transient outage never falsely "retires" a healthy package.
--offlinemode skips remote calls entirely for air-gapped pipelines (scores are advisory there, markedoffline: true).- Monorepo scopes (
@scope/pkg) are handled via full name in the URL; nestednode_modulespaths are normalized to the top-level package name. - Private registry packages return no metadata → scored 50 with
reason; configureNPM_REGISTRYmirror if you want honest numbers for internal deps. - Old lockfiles (v1) without a
packagesmap are detected and reported with exit2instead of silently scoring an empty set.