|
| 1 | +// Deprecated / unmaintained direct-dependency analyzer (#1511, part of #1499). A no-checkout headless reviewer sees |
| 2 | +// only the diff, so it cannot tell that a dependency a PR newly ADDS or UPGRADES is an officially deprecated or |
| 3 | +// abandoned package that a maintained successor has replaced — an adoption risk + future supply-chain liability the |
| 4 | +// review brief should surface. This fills that gap purely from the changed manifest patches: it reuses the shared |
| 5 | +// manifest dependency-change parser and matches each added/upgraded package name against a BUNDLED, curated list of |
| 6 | +// well-known deprecated packages per ecosystem — the same offline-list approach the typosquat analyzer uses for its |
| 7 | +// popular-package set. Deterministic, no network, no token: the curated list is the sole source of truth, so a |
| 8 | +// package it does not name is never flagged (conservative + fail-safe). Reports ecosystem, package, the added |
| 9 | +// version, the change direction, the documented reason, and the recommended replacement — never manifest contents. |
| 10 | +import type { DeprecatedDependencyFinding, EnrichRequest } from "../types.js"; |
| 11 | +import { extractDependencyChanges } from "./dependency-scan.js"; |
| 12 | + |
| 13 | +const MAX_MANIFEST_FILES = 20; // bound manifest files parsed per PR |
| 14 | +const MAX_PATCH_LINES_PER_FILE = 500; // bound patch lines parsed per manifest |
| 15 | +const MAX_FINDINGS = 25; // keep the brief bounded |
| 16 | + |
| 17 | +interface DeprecationNote { |
| 18 | + reason: string; |
| 19 | + replacement: string | null; |
| 20 | +} |
| 21 | + |
| 22 | +// Curated, conservative registry of packages with a WELL-KNOWN published deprecation (npm-deprecated, a PyPI |
| 23 | +// deprecation stub, or an officially retired project) and a maintained successor. Keyed ecosystem → normalized |
| 24 | +// package name → note. Not exhaustive by design: only unambiguous, widely-recognized cases so a match is a real |
| 25 | +// signal and never a guess. `replacement` is the community-recommended successor, or null when none is standard. |
| 26 | +const DEPRECATED: Record<string, Record<string, DeprecationNote>> = { |
| 27 | + npm: { |
| 28 | + request: { reason: "deprecated — no longer maintained since 2020", replacement: "got or axios" }, |
| 29 | + "request-promise": { reason: "deprecated with request", replacement: "got" }, |
| 30 | + "request-promise-native": { reason: "deprecated with request", replacement: "got" }, |
| 31 | + "node-sass": { reason: "deprecated — LibSass is deprecated", replacement: "sass (Dart Sass)" }, |
| 32 | + tslint: { reason: "deprecated in favor of ESLint (2019)", replacement: "eslint + typescript-eslint" }, |
| 33 | + "gulp-util": { reason: "deprecated — the bundled utility set was unpublished", replacement: null }, |
| 34 | + istanbul: { reason: "deprecated — the project was renamed", replacement: "nyc" }, |
| 35 | + "babel-preset-es2015": { reason: "deprecated — legacy Babel 6 preset", replacement: "@babel/preset-env" }, |
| 36 | + bower: { reason: "deprecated front-end package manager", replacement: "npm or yarn" }, |
| 37 | + "phantomjs-prebuilt": { reason: "deprecated — PhantomJS is suspended", replacement: "puppeteer or playwright" }, |
| 38 | + }, |
| 39 | + PyPI: { |
| 40 | + sklearn: { reason: "deprecated PyPI stub for scikit-learn", replacement: "scikit-learn" }, |
| 41 | + nose: { reason: "unmaintained — no Python 3.10+ support", replacement: "pytest or nose2" }, |
| 42 | + pycrypto: { reason: "unmaintained — known unpatched CVEs", replacement: "pycryptodome" }, |
| 43 | + beautifulsoup: { reason: "legacy BeautifulSoup 3, no longer maintained", replacement: "beautifulsoup4" }, |
| 44 | + distribute: { reason: "deprecated — merged back into setuptools", replacement: "setuptools" }, |
| 45 | + }, |
| 46 | +}; |
| 47 | + |
| 48 | +/** Registry lookup key for a package name. npm names are case-folded; PyPI applies PEP 503 normalization — |
| 49 | + * lowercased, with runs of `-`, `_`, and `.` collapsed to a single `-` — so `Foo_Bar` and `foo.bar` resolve |
| 50 | + * to the same project. Pure. */ |
| 51 | +export function normalizeName(ecosystem: string, name: string): string { |
| 52 | + const lower = name.toLowerCase(); |
| 53 | + return ecosystem === "PyPI" ? lower.replace(/[-_.]+/g, "-") : lower; |
| 54 | +} |
| 55 | + |
| 56 | +/** Flag each newly-added or upgraded direct dependency the curated list marks deprecated/unmaintained. Reuses the |
| 57 | + * shared manifest parser (which only yields deps present after the change), so removals are never flagged. |
| 58 | + * Deterministic, no network. Returns [] on an aborted signal or when no changed manifest names a listed package; |
| 59 | + * bounded by the manifest, patch-line, and finding caps. */ |
| 60 | +export async function scanDeprecatedDependencies( |
| 61 | + req: EnrichRequest, |
| 62 | + signal?: AbortSignal, |
| 63 | +): Promise<DeprecatedDependencyFinding[]> { |
| 64 | + if (signal?.aborted) return []; |
| 65 | + const findings: DeprecatedDependencyFinding[] = []; |
| 66 | + const changes = extractDependencyChanges(req.files ?? [], { |
| 67 | + maxManifestFiles: MAX_MANIFEST_FILES, |
| 68 | + maxPatchLinesPerFile: MAX_PATCH_LINES_PER_FILE, |
| 69 | + }); |
| 70 | + for (const change of changes) { |
| 71 | + if (signal?.aborted) break; |
| 72 | + const note = DEPRECATED[change.ecosystem]?.[normalizeName(change.ecosystem, change.package)]; |
| 73 | + if (!note) continue; |
| 74 | + findings.push({ |
| 75 | + ecosystem: change.ecosystem, |
| 76 | + package: change.package, |
| 77 | + version: change.to, |
| 78 | + direction: change.from ? "change" : "add", |
| 79 | + replacement: note.replacement, |
| 80 | + reason: note.reason, |
| 81 | + }); |
| 82 | + if (findings.length >= MAX_FINDINGS) break; |
| 83 | + } |
| 84 | + return findings; |
| 85 | +} |
0 commit comments