diff --git a/.env.example b/.env.example index 24836986fa..84714fbe9c 100644 --- a/.env.example +++ b/.env.example @@ -65,25 +65,26 @@ 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,duplication,churnHotspot,blameLink,approvalIntegrity,ciCheckSignals -# undocumentedExport,staleBranch,commitHygiene,pendingReviewRequests,testRatio,migrationSafety -# looseRange,terminology,todoMarker,magicNumber,conflictMarker,commitLint +# packageHealth,history,docCommentDrift,duplication,churnHotspot,blameLink,approvalIntegrity +# ciCheckSignals,undocumentedExport,staleBranch,commitHygiene,pendingReviewRequests,testRatio +# migrationSafety,looseRange,terminology,todoMarker,magicNumber,conflictMarker,commitLint # # Profile defaults: # fast: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol -# redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild,testRatio,migrationSafety -# looseRange,terminology,todoMarker,magicNumber,conflictMarker +# redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild,packageHealth,testRatio +# migrationSafety,looseRange,terminology,todoMarker,magicNumber,conflictMarker # balanced (default): dependency,lockfileDrift,secret,license,installScript,heavyDependency # actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature -# iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot,blameLink -# approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch,commitHygiene +# iacMisconfig,nativeBuild,packageHealth,history,docCommentDrift,duplication,churnHotspot +# blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch,commitHygiene # pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber # conflictMarker,commitLint # deep: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol # redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig -# nativeBuild,history,docCommentDrift,duplication,churnHotspot,blameLink,approvalIntegrity -# ciCheckSignals,undocumentedExport,staleBranch,commitHygiene,pendingReviewRequests,testRatio -# migrationSafety,looseRange,terminology,todoMarker,magicNumber,conflictMarker,commitLint +# nativeBuild,packageHealth,history,docCommentDrift,duplication,churnHotspot,blameLink +# approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch,commitHygiene +# pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber +# conflictMarker,commitLint # 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 e2f514346d..2d03d56bfd 100644 --- a/apps/gittensory-ui/src/lib/rees-analyzers.ts +++ b/apps/gittensory-ui/src/lib/rees-analyzers.ts @@ -455,6 +455,31 @@ export const REES_ANALYZERS = [ notes: "Registry JSON is capped so large package metadata cannot monopolize REES memory.", }, }, + { + name: "packageHealth", + title: "Package maintenance health", + category: "supply-chain", + cost: "registry", + defaultEnabled: true, + profiles: ["fast", "balanced", "deep"], + requires: ["files", "public-network"], + limits: { + maxQueries: 25, + maxFindings: 25, + staleDays: 730, + }, + docs: { + summary: + "Flags newly-added or upgraded npm/PyPI dependencies with maintenance-health risk signals.", + looksAt: + "Direct dependency changes in package.json and requirements.txt, then package registry metadata.", + reports: + "Package, version, ecosystem, direction, and public-safe maintenance signal details.", + network: "Calls npm, PyPI, and ecosyste.ms package APIs. No GitHub token required.", + notes: + "Fail-safe and bounded: unsupported ecosystems, invalid names, failed calls, and oversized responses stay silent.", + }, + }, { name: "history", title: "Author and change-area history", diff --git a/review-enrichment/README.md b/review-enrichment/README.md index 415fb409dc..cd3bebb021 100644 --- a/review-enrichment/README.md +++ b/review-enrichment/README.md @@ -48,6 +48,7 @@ inside the operator's trust boundary. The engine prefers a short-lived installat | `commitSignature` | Head commit signature/author provenance worth checking. | Calls GitHub API; needs headSha and token for private repos. | | `iacMisconfig` | Risky IaC/config changes like public buckets, open ingress, or insecure CORS. | Pure local. | | `nativeBuild` | Newly-added dependencies that compile native code or ship sdist-only builds. | Calls npm/PyPI registries. | +| `packageHealth` | Maintenance-health signals for newly-added or upgraded npm/PyPI packages. | Calls npm, PyPI, and ecosyste.ms package APIs. | | `history` | Author track record, same-file PR history, and linked-issue alignment. | Calls GitHub API with bounded fanout; needs author/token for private repos. | | `magicNumber` | Non-trivial numeric literals newly added in non-test source. | Pure local. | @@ -77,6 +78,25 @@ classes, per-analyzer limits, and self-host configuration. When adding or migrat - Make external-call analyzers fail open and respect the orchestrator abort signal when the scanner supports it. - Prefer a focused analyzer test file instead of expanding the shared `enrichment.test.ts` mega-test. +### Package-health analyzer + +`packageHealth` checks direct npm and PyPI dependency additions/upgrades for factual package-maintenance signals: +deprecated npm versions, yanked PyPI releases, packages whose latest release is older than the analyzer threshold, +archived upstream repositories, and packages with a single listed maintainer. + +The analyzer reuses the shared dependency-diff parser, so it only inspects dependencies newly present after the PR. +Unsupported ecosystems, invalid package names, failed registry calls, oversized responses, and missing metadata stay +silent. Findings report only package metadata: ecosystem, package, version, direction, signal kind, and a short +public-safe detail. They never include manifest lines, registry response bodies, install scripts, or repository +content. + +The scanner is intentionally additive with sibling supply-chain analyzers: + +- `dependency` reports known vulnerabilities. +- `license` reports compatibility-sensitive licenses. +- `nativeBuild` reports install-time build cost. +- `packageHealth` reports maintainability and stewardship signals. + ### Magic-number analyzer `magicNumber` is a precision-first local analyzer for unexplained numeric literals added by a PR. It is intended to diff --git a/review-enrichment/analyzer-metadata.json b/review-enrichment/analyzer-metadata.json index df8b580a6c..5a0b1d477f 100644 --- a/review-enrichment/analyzer-metadata.json +++ b/review-enrichment/analyzer-metadata.json @@ -524,6 +524,34 @@ "notes": "Registry JSON is capped so large package metadata cannot monopolize REES memory." } }, + { + "name": "packageHealth", + "title": "Package maintenance health", + "category": "supply-chain", + "cost": "registry", + "defaultEnabled": true, + "profiles": [ + "fast", + "balanced", + "deep" + ], + "requires": [ + "files", + "public-network" + ], + "limits": { + "maxQueries": 25, + "maxFindings": 25, + "staleDays": 730 + }, + "docs": { + "summary": "Flags newly-added or upgraded npm/PyPI dependencies with maintenance-health risk signals.", + "looksAt": "Direct dependency changes in package.json and requirements.txt, then package registry metadata.", + "reports": "Package, version, ecosystem, direction, and public-safe maintenance signal details.", + "network": "Calls npm, PyPI, and ecosyste.ms package APIs. No GitHub token required.", + "notes": "Fail-safe and bounded: unsupported ecosystems, invalid names, failed calls, and oversized responses stay silent." + } + }, { "name": "history", "title": "Author and change-area history", diff --git a/review-enrichment/src/analyzers/package-health.ts b/review-enrichment/src/analyzers/package-health.ts new file mode 100644 index 0000000000..3eed93496a --- /dev/null +++ b/review-enrichment/src/analyzers/package-health.ts @@ -0,0 +1,368 @@ +// Package maintenance-health analyzer (#1511). For each newly-added/upgraded direct npm/PyPI dependency, flag +// factual maintenance-risk signals that are hard for the no-checkout reviewer to verify: deprecated/yanked +// releases, packages with no recent releases, archived upstream projects, or a single maintainer. +import type { + AnalyzerDiagnostics, + EnrichRequest, + PackageHealthFinding, +} from "../types.js"; +import type { AnalysisContext } from "../analysis-context.js"; +import { boundedFetchJson } from "../external-fetch.js"; +import { extractDependencyChanges, type DepChange } from "./dependency-scan.js"; + +const MAX_QUERIES = 25; +const MAX_FINDINGS = 25; +const STALE_DAYS = 730; +const DAY_MS = 86_400_000; +const MAX_NPM_PACKUMENT_BYTES = 2 * 1024 * 1024; +const MAX_PYPI_PROJECT_BYTES = 2 * 1024 * 1024; +const MAX_ECOSYSTEMS_BYTES = 512 * 1024; + +const NPM_PACKAGE_RE = + /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$/; +const PYPI_PACKAGE_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/; +const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+!-]{0,127}$/; + +interface ScanLimits { + maxQueries?: number; + maxFindings?: number; + staleDays?: number; +} + +interface ScanOptions { + signal?: AbortSignal; + limits?: ScanLimits; + analysis?: Pick; + diagnostics?: AnalyzerDiagnostics; + now?: number; +} + +export interface NpmPackument { + versions?: Record; + time?: Record; + maintainers?: unknown[]; + users?: Record; +} + +export interface PypiProjectJson { + info?: { + version?: string; + yanked?: boolean; + yanked_reason?: string | null; + }; + releases?: Record< + string, + Array<{ + upload_time?: string; + upload_time_iso?: string; + yanked?: boolean; + yanked_reason?: string | null; + }> + >; +} + +export interface EcosystemsPackage { + archived?: boolean; + status?: string; + maintainers_count?: number; + repository?: { + archived?: boolean; + status?: string; + }; +} + +interface PackageHealthSignal { + kind: PackageHealthFinding["kind"]; + details: string; + lastReleaseAt?: string; + maintainerCount?: number; +} + +function isQueryable(change: DepChange): boolean { + if (change.ecosystem === "npm") + return NPM_PACKAGE_RE.test(change.package) && VERSION_RE.test(change.to); + if (change.ecosystem === "PyPI") + return PYPI_PACKAGE_RE.test(change.package) && VERSION_RE.test(change.to); + return false; +} + +function ecosystemRegistry(ecosystem: string): string | null { + if (ecosystem === "npm") return "npmjs.org"; + if (ecosystem === "PyPI") return "pypi.org"; + return null; +} + +function parseDateMs(value: string | undefined): number | null { + if (!value) return null; + const ms = new Date(value).getTime(); + return Number.isFinite(ms) ? ms : null; +} + +function latestDate(values: Iterable): string | null { + let best: { value: string; ms: number } | null = null; + for (const value of values) { + const ms = parseDateMs(value); + if (value && ms != null && (!best || ms > best.ms)) best = { value, ms }; + } + return best?.value ?? null; +} + +function daysBetween(now: number, then: string): number | null { + const ms = parseDateMs(then); + if (ms == null) return null; + return Math.max(0, Math.floor((now - ms) / DAY_MS)); +} + +function staleSignal( + lastReleaseAt: string | null, + now: number, + staleDays: number, +): PackageHealthSignal | null { + if (!lastReleaseAt) return null; + const ageDays = daysBetween(now, lastReleaseAt); + if (ageDays == null || ageDays < staleDays) return null; + return { + kind: "stale-release", + lastReleaseAt, + details: `latest release is ${ageDays} days old`, + }; +} + +function deprecatedSignal(meta: { deprecated?: string | boolean } | undefined): PackageHealthSignal | null { + if (!meta?.deprecated) return null; + const text = + typeof meta.deprecated === "string" && meta.deprecated.trim() + ? meta.deprecated.replace(/\s+/g, " ").slice(0, 160) + : "version is deprecated"; + return { kind: "deprecated", details: text }; +} + +function yankedSignal(project: PypiProjectJson, version: string): PackageHealthSignal | null { + const releaseFiles = project.releases?.[version] ?? []; + const yankedFile = releaseFiles.find((file) => file.yanked); + const infoYanked = project.info?.version === version && project.info.yanked === true; + if (!yankedFile && !infoYanked) return null; + const reason = + yankedFile?.yanked_reason ?? project.info?.yanked_reason ?? "release is yanked"; + return { + kind: "yanked", + details: reason ? String(reason).replace(/\s+/g, " ").slice(0, 160) : "release is yanked", + }; +} + +function maintainerSignal(count: number | undefined): PackageHealthSignal | null { + if (count !== 1) return null; + return { + kind: "sole-maintainer", + maintainerCount: count, + details: "package metadata lists a single maintainer", + }; +} + +function archivedSignal(meta: EcosystemsPackage | null): PackageHealthSignal | null { + if (!meta) return null; + const archived = + meta.archived === true || + meta.repository?.archived === true || + meta.status === "archived" || + meta.repository?.status === "archived"; + return archived + ? { kind: "archived", details: "ecosyste.ms reports the package repository as archived" } + : null; +} + +export function classifyNpmPackageHealth( + packageName: string, + version: string, + packument: NpmPackument | null, + ecosystems: EcosystemsPackage | null, + now: number, + staleDays = STALE_DAYS, +): PackageHealthSignal[] { + const signals: PackageHealthSignal[] = []; + const versionMeta = packument?.versions?.[version]; + const deprecated = deprecatedSignal(versionMeta); + if (deprecated) signals.push(deprecated); + + const latestReleaseAt = latestDate( + Object.entries(packument?.time ?? {}) + .filter(([key]) => key !== "created" && key !== "modified") + .map(([, value]) => value), + ); + const stale = staleSignal(latestReleaseAt, now, staleDays); + if (stale) signals.push(stale); + + const maintainerCount = + typeof ecosystems?.maintainers_count === "number" + ? ecosystems.maintainers_count + : Array.isArray(packument?.maintainers) + ? packument.maintainers.length + : packument?.users + ? Object.keys(packument.users).length + : undefined; + const soleMaintainer = maintainerSignal(maintainerCount); + if (soleMaintainer) signals.push(soleMaintainer); + + const archived = archivedSignal(ecosystems); + if (archived) signals.push(archived); + + return signals.map((signal) => ({ + ...signal, + details: signal.details || `${packageName}@${version} has a maintenance-health signal`, + })); +} + +export function classifyPypiPackageHealth( + packageName: string, + version: string, + project: PypiProjectJson | null, + ecosystems: EcosystemsPackage | null, + now: number, + staleDays = STALE_DAYS, +): PackageHealthSignal[] { + const signals: PackageHealthSignal[] = []; + if (project) { + const yanked = yankedSignal(project, version); + if (yanked) signals.push(yanked); + + const latestReleaseAt = latestDate( + Object.values(project.releases ?? {}).flatMap((files) => + files.map((file) => file.upload_time_iso ?? file.upload_time), + ), + ); + const stale = staleSignal(latestReleaseAt, now, staleDays); + if (stale) signals.push(stale); + } + + const soleMaintainer = maintainerSignal(ecosystems?.maintainers_count); + if (soleMaintainer) signals.push(soleMaintainer); + + const archived = archivedSignal(ecosystems); + if (archived) signals.push(archived); + + return signals.map((signal) => ({ + ...signal, + details: signal.details || `${packageName}@${version} has a maintenance-health signal`, + })); +} + +async function fetchJson( + url: string, + fetchImpl: typeof fetch, + options: ScanOptions, + endpointCategory: "npm-packument" | "pypi-project" | "ecosystems-package", + maxBytes: number, +): Promise { + if (options.signal?.aborted) return null; + const fetchOptions = { + endpointCategory, + signal: options.signal, + fetchImpl, + diagnostics: options.diagnostics, + phase: "package-health", + subcall: endpointCategory, + maxBytes, + maxCallsPerCategory: options.limits?.maxQueries ?? MAX_QUERIES, + }; + const response = options.analysis + ? await options.analysis.fetchJson(url, fetchOptions) + : await boundedFetchJson(url, fetchOptions); + return response.ok ? response.data : null; +} + +function npmPackumentUrl(packageName: string): string { + return `https://registry.npmjs.org/${encodeURIComponent(packageName)}`; +} + +function pypiProjectUrl(packageName: string): string { + return `https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`; +} + +function ecosystemsUrl(change: DepChange): string | null { + const registry = ecosystemRegistry(change.ecosystem); + if (!registry) return null; + return `https://packages.ecosyste.ms/api/v1/registries/${registry}/packages/${encodeURIComponent(change.package)}`; +} + +async function scanChange( + change: DepChange, + fetchImpl: typeof fetch, + options: ScanOptions, +): Promise { + const ecoUrl = ecosystemsUrl(change); + const ecosystems = ecoUrl + ? await fetchJson( + ecoUrl, + fetchImpl, + options, + "ecosystems-package", + MAX_ECOSYSTEMS_BYTES, + ) + : null; + + const now = options.now ?? Date.now(); + const staleDays = options.limits?.staleDays ?? STALE_DAYS; + const signals = + change.ecosystem === "npm" + ? classifyNpmPackageHealth( + change.package, + change.to, + await fetchJson( + npmPackumentUrl(change.package), + fetchImpl, + options, + "npm-packument", + MAX_NPM_PACKUMENT_BYTES, + ), + ecosystems, + now, + staleDays, + ) + : classifyPypiPackageHealth( + change.package, + change.to, + await fetchJson( + pypiProjectUrl(change.package), + fetchImpl, + options, + "pypi-project", + MAX_PYPI_PROJECT_BYTES, + ), + ecosystems, + now, + staleDays, + ); + + return signals.map((signal) => ({ + ecosystem: change.ecosystem as "npm" | "PyPI", + package: change.package, + version: change.to, + from: change.from, + direction: change.from ? "change" : "add", + kind: signal.kind, + details: signal.details, + lastReleaseAt: signal.lastReleaseAt, + maintainerCount: signal.maintainerCount, + })); +} + +/** Analyzer entrypoint: changed direct deps -> registry metadata -> public-safe maintenance-health findings. */ +export async function scanPackageHealth( + req: EnrichRequest, + fetchImpl: typeof fetch = fetch, + options: ScanOptions = {}, +): Promise { + const changes = extractDependencyChanges(req.files ?? []) + .filter(isQueryable) + .slice(0, options.limits?.maxQueries ?? MAX_QUERIES); + const findings: PackageHealthFinding[] = []; + const maxFindings = options.limits?.maxFindings ?? MAX_FINDINGS; + for (const change of changes) { + if (options.signal?.aborted || findings.length >= maxFindings) break; + for (const finding of await scanChange(change, fetchImpl, options)) { + findings.push(finding); + if (findings.length >= maxFindings) break; + } + } + return findings; +} diff --git a/review-enrichment/src/analyzers/registry.ts b/review-enrichment/src/analyzers/registry.ts index 76da147c3f..951175a23e 100644 --- a/review-enrichment/src/analyzers/registry.ts +++ b/review-enrichment/src/analyzers/registry.ts @@ -18,6 +18,7 @@ import { scanInstallScripts } from "./install-scripts.js"; import { scanLicenses } from "./license-check.js"; import { scanLockfileDrift } from "./lockfile-drift.js"; import { scanNativeBuild } from "./native-build.js"; +import { scanPackageHealth } from "./package-health.js"; import { scanPendingReviewRequests } from "./pending-review-requests.js"; import { scanProvenance } from "./provenance.js"; import { scanRedos } from "./redos.js"; @@ -353,6 +354,54 @@ export const ANALYZER_DESCRIPTORS = [ run: (req, { signal, analysis, diagnostics }) => scanNativeBuild(req, fetch, { signal, analysis, diagnostics }), }), + descriptor({ + name: "packageHealth", + title: "Package maintenance health", + category: "supply-chain", + cost: "registry", + defaultEnabled: true, + requires: ["files", "public-network"], + limits: { maxQueries: 25, maxFindings: 25, staleDays: 730 }, + docs: { + summary: + "Flags newly-added or upgraded npm/PyPI dependencies with maintenance-health risk signals.", + looksAt: + "Direct dependency changes in package.json and requirements.txt, then package registry metadata.", + reports: + "Package, version, ecosystem, direction, and public-safe maintenance signal details.", + network: + "Calls npm, PyPI, and ecosyste.ms package APIs. No GitHub token required.", + notes: + "Fail-safe and bounded: unsupported ecosystems, invalid names, failed calls, and oversized responses stay silent.", + }, + render: (findings, helpers) => { + if (!findings.length) return []; + const explain = (kind: (typeof findings)[number]["kind"]): string => { + switch (kind) { + case "deprecated": + return "deprecated release"; + case "yanked": + return "yanked release"; + case "stale-release": + return "no recent release activity"; + case "archived": + return "archived upstream project"; + case "sole-maintainer": + return "single-maintainer package"; + } + }; + const lines = ["### Package maintenance-health signals"]; + for (const item of findings) { + const from = item.direction === "change" && item.from ? ` from ${item.from}` : ""; + lines.push( + `- ${helpers.safeCodeSpan(`${item.package}@${item.version}`)} (${item.ecosystem}${from}): ${explain(item.kind)} — ${helpers.promptText(item.details)}`, + ); + } + return lines; + }, + run: (req, { signal, analysis, diagnostics }) => + scanPackageHealth(req, fetch, { signal, analysis, diagnostics }), + }), descriptor({ name: "history", title: "Author and change-area history", diff --git a/review-enrichment/src/render.ts b/review-enrichment/src/render.ts index 5d418ae81c..8ec985ce58 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -382,6 +382,8 @@ export function renderBrief( } } + lines.push(...renderDescriptorSection("packageHealth", findings.packageHealth)); + 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 7bfef1b21f..a0530c16b9 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -278,6 +278,20 @@ export interface NativeBuildFinding { reason: string; } +/** A newly-added/upgraded npm/PyPI dependency with maintenance-health signals: deprecated/yanked release, + * stale release activity, archived upstream project, or a single maintainer. Reports package metadata only. */ +export interface PackageHealthFinding { + ecosystem: "npm" | "PyPI"; + package: string; + version: string; + from: string | null; + direction: "add" | "change"; + kind: "deprecated" | "yanked" | "stale-release" | "archived" | "sole-maintainer"; + details: string; + lastReleaseAt?: string; + maintainerCount?: number; +} + /** 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). @@ -492,6 +506,7 @@ export interface BriefFindings { commitSignature?: CommitSignatureFinding[]; iacMisconfig?: IacMisconfigFinding[]; nativeBuild?: NativeBuildFinding[]; + packageHealth?: PackageHealthFinding[]; history?: HistoryFinding[]; docCommentDrift?: DocCommentDriftFinding[]; duplication?: DuplicationFinding[]; diff --git a/review-enrichment/test/analyzer-registry.test.ts b/review-enrichment/test/analyzer-registry.test.ts index edd5354d4d..786e56961c 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", + "packageHealth", "history", "docCommentDrift", "duplication", diff --git a/review-enrichment/test/package-health.test.ts b/review-enrichment/test/package-health.test.ts new file mode 100644 index 0000000000..1c69e7b24b --- /dev/null +++ b/review-enrichment/test/package-health.test.ts @@ -0,0 +1,706 @@ +// Units for the package maintenance-health analyzer (#1511). Own file to avoid shared analyzer-test collisions. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + classifyNpmPackageHealth, + classifyPypiPackageHealth, + scanPackageHealth, +} from "../dist/analyzers/package-health.js"; +import { buildBrief } from "../dist/brief.js"; +import { renderBrief } from "../dist/render.js"; + +const NOW = Date.parse("2026-07-05T00:00:00.000Z"); + +const jsonResponse = (body, init) => new Response(JSON.stringify(body), init); + +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 npmChange = (name, from = "0.9.0", to = "1.0.0") => ({ + repoFullName: "o/r", + prNumber: 1, + files: [ + { + path: "package.json", + patch: `@@ -1,1 +1,1 @@\n- "${name}": "^${from}"\n+ "${name}": "^${to}"`, + }, + ], +}); + +const pypiAdd = (name, version = "1.0.0") => ({ + repoFullName: "o/r", + prNumber: 1, + files: [ + { + path: "requirements.txt", + patch: `@@ -1,0 +1,1 @@\n+${name}==${version}`, + }, + ], +}); + +const pypiChange = (name, from = "0.9.0", to = "1.0.0") => ({ + repoFullName: "o/r", + prNumber: 1, + files: [ + { + path: "requirements.txt", + patch: `@@ -1,1 +1,1 @@\n-${name}==${from}\n+${name}==${to}`, + }, + ], +}); + +const npmPackument = (overrides = {}) => ({ + versions: { "1.0.0": {} }, + time: { + created: "2020-01-01T00:00:00.000Z", + modified: "2026-07-01T00:00:00.000Z", + "1.0.0": "2026-07-01T00:00:00.000Z", + }, + maintainers: [{ name: "a" }, { name: "b" }], + ...overrides, +}); + +const pypiProject = (overrides = {}) => ({ + info: { version: "1.0.0" }, + releases: { + "1.0.0": [{ upload_time_iso: "2026-07-01T00:00:00.000Z" }], + }, + ...overrides, +}); + +function sequenceFetch(...bodies) { + const urls = []; + const fetchImpl = async (url) => { + urls.push(String(url)); + const body = bodies.shift(); + if (body instanceof Response) return body; + return jsonResponse(body ?? {}); + }; + fetchImpl.urls = urls; + return fetchImpl; +} + +test("classifyNpmPackageHealth: reports deprecated version metadata", () => { + const signals = classifyNpmPackageHealth( + "old-lib", + "1.0.0", + npmPackument({ versions: { "1.0.0": { deprecated: "Use new-lib instead.\nPlease migrate." } } }), + null, + NOW, + ); + + assert.deepEqual(signals.map((signal) => signal.kind), ["deprecated"]); + assert.equal(signals[0].details, "Use new-lib instead. Please migrate."); +}); + +test("classifyNpmPackageHealth: boolean deprecation still reports a useful detail", () => { + const signals = classifyNpmPackageHealth( + "old-lib", + "1.0.0", + npmPackument({ versions: { "1.0.0": { deprecated: true } } }), + null, + NOW, + ); + + assert.deepEqual(signals, [{ kind: "deprecated", details: "version is deprecated" }]); +}); + +test("classifyNpmPackageHealth: ignores deprecation for a different version", () => { + const signals = classifyNpmPackageHealth( + "old-lib", + "1.0.0", + npmPackument({ versions: { "2.0.0": { deprecated: "old" } } }), + null, + NOW, + ); + + assert.deepEqual(signals, []); +}); + +test("classifyNpmPackageHealth: reports stale latest release using package time map", () => { + const signals = classifyNpmPackageHealth( + "quiet-lib", + "1.0.0", + npmPackument({ + time: { + created: "2018-01-01T00:00:00.000Z", + modified: "2026-07-01T00:00:00.000Z", + "1.0.0": "2021-01-01T00:00:00.000Z", + }, + }), + null, + NOW, + ); + + assert.equal(signals.length, 1); + assert.equal(signals[0].kind, "stale-release"); + assert.equal(signals[0].lastReleaseAt, "2021-01-01T00:00:00.000Z"); + assert.match(signals[0].details, /days old/); +}); + +test("classifyNpmPackageHealth: ignores created and modified timestamps for stale release", () => { + const signals = classifyNpmPackageHealth( + "quiet-lib", + "1.0.0", + npmPackument({ + time: { + created: "2018-01-01T00:00:00.000Z", + modified: "2026-07-01T00:00:00.000Z", + }, + }), + null, + NOW, + ); + + assert.deepEqual(signals, []); +}); + +test("classifyNpmPackageHealth: respects the stale-days threshold", () => { + assert.deepEqual( + classifyNpmPackageHealth( + "active-lib", + "1.0.0", + npmPackument({ time: { "1.0.0": "2025-12-01T00:00:00.000Z" } }), + null, + NOW, + 730, + ), + [], + ); + assert.equal( + classifyNpmPackageHealth( + "active-lib", + "1.0.0", + npmPackument({ time: { "1.0.0": "2025-12-01T00:00:00.000Z" } }), + null, + NOW, + 30, + )[0].kind, + "stale-release", + ); +}); + +test("classifyNpmPackageHealth: reports sole maintainer from npm maintainers", () => { + const signals = classifyNpmPackageHealth( + "single-lib", + "1.0.0", + npmPackument({ maintainers: [{ name: "solo" }] }), + null, + NOW, + ); + + assert.deepEqual(signals, [ + { + kind: "sole-maintainer", + maintainerCount: 1, + details: "package metadata lists a single maintainer", + }, + ]); +}); + +test("classifyNpmPackageHealth: falls back to npm users count when maintainers are absent", () => { + const signals = classifyNpmPackageHealth( + "single-lib", + "1.0.0", + npmPackument({ maintainers: undefined, users: { alice: true } }), + null, + NOW, + ); + + assert.equal(signals[0].kind, "sole-maintainer"); + assert.equal(signals[0].maintainerCount, 1); +}); + +test("classifyNpmPackageHealth: ecosyste.ms maintainer count overrides npm packument count", () => { + const signals = classifyNpmPackageHealth( + "single-lib", + "1.0.0", + npmPackument({ maintainers: [{ name: "a" }, { name: "b" }] }), + { maintainers_count: 1 }, + NOW, + ); + + assert.equal(signals[0].kind, "sole-maintainer"); +}); + +test("classifyNpmPackageHealth: reports archived packages from package or repository metadata", () => { + assert.equal( + classifyNpmPackageHealth("archived-lib", "1.0.0", npmPackument(), { archived: true }, NOW)[0].kind, + "archived", + ); + assert.equal( + classifyNpmPackageHealth("archived-lib", "1.0.0", npmPackument(), { repository: { archived: true } }, NOW)[0] + .kind, + "archived", + ); + assert.equal( + classifyNpmPackageHealth("archived-lib", "1.0.0", npmPackument(), { status: "archived" }, NOW)[0].kind, + "archived", + ); + assert.equal( + classifyNpmPackageHealth( + "archived-lib", + "1.0.0", + npmPackument(), + { repository: { status: "archived" } }, + NOW, + )[0].kind, + "archived", + ); +}); + +test("classifyNpmPackageHealth: can return several independent signals in stable order", () => { + const signals = classifyNpmPackageHealth( + "risky-lib", + "1.0.0", + npmPackument({ + versions: { "1.0.0": { deprecated: "deprecated" } }, + time: { "1.0.0": "2020-01-01T00:00:00.000Z" }, + maintainers: [{ name: "solo" }], + }), + { archived: true }, + NOW, + ); + + assert.deepEqual(signals.map((signal) => signal.kind), [ + "deprecated", + "stale-release", + "sole-maintainer", + "archived", + ]); +}); + +test("classifyPypiPackageHealth: reports yanked releases from release-file metadata", () => { + const signals = classifyPypiPackageHealth( + "badpkg", + "1.0.0", + pypiProject({ + releases: { + "1.0.0": [{ upload_time_iso: "2026-01-01T00:00:00.000Z", yanked: true, yanked_reason: "bad wheel" }], + }, + }), + null, + NOW, + ); + + assert.deepEqual(signals, [{ kind: "yanked", details: "bad wheel" }]); +}); + +test("classifyPypiPackageHealth: reports yanked releases from info metadata", () => { + const signals = classifyPypiPackageHealth( + "badpkg", + "1.0.0", + pypiProject({ info: { version: "1.0.0", yanked: true, yanked_reason: "removed" } }), + null, + NOW, + ); + + assert.deepEqual(signals, [{ kind: "yanked", details: "removed" }]); +}); + +test("classifyPypiPackageHealth: ignores info.yanked for a different version", () => { + const signals = classifyPypiPackageHealth( + "badpkg", + "1.0.0", + pypiProject({ info: { version: "2.0.0", yanked: true, yanked_reason: "removed" } }), + null, + NOW, + ); + + assert.deepEqual(signals, []); +}); + +test("classifyPypiPackageHealth: reports stale latest release from release uploads", () => { + const signals = classifyPypiPackageHealth( + "quietpkg", + "1.0.0", + pypiProject({ + releases: { + "0.9.0": [{ upload_time: "2019-01-01T00:00:00" }], + "1.0.0": [{ upload_time_iso: "2020-01-01T00:00:00.000Z" }], + }, + }), + null, + NOW, + ); + + assert.equal(signals[0].kind, "stale-release"); + assert.equal(signals[0].lastReleaseAt, "2020-01-01T00:00:00.000Z"); +}); + +test("classifyPypiPackageHealth: ignores malformed release dates", () => { + const signals = classifyPypiPackageHealth( + "quietpkg", + "1.0.0", + pypiProject({ releases: { "1.0.0": [{ upload_time_iso: "not-a-date" }] } }), + null, + NOW, + ); + + assert.deepEqual(signals, []); +}); + +test("classifyPypiPackageHealth: reports ecosyste.ms archive and sole-maintainer signals", () => { + const signals = classifyPypiPackageHealth( + "quietpkg", + "1.0.0", + pypiProject(), + { repository: { archived: true }, maintainers_count: 1 }, + NOW, + ); + + assert.deepEqual(signals.map((signal) => signal.kind), ["sole-maintainer", "archived"]); +}); + +test("scanPackageHealth: npm deprecated dependency is fetched and reported", async () => { + const fetchImpl = sequenceFetch( + {}, + npmPackument({ versions: { "1.0.0": { deprecated: "Use maintained-lib" } } }), + ); + const findings = await scanPackageHealth(npmAdd("old-lib"), fetchImpl, { now: NOW }); + + assert.deepEqual(fetchImpl.urls, [ + "https://packages.ecosyste.ms/api/v1/registries/npmjs.org/packages/old-lib", + "https://registry.npmjs.org/old-lib", + ]); + assert.equal(findings.length, 1); + assert.equal(findings[0].ecosystem, "npm"); + assert.equal(findings[0].package, "old-lib"); + assert.equal(findings[0].version, "1.0.0"); + assert.equal(findings[0].direction, "add"); + assert.equal(findings[0].kind, "deprecated"); +}); + +test("scanPackageHealth: npm upgraded dependency carries from/to direction", async () => { + const findings = await scanPackageHealth( + npmChange("old-lib", "0.9.0", "1.0.0"), + sequenceFetch({}, npmPackument({ versions: { "1.0.0": { deprecated: "old" } } })), + { now: NOW }, + ); + + assert.equal(findings[0].from, "0.9.0"); + assert.equal(findings[0].direction, "change"); +}); + +test("scanPackageHealth: scoped npm names are URL-encoded once", async () => { + const fetchImpl = sequenceFetch({}, npmPackument({ versions: { "1.0.0": { deprecated: "old" } } })); + await scanPackageHealth(npmAdd("@scope/pkg"), fetchImpl, { now: NOW }); + + assert.deepEqual(fetchImpl.urls, [ + "https://packages.ecosyste.ms/api/v1/registries/npmjs.org/packages/%40scope%2Fpkg", + "https://registry.npmjs.org/%40scope%2Fpkg", + ]); +}); + +test("scanPackageHealth: PyPI yanked dependency is fetched and reported", async () => { + const fetchImpl = sequenceFetch( + {}, + pypiProject({ + releases: { + "1.0.0": [{ upload_time_iso: "2026-01-01T00:00:00.000Z", yanked: true }], + }, + }), + ); + const findings = await scanPackageHealth(pypiAdd("badpkg"), fetchImpl, { now: NOW }); + + assert.deepEqual(fetchImpl.urls, [ + "https://packages.ecosyste.ms/api/v1/registries/pypi.org/packages/badpkg", + "https://pypi.org/pypi/badpkg/json", + ]); + assert.equal(findings[0].ecosystem, "PyPI"); + assert.equal(findings[0].kind, "yanked"); +}); + +test("scanPackageHealth: PyPI upgraded dependency carries from/to direction", async () => { + const findings = await scanPackageHealth( + pypiChange("badpkg", "0.9.0", "1.0.0"), + sequenceFetch({}, pypiProject({ info: { version: "1.0.0", yanked: true } })), + { now: NOW }, + ); + + assert.equal(findings[0].from, "0.9.0"); + assert.equal(findings[0].direction, "change"); +}); + +test("scanPackageHealth: ecosyste.ms failures do not hide registry findings", async () => { + const findings = await scanPackageHealth( + npmAdd("old-lib"), + sequenceFetch(jsonResponse({}, { status: 503 }), npmPackument({ versions: { "1.0.0": { deprecated: "old" } } })), + { now: NOW }, + ); + + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, "deprecated"); +}); + +test("scanPackageHealth: registry failures still allow ecosyste.ms findings", async () => { + const findings = await scanPackageHealth( + npmAdd("archived-lib"), + sequenceFetch({ archived: true }, jsonResponse({}, { status: 404 })), + { now: NOW }, + ); + + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, "archived"); +}); + +test("scanPackageHealth: unsupported ecosystems and invalid 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" }, + { path: "package.json", patch: '@@ -1,0 +1,1 @@\n+ "BadCaps": "^1.0.0"' }, + { path: "requirements.txt", patch: "@@ -1,0 +1,1 @@\n+bad/pkg==1.0.0" }, + ], + }; + let called = false; + const findings = await scanPackageHealth(req, async () => { + called = true; + return jsonResponse({}); + }); + + assert.deepEqual(findings, []); + assert.equal(called, false); +}); + +test("scanPackageHealth: query cap counts only queryable dependencies", async () => { + const goLines = Array.from({ length: 20 }, (_, index) => `+require example.com/m${index} v1.0.0`).join("\n"); + const req = { + repoFullName: "o/r", + prNumber: 1, + files: [ + { path: "go.mod", patch: `@@ -1,0 +1,20 @@\n${goLines}` }, + { path: "package.json", patch: '@@ -1,0 +1,1 @@\n+ "old-lib": "^1.0.0"' }, + ], + }; + const findings = await scanPackageHealth( + req, + sequenceFetch({}, npmPackument({ versions: { "1.0.0": { deprecated: "old" } } })), + { now: NOW, limits: { maxQueries: 1 } }, + ); + + assert.equal(findings.length, 1); + assert.equal(findings[0].package, "old-lib"); +}); + +test("scanPackageHealth: finding cap truncates multi-signal output", async () => { + const findings = await scanPackageHealth( + npmAdd("risky-lib"), + sequenceFetch( + { archived: true, maintainers_count: 1 }, + npmPackument({ + versions: { "1.0.0": { deprecated: "old" } }, + time: { "1.0.0": "2020-01-01T00:00:00.000Z" }, + }), + ), + { now: NOW, limits: { maxFindings: 2 } }, + ); + + assert.equal(findings.length, 2); + assert.deepEqual(findings.map((finding) => finding.kind), ["deprecated", "stale-release"]); +}); + +test("scanPackageHealth: maxQueries limits network fanout", async () => { + const req = { + repoFullName: "o/r", + prNumber: 1, + files: [ + { path: "package.json", patch: '@@ -1,0 +1,2 @@\n+ "first-lib": "^1.0.0"\n+ "second-lib": "^1.0.0"' }, + ], + }; + const fetchImpl = sequenceFetch({}, npmPackument({ versions: { "1.0.0": { deprecated: "old" } } })); + await scanPackageHealth(req, fetchImpl, { now: NOW, limits: { maxQueries: 1 } }); + + assert.equal(fetchImpl.urls.length, 2); + assert.match(fetchImpl.urls[0], /first-lib/); +}); + +test("scanPackageHealth: already-aborted signal stops before network work", async () => { + let called = false; + const findings = await scanPackageHealth( + npmAdd("old-lib"), + async () => { + called = true; + return jsonResponse({}); + }, + { signal: AbortSignal.abort(), now: NOW }, + ); + + assert.deepEqual(findings, []); + assert.equal(called, false); +}); + +test("scanPackageHealth: aborting after the first dependency stops later dependencies", async () => { + const controller = new AbortController(); + const req = { + repoFullName: "o/r", + prNumber: 1, + files: [ + { path: "package.json", patch: '@@ -1,0 +1,2 @@\n+ "first-lib": "^1.0.0"\n+ "second-lib": "^1.0.0"' }, + ], + }; + const fetchImpl = async (url) => { + if (String(url).includes("first-lib")) controller.abort(); + return jsonResponse({}); + }; + + await scanPackageHealth(req, fetchImpl, { signal: controller.signal, now: NOW }); + assert.equal(controller.signal.aborted, true); +}); + +test("scanPackageHealth: non-ok and throwing fetches fail safe", async () => { + assert.deepEqual( + await scanPackageHealth(npmAdd("old-lib"), async () => jsonResponse({}, { status: 500 }), { now: NOW }), + [], + ); + assert.deepEqual( + await scanPackageHealth( + npmAdd("old-lib"), + async () => { + throw new Error("network down"); + }, + { now: NOW }, + ), + [], + ); +}); + +test("scanPackageHealth: oversized Content-Length fails safe before reading a body", async () => { + let bodyRead = false; + const findings = await scanPackageHealth( + npmAdd("old-lib"), + async () => ({ + ok: true, + headers: new Headers({ "content-length": String(3 * 1024 * 1024) }), + body: { + getReader() { + bodyRead = true; + throw new Error("body should not be read"); + }, + }, + arrayBuffer: async () => { + bodyRead = true; + return new ArrayBuffer(0); + }, + }), + { now: NOW }, + ); + + assert.deepEqual(findings, []); + assert.equal(bodyRead, false); +}); + +test("scanPackageHealth: streamed JSON over the byte cap fails safe", async () => { + const big = `${" ".repeat(3 * 1024 * 1024)}{"versions":{"1.0.0":{"deprecated":"old"}}}`; + const findings = await scanPackageHealth(npmAdd("old-lib"), async () => new Response(big), { now: NOW }); + + assert.deepEqual(findings, []); +}); + +test("scanPackageHealth: analysis fetch context is used when provided", async () => { + const urls = []; + const analysis = { + fetchJson: async (url) => { + urls.push(url); + if (url.includes("ecosyste.ms")) return { ok: true, data: {} }; + return { + ok: true, + data: npmPackument({ versions: { "1.0.0": { deprecated: "old" } } }), + }; + }, + }; + const findings = await scanPackageHealth( + npmAdd("old-lib"), + async () => { + throw new Error("direct fetch should not be used"); + }, + { analysis, now: NOW }, + ); + + assert.equal(findings.length, 1); + assert.equal(urls.length, 2); +}); + +test("scanPackageHealth: explicit analyzer request participates in buildBrief", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + if (String(url).includes("ecosyste.ms")) return jsonResponse({}); + return jsonResponse(npmPackument({ versions: { "1.0.0": { deprecated: "old" } } })); + }; + try { + const brief = await buildBrief({ + repoFullName: "o/r", + prNumber: 1, + analyzers: ["packageHealth"], + files: npmAdd("old-lib").files, + }); + + assert.equal(brief.partial, false); + assert.equal(brief.analyzerStatus.packageHealth, "ok"); + assert.equal(brief.findings.packageHealth?.[0]?.kind, "deprecated"); + assert.match(brief.promptSection, /Package maintenance-health signals/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("renderBrief: package-health output is public-safe and compact", () => { + const { promptSection } = renderBrief({ + packageHealth: [ + { + ecosystem: "npm", + package: "old-lib", + version: "1.0.0", + from: null, + direction: "add", + kind: "deprecated", + details: "Use maintained-lib instead", + }, + { + ecosystem: "PyPI", + package: "badpkg", + version: "2.0.0", + from: "1.0.0", + direction: "change", + kind: "yanked", + details: "bad release", + }, + ], + }); + + assert.match(promptSection, /Package maintenance-health signals/); + assert.match(promptSection, /old-lib@1\.0\.0/); + assert.match(promptSection, /badpkg@2\.0\.0/); + assert.match(promptSection, /from 1\.0\.0/); + assert.doesNotMatch(promptSection, /package\.json/); + assert.doesNotMatch(promptSection, /requirements\.txt/); +}); + +test("renderBrief: renderer escapes package names and detail text", () => { + const { promptSection } = renderBrief({ + packageHealth: [ + { + ecosystem: "npm", + package: "evil`pkg", + version: "1.0.0", + from: null, + direction: "add", + kind: "deprecated", + details: "line one\nline two", + }, + ], + }); + + assert.match(promptSection, /evil.pkg@1\.0\.0/); + assert.doesNotMatch(promptSection, /evil`pkg/); + assert.match(promptSection, /line one line two/); +}); diff --git a/src/review/enrichment-analyzer-names.ts b/src/review/enrichment-analyzer-names.ts index f1f7d44de3..c19bcf8da2 100644 --- a/src/review/enrichment-analyzer-names.ts +++ b/src/review/enrichment-analyzer-names.ts @@ -21,6 +21,7 @@ export const REES_ANALYZER_NAMES = [ "commitSignature", "iacMisconfig", "nativeBuild", + "packageHealth", "history", "docCommentDrift", "duplication",