Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions review-enrichment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
200 changes: 200 additions & 0 deletions review-enrichment/src/analyzers/maintenance-health.ts
Original file line number Diff line number Diff line change
@@ -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<unknown | null> {
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<string, NpmRegistryVersion | undefined>;
time?: Record<string, string | undefined>;
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<RegistrySignal | null> {
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<string, PypiReleaseFile[] | undefined>;
}

export async function fetchPypiSignals(
name: string,
version: string,
fetchImpl: typeof fetch = fetch,
signal?: AbortSignal,
): Promise<RegistrySignal | null> {
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<MaintenanceHealthFinding[]> {
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;
}
3 changes: 3 additions & 0 deletions review-enrichment/src/brief.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -32,6 +33,8 @@ const ANALYZERS: Record<keyof BriefFindings, AnalyzerFn> = {
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),
Expand Down
32 changes: 32 additions & 0 deletions review-enrichment/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions review-enrichment/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -168,6 +185,7 @@ export interface BriefFindings {
lockfileDrift?: LockfileDriftFinding[];
secret?: SecretFinding[];
license?: LicenseFinding[];
maintenanceHealth?: MaintenanceHealthFinding[];
actionPin?: ActionPinFinding[];
installScript?: InstallScriptFinding[];
eol?: EolFinding[];
Expand Down
Loading