From 75d7ddc61b8eb5c5c1d40588462df5c18aed5ed3 Mon Sep 17 00:00:00 2001 From: GildardoDev <267998055+GildardoDev@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:20:44 -0500 Subject: [PATCH] feat(enrichment): add package maintenance-health and deprecated-dep scorer for npm and PyPI --- .env.example | 8 +- apps/gittensory-ui/src/lib/rees-analyzers.ts | 23 + review-enrichment/analyzer-metadata.json | 27 ++ .../src/analyzers/dep-maintenance-health.ts | 331 ++++++++++++++ review-enrichment/src/analyzers/registry.ts | 20 + review-enrichment/src/render.ts | 14 + review-enrichment/src/types.ts | 14 + .../test/analyzer-registry.test.ts | 1 + .../test/dep-maintenance-health.test.ts | 421 ++++++++++++++++++ 9 files changed, 855 insertions(+), 4 deletions(-) create mode 100644 review-enrichment/src/analyzers/dep-maintenance-health.ts create mode 100644 review-enrichment/test/dep-maintenance-health.test.ts diff --git a/.env.example b/.env.example index 3d09e3ebe..c5c85dafe 100644 --- a/.env.example +++ b/.env.example @@ -65,17 +65,17 @@ GITTENSORY_REVIEW_ENRICHMENT=false # Current analyzer names: # dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol,redos # provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig,nativeBuild -# history,docCommentDrift +# depMaintenanceHealth,history,docCommentDrift # # Profile defaults: # fast: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol -# redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild +# redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild,depMaintenanceHealth # balanced (default): dependency,lockfileDrift,secret,license,installScript,heavyDependency # actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature -# iacMisconfig,nativeBuild,history,docCommentDrift +# iacMisconfig,nativeBuild,depMaintenanceHealth,history,docCommentDrift # deep: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol # redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig -# nativeBuild,history,docCommentDrift +# nativeBuild,depMaintenanceHealth,history,docCommentDrift # END GENERATED REES ANALYZERS # Submitter-reputation spend control (internal-only): downgrades new/burst/low-rep diff --git a/apps/gittensory-ui/src/lib/rees-analyzers.ts b/apps/gittensory-ui/src/lib/rees-analyzers.ts index a73ff5eec..0ea266864 100644 --- a/apps/gittensory-ui/src/lib/rees-analyzers.ts +++ b/apps/gittensory-ui/src/lib/rees-analyzers.ts @@ -454,6 +454,29 @@ export const REES_ANALYZERS = [ notes: "Registry JSON is capped so large package metadata cannot monopolize REES memory.", }, }, + { + name: "depMaintenanceHealth", + title: "Dependency maintenance health", + category: "supply-chain", + cost: "registry", + defaultEnabled: true, + profiles: ["fast", "balanced", "deep"], + requires: ["files", "public-network"], + limits: { + maxQueries: 25, + maxRegistryJsonBytes: 2097152, + }, + docs: { + summary: + "Flags newly-added or upgraded dependencies that are deprecated/yanked by their maintainer or stale (no recent release).", + looksAt: "New/upgraded npm and PyPI dependency versions.", + reports: + "Package, version, ecosystem, the maintenance signal (deprecated/yanked/stale), and a public-safe reason.", + network: "Calls the npm and PyPI registries. No GitHub token required.", + notes: + "Registry JSON is capped so large package metadata cannot monopolize REES memory; PyPI versions are matched to releases by PEP 440 equality.", + }, + }, { name: "history", title: "Author and change-area history", diff --git a/review-enrichment/analyzer-metadata.json b/review-enrichment/analyzer-metadata.json index 9ad7e58ad..6dd4d2252 100644 --- a/review-enrichment/analyzer-metadata.json +++ b/review-enrichment/analyzer-metadata.json @@ -524,6 +524,33 @@ "notes": "Registry JSON is capped so large package metadata cannot monopolize REES memory." } }, + { + "name": "depMaintenanceHealth", + "title": "Dependency maintenance health", + "category": "supply-chain", + "cost": "registry", + "defaultEnabled": true, + "profiles": [ + "fast", + "balanced", + "deep" + ], + "requires": [ + "files", + "public-network" + ], + "limits": { + "maxQueries": 25, + "maxRegistryJsonBytes": 2097152 + }, + "docs": { + "summary": "Flags newly-added or upgraded dependencies that are deprecated/yanked by their maintainer or stale (no recent release).", + "looksAt": "New/upgraded npm and PyPI dependency versions.", + "reports": "Package, version, ecosystem, the maintenance signal (deprecated/yanked/stale), and a public-safe reason.", + "network": "Calls the npm and PyPI registries. No GitHub token required.", + "notes": "Registry JSON is capped so large package metadata cannot monopolize REES memory; PyPI versions are matched to releases by PEP 440 equality." + } + }, { "name": "history", "title": "Author and change-area history", diff --git a/review-enrichment/src/analyzers/dep-maintenance-health.ts b/review-enrichment/src/analyzers/dep-maintenance-health.ts new file mode 100644 index 000000000..babf685ce --- /dev/null +++ b/review-enrichment/src/analyzers/dep-maintenance-health.ts @@ -0,0 +1,331 @@ +// Package maintenance-health / deprecated-dep analyzer (#1511). For each dependency a PR newly ADDS or UPGRADES, +// flags the ones a maintainer would want to know about but the no-checkout reviewer cannot derive: a package that +// is DEPRECATED/yanked, or STALE (no release in roughly N years). Two deterministic registry signals only — no +// flaky "archived"/sole-maintainer/Scorecard probes. Reports package@version + a short factual reason. +// - npm: the packument `deprecated` field (non-empty string ⇒ deprecated, surface a short reason) and the `time` +// map (the newest version's publish date — stale when the most recent publish is older than the threshold). +// - PyPI: the PROJECT-level JSON `releases` map. The queried version is matched to its actual release key by +// PEP 440 equality (a `==1.0.0` requirement can be published as `1.0`); yanked when that release's files are +// all yanked, otherwise stale when the newest upload across all releases is older than the threshold. +import type { EnrichRequest, DepMaintenanceHealthFinding } from "../types.js"; +import { extractDependencyChanges } from "./dependency-scan.js"; + +const MAX_QUERIES = 25; +// Staleness threshold: flag a package whose most recent release is older than this. Two years in ms. +const STALE_AGE_MS = 2 * 365 * 24 * 60 * 60 * 1000; +// Cap the registry body we read so an oversized packument/project JSON fails closed instead of blowing the budget. +const MAX_REGISTRY_JSON_BYTES = 2 * 1024 * 1024; + +const NPM_PACKAGE_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$/; +const SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const PYPI_PACKAGE_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/; +// PyPI versions are PEP 440, not semver: `1.0`, `24.1`, `1.0rc1`, `1.0.post1`, `1!2.0`. Validate only that the +// string is non-empty and URL-path-safe (it goes into the project JSON URL) rather than imposing semver. +const PYPI_VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+!-]{0,63}$/; + +/** Is this dependency change one we can query a registry for (supported ecosystem + URL-safe name/version)? */ +function isQueryable(change: { ecosystem: string; package: string; to: string }): boolean { + if (change.ecosystem === "npm") return NPM_PACKAGE_RE.test(change.package) && SEMVER_RE.test(change.to); + if (change.ecosystem === "PyPI") return PYPI_PACKAGE_RE.test(change.package) && PYPI_VERSION_RE.test(change.to); + return false; +} + +interface ScanLimits { + maxQueries?: number; + staleAgeMs?: number; +} + +interface ScanOptions { + signal?: AbortSignal; + limits?: ScanLimits; + /** Injectable wall clock (ms epoch) so the end-to-end stale path is deterministically testable. */ + now?: number; +} + +/** A maintenance signal derived from registry metadata: `deprecated`/`yanked`, or `stale`. */ +type Health = { kind: "deprecated" | "yanked" | "stale"; reason: string }; + +/** Collapse whitespace and cap the length of a registry-supplied reason so the rendered line stays short + safe. */ +function tidyReason(value: string): string { + return value.replace(/\s+/g, " ").trim().slice(0, 140); +} + +/** Pure: the most recent publish date (ms epoch) from an npm `time` map, ignoring the `created`/`modified` + * pseudo-entries and any unparseable timestamp. Returns null when no real, parseable publish date exists. */ +export function newestNpmPublishMs(time: Record | undefined): number | null { + if (!time) return null; + let newest: number | null = null; + for (const [version, iso] of Object.entries(time)) { + if (version === "created" || version === "modified") continue; + const ms = Date.parse(iso); + if (!Number.isFinite(ms)) continue; // garbage timestamp → ignore this entry (fail safe, don't flag) + if (newest === null || ms > newest) newest = ms; + } + return newest; +} + +/** npm packument subset that carries the maintenance signals. `deprecated` is a string (the reason) when set, but + * registries have historically also emitted `true`/`false` — both non-string forms are handled defensively. */ +export interface NpmPackument { + deprecated?: unknown; + versions?: Record; + time?: Record; +} + +/** Pure: classify an npm package from its packument for the QUERIED version. npm exposes deprecation per version + * under `versions[].deprecated` (the reason string); a top-level `deprecated` is also accepted as a + * fallback. Otherwise stale when the newest real publish is older than `staleAgeMs`. `now` injected for tests. */ +export function npmHealth( + meta: NpmPackument, + version: string, + staleAgeMs: number, + now: number, +): Health | null { + // If the packument enumerates versions and the queried version is absent, it does not exist on npm — attribute + // no finding (mirrors the PyPI release-key resolution rather than reporting a phantom version as stale). + if ( + meta.versions != null && + typeof meta.versions === "object" && + !Object.prototype.hasOwnProperty.call(meta.versions, version) + ) { + return null; + } + // Deprecation lives on the queried version first, then the top level; a boolean/empty/whitespace value is NOT + // a deprecation (fail safe). Only a non-empty reason string flags. + const versionDeprecated = meta.versions?.[version]?.deprecated; + const deprecatedReason = + typeof versionDeprecated === "string" && versionDeprecated.trim() !== "" + ? versionDeprecated + : typeof meta.deprecated === "string" && meta.deprecated.trim() !== "" + ? meta.deprecated + : null; + if (deprecatedReason !== null) { + return { kind: "deprecated", reason: `deprecated by maintainer — ${tidyReason(deprecatedReason)}` }; + } + const newest = newestNpmPublishMs(meta.time); + if (newest !== null && now - newest > staleAgeMs) { + const years = Math.floor((now - newest) / (365 * 24 * 60 * 60 * 1000)); + return { kind: "stale", reason: `no release in ~${years}y (last publish ${new Date(newest).toISOString().slice(0, 10)})` }; + } + return null; +} + +/** PyPI PROJECT-level JSON subset (`/pypi//json`): the all-release `releases` map. Each version maps to + * its uploaded files; each file carries its upload date and a per-file `yanked` flag/reason. The project endpoint + * is used (not the version-specific one) so staleness sees the whole release history and yanked is read per file. */ +export interface PypiProjectJson { + releases?: Record< + string, + Array<{ + upload_time_iso_8601?: string; + upload_time?: string; + yanked?: boolean; + yanked_reason?: string | null; + }> + >; +} + +// Bounded PEP 440 public-version grammar (epoch, release, then optional pre/post/dev in that order, optional local). +// Anchored, so arbitrary suffix text (e.g. `1.0foo`) fails to match — an invalid version can never collide with a +// real release key. Groups: 1 epoch, 2 release, 3 pre-letter, 4 pre-num, 5 dash-post-num, 6 post-letter, +// 7 post-num, 8 dev-presence, 9 dev-num. +const PEP440_RE = + /^v?(?:(\d+)!)?(\d+(?:\.\d+)*)(?:[-_.]?(a|b|c|rc|alpha|beta|pre|preview)[-_.]?(\d+)?)?(?:-(\d+)|[-_.]?(post|rev|r)[-_.]?(\d+)?)?([-_.]?dev[-_.]?(\d+)?)?(?:\+[a-z0-9]+(?:[-_.][a-z0-9]+)*)?$/; + +/** Pure: a PEP 440 equality key for a version, or null when the string is NOT a valid PEP 440 version (fail closed, + * so garbage never matches a real release key). Drops a leading `v` and any local (`+...`) segment, strips + * insignificant trailing release zeros (so `1.0.0` == `1.0` == `1`), and normalizes pre/post/dev spellings and + * separators (so `1.0-rc1` == `1.0rc1` and `1.0-1` == `1.0.post1`). Used to match a requirement's version to + * PyPI's normalized release keys. */ +export function pep440Key(version: string): string | null { + const m = PEP440_RE.exec(version.trim().toLowerCase()); + if (!m) return null; + const epoch = m[1] ?? "0"; + const release = (m[2] ?? "").split(".").map((n) => Number.parseInt(n, 10)); + while (release.length > 1 && release[release.length - 1] === 0) release.pop(); // trailing zeros are insignificant + let pre = ""; + if (m[3]) { + const l = m[3]; + const letter = l === "alpha" ? "a" : l === "beta" ? "b" : l === "a" || l === "b" ? l : "rc"; // c/rc/pre/preview ⇒ rc + pre = `${letter}${m[4] ?? "0"}`; + } + let post = ""; + if (m[5] !== undefined) post = `post${m[5]}`; // implicit `-N` post form + else if (m[6]) post = `post${m[7] ?? "0"}`; // post/rev/r form + const dev = m[8] ? `dev${m[9] ?? "0"}` : ""; + return `${epoch}!${release.join(".")}${pre}${post}${dev}`; +} + +/** Pure: resolve the queried version to its actual key in the `releases` map — exact string match first, then by + * PEP 440 equality (PyPI may publish `==1.0.0` as `1.0`). Null when nothing matches. */ +export function resolvePypiReleaseKey( + releases: PypiProjectJson["releases"], + requested: string, +): string | null { + if (!releases) return null; + if (Object.prototype.hasOwnProperty.call(releases, requested)) return requested; + const want = pep440Key(requested); + if (want === null) return null; + for (const key of Object.keys(releases)) { + if (pep440Key(key) === want) return key; + } + return null; +} + +/** Pure: the most recent upload date (ms epoch) across all releases, ignoring malformed entries and unparseable + * timestamps. A release value that is not an array (or a file that is not an object) is skipped, never thrown on. */ +export function newestPypiUploadMs(releases: PypiProjectJson["releases"]): number | null { + if (!releases) return null; + let newest: number | null = null; + for (const files of Object.values(releases)) { + if (!Array.isArray(files)) continue; // malformed entry (e.g. an object/null under a version) → skip, fail safe + for (const file of files) { + if (!file || typeof file !== "object") continue; + const iso = file.upload_time_iso_8601 ?? file.upload_time; + if (!iso) continue; + const ms = Date.parse(iso); + if (!Number.isFinite(ms)) continue; // garbage timestamp → ignore (fail safe) + if (newest === null || ms > newest) newest = ms; + } + } + return newest; +} + +/** Pure: classify a PyPI release from the PROJECT-level JSON. The queried version is matched to its real release + * key by PEP 440 equality; if it cannot be resolved to a published release we report NOTHING (a maintenance signal + * must not be attributed to a version that may not exist on PyPI). When resolved: yanked if that release has files + * and every one is an object marked yanked (malformed entries fail safe, never throw); otherwise stale when the + * newest upload across the project's releases is older than `staleAgeMs`. */ +export function pypiHealth( + data: PypiProjectJson, + version: string, + staleAgeMs: number, + now: number, +): Health | null { + const key = resolvePypiReleaseKey(data.releases, version); + if (key === null) return null; // requested version not a published release → no finding (fail safe) + // The QUERIED release must itself be a well-formed, non-empty array of file objects. A malformed/empty queried + // release fails safe with NO finding — otherwise project-wide staleness from OTHER releases would be wrongly + // attributed to a version whose own metadata we cannot trust. + const files = data.releases?.[key]; + if ( + !Array.isArray(files) || + files.length === 0 || + !files.every((f) => f != null && typeof f === "object") + ) { + return null; + } + if (files.every((f) => f.yanked === true)) { + const reasonFile = files.find( + (f) => typeof f.yanked_reason === "string" && f.yanked_reason.trim() !== "", + ); + const why = reasonFile ? ` — ${tidyReason(reasonFile.yanked_reason as string)}` : ""; + return { kind: "yanked", reason: `release yanked from PyPI${why}` }; + } + const newest = newestPypiUploadMs(data.releases); + if (newest !== null && now - newest > staleAgeMs) { + const years = Math.floor((now - newest) / (365 * 24 * 60 * 60 * 1000)); + return { kind: "stale", reason: `no release in ~${years}y (last upload ${new Date(newest).toISOString().slice(0, 10)})` }; + } + return null; +} + +/** Read a response body as text, bounded to MAX_REGISTRY_JSON_BYTES so an oversized registry payload fails closed + * (returns null) rather than blowing the analyzer's memory/time budget. Mirrors the bounded reader in + * native-build.ts: an over-cap content-length short-circuits, and a streamed body is aborted once it exceeds the cap. */ +async function readJsonText(response: Response): Promise { + const contentLength = response.headers.get("content-length"); + if (contentLength !== null) { + const parsedLength = Number.parseInt(contentLength, 10); + if (Number.isFinite(parsedLength) && parsedLength > MAX_REGISTRY_JSON_BYTES) return null; + } + const reader = response.body?.getReader(); + if (!reader) { + const buffer = await response.arrayBuffer(); + if (buffer.byteLength > MAX_REGISTRY_JSON_BYTES) return null; + return new TextDecoder().decode(buffer); + } + const decoder = new TextDecoder(); + let totalBytes = 0; + let text = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_REGISTRY_JSON_BYTES) { + await reader.cancel(); + return null; + } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return text; + } finally { + reader.releaseLock(); + } +} + +async function fetchJson( + fetchImpl: typeof fetch, + url: string, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) return null; + try { + const response = await fetchImpl(url, { signal }); + if (!response.ok) return null; + const text = await readJsonText(response); + return text === null ? null : JSON.parse(text); + } catch { + return null; + } +} + +/** Analyzer entrypoint: added/upgraded deps → registry metadata → only the deps that are deprecated/yanked or stale. */ +export async function scanDepMaintenanceHealth( + req: EnrichRequest, + fetchImpl: typeof fetch = fetch, + options: ScanOptions = {}, +): Promise { + const staleAgeMs = options.limits?.staleAgeMs ?? STALE_AGE_MS; + const now = options.now ?? Date.now(); + // Filter to queryable (supported, URL-safe) changes BEFORE applying the cap, so unsupported/invalid entries + // can't consume the budget and starve a later real dependency. + const changes = extractDependencyChanges(req.files ?? []) + .filter(isQueryable) + .slice(0, options.limits?.maxQueries ?? MAX_QUERIES); + const findings: DepMaintenanceHealthFinding[] = []; + for (const change of changes) { + if (options.signal?.aborted) break; + + let health: Health | null = null; + if (change.ecosystem === "npm") { + const data = (await fetchJson( + fetchImpl, + `https://registry.npmjs.org/${encodeURIComponent(change.package)}`, + options.signal, + )) as NpmPackument | null; + health = data && npmHealth(data, change.to, staleAgeMs, now); + } else { + // PyPI — the only other ecosystem isQueryable admits. Project-level JSON carries the full `releases` map. + const data = (await fetchJson( + fetchImpl, + `https://pypi.org/pypi/${encodeURIComponent(change.package)}/json`, + options.signal, + )) as PypiProjectJson | null; + health = data && pypiHealth(data, change.to, staleAgeMs, now); + } + + if (health) { + findings.push({ + ecosystem: change.ecosystem as "npm" | "PyPI", + package: change.package, + version: change.to, + kind: health.kind, + reason: health.reason, + }); + } + } + return findings; +} diff --git a/review-enrichment/src/analyzers/registry.ts b/review-enrichment/src/analyzers/registry.ts index 9d8967055..c07caba41 100644 --- a/review-enrichment/src/analyzers/registry.ts +++ b/review-enrichment/src/analyzers/registry.ts @@ -2,6 +2,7 @@ import { scanActionPins } from "./actions-pin.js"; import { scanAssetWeight } from "./asset-weight.js"; import { scanCodeowners } from "./codeowners.js"; import { scanCommitSignature } from "./commit-signature.js"; +import { scanDepMaintenanceHealth } from "./dep-maintenance-health.js"; import { dependencyAnalyzer } from "./dependency/descriptor.js"; import { scanDocCommentDrift } from "./doc-comment-drift.js"; import { scanEol } from "./eol-check.js"; @@ -335,6 +336,25 @@ export const ANALYZER_DESCRIPTORS = [ run: (req, { signal, analysis, diagnostics }) => scanNativeBuild(req, fetch, { signal, analysis, diagnostics }), }), + descriptor({ + name: "depMaintenanceHealth", + title: "Dependency maintenance health", + category: "supply-chain", + cost: "registry", + defaultEnabled: true, + requires: ["files", "public-network"], + limits: { maxQueries: 25, maxRegistryJsonBytes: 2 * 1024 * 1024 }, + docs: { + summary: + "Flags newly-added or upgraded dependencies that are deprecated/yanked by their maintainer or stale (no recent release).", + looksAt: "New/upgraded npm and PyPI dependency versions.", + reports: "Package, version, ecosystem, the maintenance signal (deprecated/yanked/stale), and a public-safe reason.", + network: "Calls the npm and PyPI registries. No GitHub token required.", + notes: + "Registry JSON is capped so large package metadata cannot monopolize REES memory; PyPI versions are matched to releases by PEP 440 equality.", + }, + run: (req, { signal }) => scanDepMaintenanceHealth(req, fetch, { signal }), + }), descriptor({ name: "history", title: "Author and change-area history", diff --git a/review-enrichment/src/render.ts b/review-enrichment/src/render.ts index c90461c43..f92903ff7 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -295,6 +295,20 @@ export function renderBrief( } } + const depHealth = findings.depMaintenanceHealth ?? []; + if (depHealth.length) { + lines.push( + "### Deprecated / yanked / stale dependencies (maintenance-health — prefer a maintained alternative)", + ); + for (const item of depHealth) { + // `reason` can embed registry-supplied text (npm `deprecated` / PyPI `yanked_reason`), so it is escaped + // through promptText before splicing — never spliced raw into the prompt block. + lines.push( + `- ${safeCodeSpan(`${item.package}@${item.version}`)} (${item.ecosystem}): ${promptText(item.reason)}`, + ); + } + } + const history = findings.history ?? []; for (const item of history) { const entries: string[] = []; diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index d3e4bd61e..7893f932d 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -239,6 +239,19 @@ export interface NativeBuildFinding { reason: string; } +/** A newly-added/upgraded direct dependency (npm/PyPI) that is DEPRECATED/yanked by its maintainer, or STALE + * (no release in roughly N years) — maintenance-health risks the no-checkout reviewer cannot derive from the + * registry. Reports package@version + a short factual reason from the metadata only. (#1511) */ +export interface DepMaintenanceHealthFinding { + ecosystem: "npm" | "PyPI"; + package: string; + version: string; + /** `deprecated` (npm), `yanked` (PyPI), or `stale` (no recent release in either ecosystem). */ + kind: "deprecated" | "yanked" | "stale"; + /** Short, public-safe explanation of the maintenance signal. */ + reason: string; +} + /** Public-safe historical context the no-checkout reviewer is blind to and the engine deliberately does NOT compute: * the author's track record IN THIS repo, past PRs that already changed the same files (with their outcome), and * whether the diff covers the linked issue's stated requirement. Surfaced as a single block (0-or-1 element array). @@ -289,6 +302,7 @@ export interface BriefFindings { commitSignature?: CommitSignatureFinding[]; iacMisconfig?: IacMisconfigFinding[]; nativeBuild?: NativeBuildFinding[]; + depMaintenanceHealth?: DepMaintenanceHealthFinding[]; history?: HistoryFinding[]; docCommentDrift?: DocCommentDriftFinding[]; } diff --git a/review-enrichment/test/analyzer-registry.test.ts b/review-enrichment/test/analyzer-registry.test.ts index 6ec736433..1fed0af05 100644 --- a/review-enrichment/test/analyzer-registry.test.ts +++ b/review-enrichment/test/analyzer-registry.test.ts @@ -27,6 +27,7 @@ const EXPECTED_ANALYZERS = [ "commitSignature", "iacMisconfig", "nativeBuild", + "depMaintenanceHealth", "history", "docCommentDrift", ]; diff --git a/review-enrichment/test/dep-maintenance-health.test.ts b/review-enrichment/test/dep-maintenance-health.test.ts new file mode 100644 index 000000000..679bed1a9 --- /dev/null +++ b/review-enrichment/test/dep-maintenance-health.test.ts @@ -0,0 +1,421 @@ +// Units for the maintenance-health / deprecated-dep analyzer (#1511). Own file (not enrichment.test.ts) so +// concurrent analyzer PRs don't collide. Runs against the compiled dist/. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + newestNpmPublishMs, + newestPypiUploadMs, + npmHealth, + pypiHealth, + pep440Key, + resolvePypiReleaseKey, + scanDepMaintenanceHealth, +} from "../dist/analyzers/dep-maintenance-health.js"; +import { renderBrief } from "../dist/render.js"; + +const NOW = Date.parse("2026-06-29T00:00:00Z"); +const YEAR_MS = 365 * 24 * 60 * 60 * 1000; +const STALE_MS = 2 * YEAR_MS; +const iso = (ms) => new Date(ms).toISOString(); + +// A package.json diff that ADDS one dependency (a single `+` line → from === null). +const npmAdd = (name, version = "1.0.0") => ({ + repoFullName: "o/r", + prNumber: 1, + files: [{ path: "package.json", patch: `@@ -1,0 +1,1 @@\n+ "${name}": "^${version}"` }], +}); +const pypiAdd = (name, version = "1.0.0") => ({ + repoFullName: "o/r", + prNumber: 1, + files: [{ path: "requirements.txt", patch: `@@ -1,0 +1,1 @@\n+${name}==${version}` }], +}); +// Real Response objects so the bounded reader (headers + body stream) works exactly as in production. +const jsonResponse = (body, init) => new Response(JSON.stringify(body), init); +const npmFetch = (packument) => async () => jsonResponse(packument); +const pypiFetch = (data) => async () => jsonResponse(data); +const status = (code) => async () => jsonResponse({}, { status: code }); +const throwingFetch = async () => { + throw new Error("network down"); +}; +const freshNpmTime = { "1.0.0": iso(NOW - 30 * 24 * 60 * 60 * 1000) }; +const freshPypiReleases = { "1.0.0": [{ upload_time_iso_8601: iso(NOW - 30 * 24 * 60 * 60 * 1000) }] }; + +test("npmHealth: a non-empty deprecated STRING on the queried version is flagged with its reason", () => { + const hit = npmHealth({ versions: { "1.0.0": { deprecated: "use foo instead" } }, time: freshNpmTime }, "1.0.0", STALE_MS, NOW); + assert.equal(hit?.kind, "deprecated"); + assert.match(hit.reason, /use foo instead/); +}); + +test("npmHealth: a top-level deprecated STRING is accepted as a fallback (incl. when version-level is empty)", () => { + assert.match(npmHealth({ deprecated: "gone", time: freshNpmTime }, "1.0.0", STALE_MS, NOW).reason, /gone/); + // version-level present but empty ⇒ falls back to the top-level reason (documents the precedence). + const hit = npmHealth({ versions: { "1.0.0": { deprecated: "" } }, deprecated: "top reason", time: freshNpmTime }, "1.0.0", STALE_MS, NOW); + assert.equal(hit?.kind, "deprecated"); + assert.match(hit.reason, /top reason/); +}); + +test("npmHealth: deprecation on a DIFFERENT version is not attributed to the queried version", () => { + assert.equal(npmHealth({ versions: { "0.9.0": { deprecated: "old" } }, time: freshNpmTime }, "1.0.0", STALE_MS, NOW), null); +}); + +test("npmHealth: deprecated false/boolean/empty/whitespace is NOT a deprecation (fail safe)", () => { + assert.equal(npmHealth({ versions: { "1.0.0": { deprecated: false } }, time: freshNpmTime }, "1.0.0", STALE_MS, NOW), null); + assert.equal(npmHealth({ versions: { "1.0.0": { deprecated: true } }, time: freshNpmTime }, "1.0.0", STALE_MS, NOW), null); + assert.equal(npmHealth({ versions: { "1.0.0": { deprecated: "" } }, time: freshNpmTime }, "1.0.0", STALE_MS, NOW), null); + assert.equal(npmHealth({ deprecated: " ", time: freshNpmTime }, "1.0.0", STALE_MS, NOW), null); +}); + +test("npmHealth: a package with no release in >2y is flagged stale", () => { + const hit = npmHealth({ time: { "1.0.0": iso(NOW - 3 * YEAR_MS) } }, "1.0.0", STALE_MS, NOW); + assert.equal(hit?.kind, "stale"); + assert.match(hit.reason, /no release in ~3y/); +}); + +test("npmHealth: a recently-published healthy package yields no finding", () => { + assert.equal(npmHealth({ time: freshNpmTime }, "1.0.0", STALE_MS, NOW), null); +}); + +test("npmHealth: missing time / no parseable date fails safe (no stale finding)", () => { + assert.equal(npmHealth({}, "1.0.0", STALE_MS, NOW), null); + assert.equal(npmHealth({ time: { "1.0.0": "not-a-date" } }, "1.0.0", STALE_MS, NOW), null); +}); + +test("newestNpmPublishMs: ignores created/modified and unparseable timestamps", () => { + const ms = newestNpmPublishMs({ + created: iso(NOW), + modified: iso(NOW), + "1.0.0": iso(NOW - 5 * YEAR_MS), + "1.1.0": "garbage", + "2.0.0": iso(NOW - 3 * YEAR_MS), + }); + assert.equal(ms, NOW - 3 * YEAR_MS); // newest real publish, not the created/modified pseudo-entries +}); + +test("pep440Key: trailing-zero-insignificant and separator-normalized versions compare equal", () => { + assert.equal(pep440Key("1.0.0"), pep440Key("1.0")); + assert.equal(pep440Key("1.0.0"), pep440Key("1")); + assert.equal(pep440Key("1.0-rc1"), pep440Key("1.0rc1")); + assert.equal(pep440Key("1.0.rc1"), pep440Key("1.0rc1")); // dotted separator too + assert.equal(pep440Key("1.0c1"), pep440Key("1.0rc1")); // c is an alias for rc + assert.equal(pep440Key("1.0-1"), pep440Key("1.0.post1")); // implicit post release + assert.equal(pep440Key("V1.0.0+local"), pep440Key("1.0")); // leading v + local segment ignored + assert.notEqual(pep440Key("1.10"), pep440Key("1.1")); // distinct versions stay distinct + assert.notEqual(pep440Key("1!2.0"), pep440Key("2.0")); // epoch is significant + assert.equal(pep440Key("not-a-version"), null); +}); + +test("pep440Key: rejects malformed numeric-prefix versions (arbitrary suffix) instead of normalizing them", () => { + // The blocker case: `1.0foo` must NOT collide with a release keyed `1.0.foo`; both are invalid → null. + assert.equal(pep440Key("1.0foo"), null); + assert.equal(pep440Key("1.0.foo"), null); + assert.equal(pep440Key("1.0rc"), "0!1rc0"); // a real pre-release with implicit number IS valid +}); + +test("resolvePypiReleaseKey: an invalid requested version never false-matches a release key", () => { + assert.equal(resolvePypiReleaseKey({ "1.0.foo": [] }, "1.0foo"), null); +}); + +test("resolvePypiReleaseKey: exact match first, then PEP 440 equality, else null", () => { + assert.equal(resolvePypiReleaseKey({ "1.0.0": [] }, "1.0.0"), "1.0.0"); + assert.equal(resolvePypiReleaseKey({ "1.0": [] }, "1.0.0"), "1.0"); // ==1.0.0 resolves to release 1.0 + assert.equal(resolvePypiReleaseKey({ "2.0": [] }, "1.0.0"), null); + assert.equal(resolvePypiReleaseKey(undefined, "1.0.0"), null); +}); + +test("pypiHealth: a yanked version is flagged even when its release key is a PEP 440 equivalent", () => { + // requirement ==1.0.0, but PyPI keys the release as 1.0 — must still resolve and flag yanked. + const hit = pypiHealth( + { releases: { "1.0": [{ upload_time_iso_8601: iso(NOW), yanked: true, yanked_reason: "security issue" }] } }, + "1.0.0", + STALE_MS, + NOW, + ); + assert.equal(hit?.kind, "yanked"); + assert.match(hit.reason, /security issue/); +}); + +test("pypiHealth: a yanked version with no/null reason still flags (no reason appended)", () => { + const hit = pypiHealth({ releases: { "1.0.0": [{ upload_time_iso_8601: iso(NOW), yanked: true, yanked_reason: null }] } }, "1.0.0", STALE_MS, NOW); + assert.equal(hit?.kind, "yanked"); + assert.match(hit.reason, /release yanked from PyPI$/); +}); + +test("pypiHealth: yanked is read for the QUERIED version only (a different yanked version is ignored)", () => { + const data = { + releases: { + "1.0.0": [{ upload_time_iso_8601: iso(NOW), yanked: true }], + "2.0.0": [{ upload_time_iso_8601: iso(NOW) }], + }, + }; + assert.equal(pypiHealth(data, "2.0.0", STALE_MS, NOW), null); +}); + +test("pypiHealth: a partially-yanked version (some files not yanked) / no files is not flagged yanked", () => { + const partial = { + releases: { "1.0.0": [{ upload_time_iso_8601: iso(NOW), yanked: true }, { upload_time_iso_8601: iso(NOW), yanked: false }] }, + }; + assert.notEqual(pypiHealth(partial, "1.0.0", STALE_MS, NOW)?.kind, "yanked"); + assert.equal(pypiHealth({ releases: freshPypiReleases }, "1.0.0", STALE_MS, NOW), null); +}); + +test("pypiHealth: no upload in >2y is flagged stale; falls back to upload_time", () => { + const hit = pypiHealth({ releases: { "1.0.0": [{ upload_time: iso(NOW - 4 * YEAR_MS) }] } }, "1.0.0", STALE_MS, NOW); + assert.equal(hit?.kind, "stale"); + assert.match(hit.reason, /no release in ~4y/); +}); + +test("pypiHealth: missing releases / unparseable upload date fails safe", () => { + assert.equal(pypiHealth({}, "1.0.0", STALE_MS, NOW), null); + assert.equal(pypiHealth({ releases: { "1.0.0": [{ upload_time_iso_8601: "nope" }] } }, "1.0.0", STALE_MS, NOW), null); +}); + +test("pypiHealth: a malformed QUERIED release fails safe even when another release looks old (no false stale)", () => { + // The gate's exact regression case: 1.0.0 resolves but is malformed ({}), 0.9.0 is old — 1.0.0 must NOT be + // reported stale from another release's date. + assert.equal( + pypiHealth( + { releases: { "1.0.0": {}, "0.9.0": [{ upload_time_iso_8601: iso(NOW - 4 * YEAR_MS) }] } }, + "1.0.0", + STALE_MS, + NOW, + ), + null, + ); + // empty file list and mixed malformed-plus-valid file arrays also fail safe. + assert.equal(pypiHealth({ releases: { "1.0.0": [] } }, "1.0.0", STALE_MS, NOW), null); + assert.equal( + pypiHealth({ releases: { "1.0.0": [null, { upload_time_iso_8601: iso(NOW) }] } }, "1.0.0", STALE_MS, NOW), + null, + ); +}); + +test("npmHealth: a queried version absent from the packument's versions map yields no finding", () => { + // versions enumerates 0.9.0 only; querying 1.0.0 (which would otherwise look stale from `time`) must not flag. + assert.equal( + npmHealth( + { versions: { "0.9.0": {} }, time: { "0.9.0": iso(NOW - 4 * YEAR_MS) } }, + "1.0.0", + STALE_MS, + NOW, + ), + null, + ); +}); + +test("pypiHealth / newestPypiUploadMs: malformed (non-array / null / non-object) release values fail safe, never throw", () => { + assert.equal(newestPypiUploadMs({ "1.0.0": {} }), null); + assert.equal(newestPypiUploadMs({ "1.0.0": null }), null); + assert.equal(newestPypiUploadMs({ "1.0.0": [null, "x", 7] }), null); + assert.doesNotThrow(() => pypiHealth({ releases: { "1.0.0": {} } }, "1.0.0", STALE_MS, NOW)); + assert.equal(pypiHealth({ releases: { "1.0.0": {} } }, "1.0.0", STALE_MS, NOW), null); + assert.doesNotThrow(() => pypiHealth({ releases: { "1.0.0": [null] } }, "1.0.0", STALE_MS, NOW)); + assert.equal(pypiHealth({ releases: { "1.0.0": [null, "x"] } }, "1.0.0", STALE_MS, NOW), null); +}); + +test("newestPypiUploadMs: handles missing/empty file lists and picks the newest", () => { + assert.equal(newestPypiUploadMs(undefined), null); + assert.equal(newestPypiUploadMs({ "1.0.0": [] }), null); + const ms = newestPypiUploadMs({ + "1.0.0": [{ upload_time_iso_8601: iso(NOW - 5 * YEAR_MS) }], + "2.0.0": [{ upload_time_iso_8601: iso(NOW - 1 * YEAR_MS) }], + }); + assert.equal(ms, NOW - 1 * YEAR_MS); +}); + +test("scanDepMaintenanceHealth: npm deprecated dependency is flagged", async () => { + const findings = await scanDepMaintenanceHealth( + npmAdd("request"), + npmFetch({ deprecated: "no longer supported", time: freshNpmTime }), + ); + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, "deprecated"); + assert.equal(findings[0].ecosystem, "npm"); + assert.equal(findings[0].package, "request"); + assert.equal(findings[0].version, "1.0.0"); +}); + +test("scanDepMaintenanceHealth: end-to-end stale finding via the injected now clock", async () => { + const findings = await scanDepMaintenanceHealth( + npmAdd("oldpkg"), + npmFetch({ time: { "1.0.0": iso(NOW - 4 * YEAR_MS) } }), + { now: NOW }, + ); + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, "stale"); + assert.equal(findings[0].package, "oldpkg"); +}); + +test("scanDepMaintenanceHealth: a PyPI yanked release is matched by PEP 440 equality (==1.0.0 → release 1.0)", async () => { + const findings = await scanDepMaintenanceHealth( + pypiAdd("badpkg", "1.0.0"), + pypiFetch({ releases: { "1.0": [{ upload_time_iso_8601: iso(NOW), yanked: true, yanked_reason: "broken" }] } }), + ); + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, "yanked"); + assert.equal(findings[0].ecosystem, "PyPI"); + assert.equal(findings[0].version, "1.0.0"); // reports the requirement's version +}); + +test("scanDepMaintenanceHealth: queries PyPI at the project-level endpoint", async () => { + let requested = ""; + await scanDepMaintenanceHealth(pypiAdd("requests", "2.31.0"), async (url) => { + requested = url; + return jsonResponse({ releases: {} }); + }); + assert.equal(requested, "https://pypi.org/pypi/requests/json"); +}); + +test("scanDepMaintenanceHealth: healthy npm and PyPI dependencies are not flagged", async () => { + assert.deepEqual(await scanDepMaintenanceHealth(npmAdd("lodash"), npmFetch({ time: freshNpmTime })), []); + assert.deepEqual(await scanDepMaintenanceHealth(pypiAdd("requests"), pypiFetch({ releases: freshPypiReleases })), []); +}); + +test("scanDepMaintenanceHealth: a scoped npm name is URL-encoded and still queryable", async () => { + let requested = ""; + const findings = await scanDepMaintenanceHealth(npmAdd("@scope/pkg"), async (url) => { + requested = url; + return jsonResponse({ deprecated: "gone", time: freshNpmTime }); + }); + assert.equal(findings.length, 1); + assert.match(requested, /%40scope%2Fpkg/); // encodeURIComponent applied to the scoped name +}); + +test("scanDepMaintenanceHealth: unsupported ecosystems and malformed names are never queried", async () => { + const req = { + repoFullName: "o/r", + prNumber: 1, + files: [ + { path: "go.mod", patch: `@@ -1,0 +1,1 @@\n+require example.com/x v1.0.0` }, // Go — unsupported + { path: "package.json", patch: `@@ -1,0 +1,1 @@\n+ "BadCaps": "^1.0.0"` }, // invalid npm name + ], + }; + let called = false; + const out = await scanDepMaintenanceHealth(req, async () => { + called = true; + return status(200)(); + }); + assert.deepEqual(out, []); + assert.equal(called, false); // nothing queryable → no registry call +}); + +test("scanDepMaintenanceHealth: only DIRECT added/upgraded deps are scanned (an upgrade is queried)", async () => { + const upgrade = { + repoFullName: "o/r", + prNumber: 1, + files: [{ path: "package.json", patch: `@@ -1,1 +1,1 @@\n- "request": "^1.0.0"\n+ "request": "^2.0.0"` }], + }; + const findings = await scanDepMaintenanceHealth(upgrade, async () => + jsonResponse({ deprecated: "use a maintained fork", time: freshNpmTime }), + ); + assert.equal(findings.length, 1); + assert.equal(findings[0].version, "2.0.0"); // the upgraded-to version is reported +}); + +test("scanDepMaintenanceHealth: the query cap counts only queryable changes (skips don't starve a real dep)", async () => { + const goLines = Array.from({ length: 25 }, (_, i) => `+require example.com/m${i} v1.0.0`).join("\n"); + const req = { + repoFullName: "o/r", + prNumber: 1, + files: [ + { path: "go.mod", patch: `@@ -1,0 +1,25 @@\n${goLines}` }, + { path: "package.json", patch: `@@ -1,0 +1,1 @@\n+ "request": "^1.0.0"` }, + ], + }; + const findings = await scanDepMaintenanceHealth( + req, + npmFetch({ deprecated: "gone", time: freshNpmTime }), + { limits: { maxQueries: 25 } }, + ); + assert.equal(findings.length, 1); + assert.equal(findings[0].package, "request"); +}); + +test("scanDepMaintenanceHealth: the query cap bounds total fetches", async () => { + const lines = Array.from({ length: 5 }, (_, i) => `+ "pkg${i}": "^1.0.0"`).join("\n"); + const req = { + repoFullName: "o/r", + prNumber: 1, + files: [{ path: "package.json", patch: `@@ -1,0 +1,5 @@\n${lines}` }], + }; + let calls = 0; + await scanDepMaintenanceHealth( + req, + async () => { + calls += 1; + return jsonResponse({ time: freshNpmTime }); + }, + { limits: { maxQueries: 2 } }, + ); + assert.equal(calls, 2); // capped at maxQueries even though 5 deps were added +}); + +test("scanDepMaintenanceHealth fails safe on a non-ok or throwing fetch", async () => { + assert.deepEqual(await scanDepMaintenanceHealth(npmAdd("request"), status(404)), []); + assert.deepEqual(await scanDepMaintenanceHealth(npmAdd("request"), throwingFetch), []); +}); + +test("scanDepMaintenanceHealth fails safe on malformed registry JSON (npm + PyPI)", async () => { + const malformed = async () => jsonResponse({ unexpected: "shape" }); + assert.deepEqual(await scanDepMaintenanceHealth(npmAdd("request"), malformed), []); + assert.deepEqual(await scanDepMaintenanceHealth(pypiAdd("requests"), malformed), []); +}); + +test("scanDepMaintenanceHealth fails closed on an oversized registry body (content-length over the cap)", async () => { + // A real deprecation is present, but the body is declared larger than the 2 MiB cap, so the bounded reader + // returns null and nothing is flagged — an oversized packument cannot blow the analyzer budget. + const oversized = async () => + new Response(JSON.stringify({ deprecated: "gone", time: freshNpmTime }), { + headers: { "content-length": String(2 * 1024 * 1024 + 1) }, + }); + assert.deepEqual(await scanDepMaintenanceHealth(npmAdd("request"), oversized), []); +}); + +test("pep440Key: pins the supported subset — epochs, post/dev ordering, local versions", () => { + assert.equal(pep440Key("1!1.0"), "1!1"); // epoch preserved, trailing zero dropped + assert.equal(pep440Key("1.0.post1.dev2"), "0!1post1dev2"); // post before dev + assert.equal(pep440Key("1.0.dev0"), "0!1dev0"); + assert.equal(pep440Key("1.2.3+ubuntu.1"), pep440Key("1.2.3")); // local segment ignored for equality + assert.notEqual(pep440Key("1.0.post1"), pep440Key("1.0.dev1")); // post and dev are distinct +}); + +test("pypiHealth: no finding when the requested version cannot be resolved to a published release (fail safe)", () => { + // The requested version (1.0.0) is not present in `releases` (only 0.9.0 is), so we must NOT attribute a stale + // (or any) finding to a version that may not exist on PyPI — even though the project itself looks old. + assert.equal( + pypiHealth( + { releases: { "0.9.0": [{ upload_time_iso_8601: iso(NOW - 4 * YEAR_MS) }] } }, + "1.0.0", + STALE_MS, + NOW, + ), + null, + ); + // But when the requested version DOES resolve, package staleness is still reported. + const resolved = pypiHealth( + { releases: { "1.0.0": [{ upload_time_iso_8601: iso(NOW - 4 * YEAR_MS) }] } }, + "1.0.0", + STALE_MS, + NOW, + ); + assert.equal(resolved?.kind, "stale"); +}); + +test("scanDepMaintenanceHealth stops on an already-aborted signal", async () => { + const findings = await scanDepMaintenanceHealth(npmAdd("request"), npmFetch({ deprecated: "gone", time: freshNpmTime }), { + signal: AbortSignal.abort(), + }); + assert.deepEqual(findings, []); +}); + +test("renderBrief emits a public-safe deprecated/stale block and escapes registry-supplied reasons", () => { + const { promptSection } = renderBrief({ + depMaintenanceHealth: [ + { ecosystem: "npm", package: "request", version: "2.88.2", kind: "deprecated", reason: "deprecated by maintainer — use `axios`*injected*" }, + { ecosystem: "PyPI", package: "oldpkg", version: "0.1.0", kind: "stale", reason: "no release in ~5y (last upload 2020-01-01)" }, + ], + }); + assert.match(promptSection, /Deprecated \/ yanked \/ stale dependencies/); + assert.match(promptSection, /request@2\.88\.2/); + assert.match(promptSection, /oldpkg@0\.1\.0/); + assert.doesNotMatch(promptSection, /\*injected\*/); // markdown metacharacters from registry text are escaped +});