diff --git a/review-enrichment/README.md b/review-enrichment/README.md index 2fdb5f250e..3ccb9c7126 100644 --- a/review-enrichment/README.md +++ b/review-enrichment/README.md @@ -23,6 +23,7 @@ See `src/server.ts` for the `EnrichRequest` / `ReviewBrief` contract. - **#1474** dependency-diff + OSV.dev CVE - **#1502** lockfile-only transitive vulnerability drift via OSV.dev - **#1475** SPDX license policy +- **#1511** package maintenance-health / deprecated-dep scorer - **#1476** gitleaks-grade secret scan (value-redacted) - **#1477** static analysis + complexity (lint/semgrep over the diff) - **#1478** history (author track record, similar past PRs, linked-issue alignment) diff --git a/review-enrichment/src/analyzers/maintenance-health.ts b/review-enrichment/src/analyzers/maintenance-health.ts new file mode 100644 index 0000000000..f6e30d677b --- /dev/null +++ b/review-enrichment/src/analyzers/maintenance-health.ts @@ -0,0 +1,200 @@ +// Package maintenance-health analyzer (#1511). For each direct dependency a PR adds/upgrades, resolve public +// registry metadata and flag low-noise adoption risks: deprecated releases, yanked PyPI releases, stale/no recent +// releases, and sole-maintainer packages. The no-checkout reviewer cannot fetch this historical metadata; REES can. +import type { EnrichRequest, MaintenanceHealthFinding } from "../types.js"; +import { extractDependencyChanges } from "./dependency-scan.js"; + +const MAX_LOOKUPS = 25; +const LOOKUP_TIMEOUT_MS = 1_500; +const STALE_RELEASE_YEARS = 3; +const MS_PER_YEAR = 365 * 24 * 60 * 60 * 1000; + +interface RegistrySignal { + deprecatedMessage?: string | null; + yanked?: boolean; + lastReleaseDate?: string | null; + maintainers?: number | null; +} + +function toIsoDate(value: string | null | undefined): string | null { + if (!value) return null; + const ms = Date.parse(value); + if (!Number.isFinite(ms)) return null; + return new Date(ms).toISOString(); +} + +function isStaleRelease(date: string | null, now: number): boolean { + if (!date) return false; + const ms = Date.parse(date); + if (!Number.isFinite(ms)) return false; + return now - ms >= STALE_RELEASE_YEARS * MS_PER_YEAR; +} + +function classifySignals( + signal: RegistrySignal, + now: number, +): MaintenanceHealthFinding["reasons"] { + const reasons: MaintenanceHealthFinding["reasons"] = []; + if (signal.deprecatedMessage) reasons.push("deprecated"); + if (signal.yanked) reasons.push("yanked"); + if (isStaleRelease(signal.lastReleaseDate ?? null, now)) + reasons.push("stale-release"); + if (signal.maintainers === 1) reasons.push("sole-maintainer"); + return reasons; +} + +async function fetchJson( + url: string, + fetchImpl: typeof fetch, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) return null; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), LOOKUP_TIMEOUT_MS); + const onAbort = () => controller.abort(); + signal?.addEventListener("abort", onAbort, { once: true }); + try { + const response = await fetchImpl(url, { signal: controller.signal }); + if (!response.ok) return null; + return await response.json(); + } catch { + return null; + } finally { + clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + } +} + +interface NpmRegistryVersion { + deprecated?: string; +} + +interface NpmRegistryDoc { + versions?: Record; + time?: Record; + maintainers?: unknown[]; +} + +function latestNpmActivityDate( + time: NpmRegistryDoc["time"], +): string | null { + const modified = toIsoDate(time?.modified); + if (modified) return modified; + const candidates = Object.entries(time ?? {}) + .filter(([key]) => key !== "created" && key !== "modified") + .map(([, value]) => toIsoDate(value)) + .filter((value): value is string => Boolean(value)) + .sort(); + return candidates.at(-1) ?? null; +} + +export async function fetchNpmSignals( + name: string, + version: string, + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal, +): Promise { + const data = (await fetchJson( + `https://registry.npmjs.org/${encodeURIComponent(name)}`, + fetchImpl, + signal, + )) as NpmRegistryDoc | null; + if (!data) return null; + const versionDoc = data.versions?.[version]; + return { + deprecatedMessage: + typeof versionDoc?.deprecated === "string" + ? versionDoc.deprecated.replace(/\s+/g, " ").slice(0, 180) + : null, + lastReleaseDate: latestNpmActivityDate(data.time), + maintainers: Array.isArray(data.maintainers) ? data.maintainers.length : null, + }; +} + +interface PypiReleaseFile { + upload_time_iso_8601?: string; + yanked?: boolean; +} + +interface PypiProjectInfo { + deprecated?: boolean | string; + maintainer?: string | null; + maintainer_email?: string | null; +} + +interface PypiDoc { + info?: PypiProjectInfo; + releases?: Record; +} + +export async function fetchPypiSignals( + name: string, + version: string, + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal, +): Promise { + const data = (await fetchJson( + `https://pypi.org/pypi/${encodeURIComponent(name)}/json`, + fetchImpl, + signal, + )) as PypiDoc | null; + if (!data) return null; + const files = data.releases?.[version] ?? []; + const uploadTimes = files + .map((item) => toIsoDate(item.upload_time_iso_8601)) + .filter((value): value is string => Boolean(value)) + .sort(); + const maintainerName = data.info?.maintainer?.trim() || null; + const maintainerEmail = + maintainerName ? null : data.info?.maintainer_email?.trim() || null; + const deprecated = + typeof data.info?.deprecated === "string" + ? data.info.deprecated + : data.info?.deprecated + ? "PyPI metadata marks this project deprecated" + : null; + return { + deprecatedMessage: deprecated, + yanked: files.some((item) => item.yanked === true), + lastReleaseDate: uploadTimes.at(-1) ?? null, + maintainers: maintainerName || maintainerEmail ? 1 : null, + }; +} + +/** Analyzer entrypoint: direct dependency adds/bumps → public registry maintenance metadata → only risky ones. */ +export async function scanMaintenanceHealth( + req: EnrichRequest, + fetchImpl: typeof fetch = fetch, + options: { signal?: AbortSignal; now?: number } = {}, +): Promise { + const findings: MaintenanceHealthFinding[] = []; + const now = options.now ?? Date.now(); + const changes = extractDependencyChanges(req.files ?? []).slice(0, MAX_LOOKUPS); + for (const change of changes) { + if (options.signal?.aborted) break; + const signal = + change.ecosystem === "npm" + ? await fetchNpmSignals(change.package, change.to, fetchImpl, options.signal) + : change.ecosystem === "PyPI" + ? await fetchPypiSignals( + change.package, + change.to, + fetchImpl, + options.signal, + ) + : null; + if (!signal) continue; + const reasons = classifySignals(signal, now); + if (!reasons.length) continue; + findings.push({ + ecosystem: change.ecosystem, + package: change.package, + version: change.to, + reasons, + deprecatedMessage: signal.deprecatedMessage ?? null, + lastReleaseDate: signal.lastReleaseDate ?? null, + maintainers: signal.maintainers ?? null, + }); + } + return findings; +} diff --git a/review-enrichment/src/brief.ts b/review-enrichment/src/brief.ts index 7550f7db89..85366a5377 100644 --- a/review-enrichment/src/brief.ts +++ b/review-enrichment/src/brief.ts @@ -11,6 +11,7 @@ import { scanDependencies } from "./analyzers/dependency-scan.js"; import { scanLockfileDrift } from "./analyzers/lockfile-drift.js"; import { scanSecrets } from "./analyzers/secret-scan.js"; import { scanLicenses } from "./analyzers/license-check.js"; +import { scanMaintenanceHealth } from "./analyzers/maintenance-health.js"; import { scanInstallScripts } from "./analyzers/install-scripts.js"; import { scanActionPins } from "./analyzers/actions-pin.js"; import { scanEol } from "./analyzers/eol-check.js"; @@ -32,6 +33,8 @@ const ANALYZERS: Record = { lockfileDrift: (req, signal) => scanLockfileDrift(req, fetch, { signal }), secret: (req) => scanSecrets(req), license: (req) => scanLicenses(req), + maintenanceHealth: (req, signal) => + scanMaintenanceHealth(req, fetch, { signal }), installScript: (req) => scanInstallScripts(req), actionPin: (req) => scanActionPins(req), eol: (req) => scanEol(req), diff --git a/review-enrichment/src/render.ts b/review-enrichment/src/render.ts index e9fc157841..e73d301b2a 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -110,6 +110,38 @@ export function renderBrief( } } + const maintenanceHealth = findings.maintenanceHealth ?? []; + if (maintenanceHealth.length) { + lines.push( + "### Dependency maintenance-health risks (adoption and supply-chain durability)", + ); + for (const item of maintenanceHealth) { + const details: string[] = []; + for (const reason of item.reasons) { + if (reason === "deprecated") { + details.push( + item.deprecatedMessage + ? `deprecated — ${promptText(item.deprecatedMessage)}` + : "deprecated", + ); + } else if (reason === "yanked") { + details.push("yanked release"); + } else if (reason === "stale-release") { + details.push( + item.lastReleaseDate + ? `no recent release since ${promptText(item.lastReleaseDate.slice(0, 10))}` + : "no recent releases", + ); + } else if (reason === "sole-maintainer") { + details.push("single-maintainer package"); + } + } + lines.push( + `- ${safeCodeSpan(`${item.package}@${item.version}`)} (${item.ecosystem}): ${details.join("; ")}`, + ); + } + } + const installScripts = findings.installScript ?? []; if (installScripts.length) { lines.push( diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index 70dcf66619..7dcff3218f 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -73,6 +73,23 @@ export interface LicenseFinding { classification: "copyleft" | "unknown"; } +/** A newly-added/upgraded direct dependency with maintenance-health risk signals. Keeps the signal public-safe: + * package/version + generic risk reasons + coarse metadata only, never tokens, URLs, or unpublished details. */ +export interface MaintenanceHealthFinding { + ecosystem: string; + package: string; + version: string; + reasons: Array< + | "deprecated" + | "yanked" + | "stale-release" + | "sole-maintainer" + >; + deprecatedMessage?: string | null; + lastReleaseDate?: string | null; + maintainers?: number | null; +} + /** A newly-added/upgraded npm dependency version that runs install lifecycle scripts (supply-chain risk). */ export interface InstallScriptFinding { package: string; @@ -168,6 +185,7 @@ export interface BriefFindings { lockfileDrift?: LockfileDriftFinding[]; secret?: SecretFinding[]; license?: LicenseFinding[]; + maintenanceHealth?: MaintenanceHealthFinding[]; actionPin?: ActionPinFinding[]; installScript?: InstallScriptFinding[]; eol?: EolFinding[]; diff --git a/review-enrichment/test/maintenance-health.test.ts b/review-enrichment/test/maintenance-health.test.ts new file mode 100644 index 0000000000..0574a75917 --- /dev/null +++ b/review-enrichment/test/maintenance-health.test.ts @@ -0,0 +1,197 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + fetchNpmSignals, + fetchPypiSignals, + scanMaintenanceHealth, +} from "../dist/analyzers/maintenance-health.js"; +import { renderBrief } from "../dist/render.js"; + +const NOW = new Date("2026-06-29T00:00:00.000Z").getTime(); + +const npmPatch = (name, version = "1.0.0") => ({ + repoFullName: "o/r", + prNumber: 1, + files: [{ path: "package.json", patch: `+ "${name}": "${version}",` }], +}); + +const pypiPatch = (name, version = "1.0.0") => ({ + repoFullName: "o/r", + prNumber: 1, + files: [{ path: "requirements.txt", patch: `+${name}==${version}` }], +}); + +const okJson = + (body) => + async () => ({ + ok: true, + json: async () => body, + }); + +test("fetchNpmSignals reads deprecation, package activity date, and maintainer count", async () => { + const signals = await fetchNpmSignals( + "left-pad", + "1.0.0", + okJson({ + versions: { "1.0.0": { deprecated: "Use pad-left instead" } }, + time: { + "1.0.0": "2021-01-01T00:00:00.000Z", + modified: "2026-01-01T00:00:00.000Z", + }, + maintainers: [{ name: "one" }], + }), + ); + assert.equal(signals?.deprecatedMessage, "Use pad-left instead"); + assert.equal(signals?.lastReleaseDate, "2026-01-01T00:00:00.000Z"); + assert.equal(signals?.maintainers, 1); +}); + +test("fetchPypiSignals dedupes maintainer name/email into one maintainer hint", async () => { + const signals = await fetchPypiSignals( + "demo", + "2.0.0", + okJson({ + info: { + maintainer: "alice", + maintainer_email: "alice@example.com", + deprecated: "Project retired", + }, + releases: { + "2.0.0": [ + { + upload_time_iso_8601: "2020-02-02T00:00:00.000Z", + yanked: true, + }, + ], + }, + }), + ); + assert.equal(signals?.deprecatedMessage, "Project retired"); + assert.equal(signals?.yanked, true); + assert.equal(signals?.lastReleaseDate, "2020-02-02T00:00:00.000Z"); + assert.equal(signals?.maintainers, 1); +}); + +test("scanMaintenanceHealth flags npm dependency risks with low-noise reasons", async () => { + const findings = await scanMaintenanceHealth( + npmPatch("legacy-lib"), + okJson({ + versions: { "1.0.0": { deprecated: "No longer maintained" } }, + time: { "1.0.0": "2021-01-01T00:00:00.000Z" }, + maintainers: [{ name: "solo" }], + }), + { now: NOW }, + ); + assert.deepEqual(findings, [ + { + ecosystem: "npm", + package: "legacy-lib", + version: "1.0.0", + reasons: ["deprecated", "stale-release", "sole-maintainer"], + deprecatedMessage: "No longer maintained", + lastReleaseDate: "2021-01-01T00:00:00.000Z", + maintainers: 1, + }, + ]); +}); + +test("scanMaintenanceHealth does not flag stale-release for an old version in an active npm package", async () => { + const findings = await scanMaintenanceHealth( + npmPatch("legacy-lib"), + okJson({ + versions: { "1.0.0": {} }, + time: { + "1.0.0": "2021-01-01T00:00:00.000Z", + modified: "2026-06-01T00:00:00.000Z", + }, + maintainers: [{ name: "solo" }], + }), + { now: NOW }, + ); + assert.deepEqual(findings, [ + { + ecosystem: "npm", + package: "legacy-lib", + version: "1.0.0", + reasons: ["sole-maintainer"], + deprecatedMessage: null, + lastReleaseDate: "2026-06-01T00:00:00.000Z", + maintainers: 1, + }, + ]); +}); + +test("scanMaintenanceHealth flags yanked PyPI releases", async () => { + const findings = await scanMaintenanceHealth( + pypiPatch("legacy-lib", "2.0.0"), + okJson({ + info: { maintainer: "alice" }, + releases: { + "2.0.0": [ + { + upload_time_iso_8601: "2020-02-02T00:00:00.000Z", + yanked: true, + }, + ], + }, + }), + { now: NOW }, + ); + assert.deepEqual(findings[0]?.reasons, [ + "yanked", + "stale-release", + "sole-maintainer", + ]); +}); + +test("scanMaintenanceHealth skips healthy packages and unsupported ecosystems", async () => { + const findings = await scanMaintenanceHealth( + { + repoFullName: "o/r", + prNumber: 1, + files: [ + { path: "package.json", patch: '+ "healthy": "1.0.0",' }, + { path: "go.mod", patch: "+example.com/healthy v1.2.3" }, + ], + }, + okJson({ + versions: { "1.0.0": {} }, + time: { "1.0.0": "2026-01-01T00:00:00.000Z" }, + maintainers: [{}, {}], + }), + { now: NOW }, + ); + assert.deepEqual(findings, []); +}); + +test("scanMaintenanceHealth fails safe on fetch errors", async () => { + const findings = await scanMaintenanceHealth( + npmPatch("broken"), + async () => { + throw new Error("network down"); + }, + { now: NOW }, + ); + assert.deepEqual(findings, []); +}); + +test("renderBrief emits a public-safe maintenance-health block", () => { + const { promptSection } = renderBrief({ + maintenanceHealth: [ + { + ecosystem: "npm", + package: "legacy-lib", + version: "1.0.0", + reasons: ["deprecated", "stale-release", "sole-maintainer"], + deprecatedMessage: "No longer maintained", + lastReleaseDate: "2021-01-01T00:00:00.000Z", + maintainers: 1, + }, + ], + }); + assert.match(promptSection, /Dependency maintenance-health risks/); + assert.match(promptSection, /legacy-lib@1.0.0/); + assert.match(promptSection, /deprecated/); + assert.match(promptSection, /single-maintainer package/); + assert.match(promptSection, /2021\\-01\\-01/); +});