From 05718ff2f7d1a5d8fbee14fb835b8e92d0fc157b Mon Sep 17 00:00:00 2001 From: e11734937-beep Date: Sun, 5 Jul 2026 22:05:30 +0200 Subject: [PATCH] feat(enrichment): add deprecated / unmaintained dependency analyzer Add a local, deterministic deprecatedDep REES analyzer that flags a direct dependency a PR newly adds or upgrades when it is an officially deprecated or unmaintained package with a maintained successor. Reuses the shared manifest parser and matches added/upgraded names against a bundled curated list per ecosystem (npm + PyPI); no network, no token. Conservative and fail-safe: only exact list matches are reported, bounded by manifest/patch/finding caps. Closes #1511 --- .env.example | 6 +- apps/gittensory-ui/src/lib/rees-analyzers.ts | 25 ++++ review-enrichment/analyzer-metadata.json | 27 ++++ .../src/analyzers/deprecated-dep.ts | 85 +++++++++++++ review-enrichment/src/analyzers/registry.ts | 37 ++++++ review-enrichment/src/render.ts | 1 + review-enrichment/src/types.ts | 13 ++ .../test/analyzer-registry.test.ts | 1 + review-enrichment/test/deprecated-dep.test.ts | 117 ++++++++++++++++++ src/review/enrichment-analyzer-names.ts | 1 + 10 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 review-enrichment/src/analyzers/deprecated-dep.ts create mode 100644 review-enrichment/test/deprecated-dep.test.ts diff --git a/.env.example b/.env.example index f9520f7af0..327637fef9 100644 --- a/.env.example +++ b/.env.example @@ -69,13 +69,14 @@ GITTENSORY_REVIEW_ENRICHMENT=false # blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch,commitHygiene # pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber # conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting,errorSwallow,unsafeAny,a11y -# i18n,unusedExport,exhaustiveness,flakyTest,commitLint,apiBreak +# i18n,unusedExport,exhaustiveness,flakyTest,commitLint,apiBreak,deprecatedDep # # Profile defaults: # fast: dependency,dependencyDiff,lockfileDrift,secret,license,installScript,heavyDependency # hardcodedUrl,actionPin,eol,redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild # testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber,conflictMarker # debugLeftover,sizeSmell,floatingPromise,deepNesting,errorSwallow,unsafeAny,a11y,i18n,apiBreak +# deprecatedDep # balanced (default): dependency,dependencyDiff,lockfileDrift,secret,license,installScript # heavyDependency,hardcodedUrl,actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight # typosquat,commitSignature,iacMisconfig,nativeBuild,history,docCommentDrift,duplication @@ -83,13 +84,14 @@ GITTENSORY_REVIEW_ENRICHMENT=false # commitHygiene,pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology # todoMarker,magicNumber,conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting # errorSwallow,unsafeAny,a11y,i18n,unusedExport,exhaustiveness,flakyTest,commitLint,apiBreak +# deprecatedDep # deep: dependency,dependencyDiff,lockfileDrift,secret,license,installScript,heavyDependency # hardcodedUrl,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,debugLeftover,sizeSmell,floatingPromise,deepNesting,errorSwallow,unsafeAny,a11y -# i18n,unusedExport,exhaustiveness,flakyTest,commitLint,apiBreak +# i18n,unusedExport,exhaustiveness,flakyTest,commitLint,apiBreak,deprecatedDep # 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 901efeddf1..2f93f5a570 100644 --- a/apps/gittensory-ui/src/lib/rees-analyzers.ts +++ b/apps/gittensory-ui/src/lib/rees-analyzers.ts @@ -1243,6 +1243,31 @@ export const REES_ANALYZERS = [ "Conservative: only a top-level export whose exact name disappears from the file's public surface is reported; a same-name edit (signature or value change) or a non-entrypoint file is never flagged. Bounded by entrypoint and finding caps; fail-safe on absent or malformed patches.", }, }, + { + name: "deprecatedDep", + title: "Deprecated / unmaintained dependency", + category: "supply-chain", + cost: "local", + defaultEnabled: true, + profiles: ["fast", "balanced", "deep"], + requires: ["files"], + limits: { + maxManifestFiles: 20, + maxPatchLinesPerFile: 500, + maxFindings: 25, + }, + docs: { + summary: + "Flags a direct dependency a PR newly adds or upgrades that is an officially deprecated or unmaintained package with a maintained successor — an adoption risk the review brief should surface.", + looksAt: + "Added/changed dependency names in package.json and requirements.txt patches, matched against a bundled curated list of well-known deprecated packages.", + reports: + "Ecosystem, package, added version, direction (add/change), the documented deprecation reason, and the recommended replacement — never manifest contents.", + network: "Pure local analyzer. No external network call; the curated list is bundled.", + notes: + "Conservative: only an exact match against the bundled list is flagged, so a package it does not name is never reported. Bounded by manifest, patch-line, and finding caps; fail-safe on absent patches or an aborted signal.", + }, + }, ] as const satisfies readonly ReesAnalyzerDoc[]; export const REES_ANALYZER_NAMES = REES_ANALYZERS.map((analyzer) => analyzer.name); diff --git a/review-enrichment/analyzer-metadata.json b/review-enrichment/analyzer-metadata.json index bf9eece876..8fad1b54e0 100644 --- a/review-enrichment/analyzer-metadata.json +++ b/review-enrichment/analyzer-metadata.json @@ -1400,6 +1400,33 @@ "network": "Pure local analyzer. No external network call.", "notes": "Conservative: only a top-level export whose exact name disappears from the file's public surface is reported; a same-name edit (signature or value change) or a non-entrypoint file is never flagged. Bounded by entrypoint and finding caps; fail-safe on absent or malformed patches." } + }, + { + "name": "deprecatedDep", + "title": "Deprecated / unmaintained dependency", + "category": "supply-chain", + "cost": "local", + "defaultEnabled": true, + "profiles": [ + "fast", + "balanced", + "deep" + ], + "requires": [ + "files" + ], + "limits": { + "maxManifestFiles": 20, + "maxPatchLinesPerFile": 500, + "maxFindings": 25 + }, + "docs": { + "summary": "Flags a direct dependency a PR newly adds or upgrades that is an officially deprecated or unmaintained package with a maintained successor — an adoption risk the review brief should surface.", + "looksAt": "Added/changed dependency names in package.json and requirements.txt patches, matched against a bundled curated list of well-known deprecated packages.", + "reports": "Ecosystem, package, added version, direction (add/change), the documented deprecation reason, and the recommended replacement — never manifest contents.", + "network": "Pure local analyzer. No external network call; the curated list is bundled.", + "notes": "Conservative: only an exact match against the bundled list is flagged, so a package it does not name is never reported. Bounded by manifest, patch-line, and finding caps; fail-safe on absent patches or an aborted signal." + } } ] } diff --git a/review-enrichment/src/analyzers/deprecated-dep.ts b/review-enrichment/src/analyzers/deprecated-dep.ts new file mode 100644 index 0000000000..aec64eb244 --- /dev/null +++ b/review-enrichment/src/analyzers/deprecated-dep.ts @@ -0,0 +1,85 @@ +// Deprecated / unmaintained direct-dependency analyzer (#1511, part of #1499). A no-checkout headless reviewer sees +// only the diff, so it cannot tell that a dependency a PR newly ADDS or UPGRADES is an officially deprecated or +// abandoned package that a maintained successor has replaced — an adoption risk + future supply-chain liability the +// review brief should surface. This fills that gap purely from the changed manifest patches: it reuses the shared +// manifest dependency-change parser and matches each added/upgraded package name against a BUNDLED, curated list of +// well-known deprecated packages per ecosystem — the same offline-list approach the typosquat analyzer uses for its +// popular-package set. Deterministic, no network, no token: the curated list is the sole source of truth, so a +// package it does not name is never flagged (conservative + fail-safe). Reports ecosystem, package, the added +// version, the change direction, the documented reason, and the recommended replacement — never manifest contents. +import type { DeprecatedDependencyFinding, EnrichRequest } from "../types.js"; +import { extractDependencyChanges } from "./dependency-scan.js"; + +const MAX_MANIFEST_FILES = 20; // bound manifest files parsed per PR +const MAX_PATCH_LINES_PER_FILE = 500; // bound patch lines parsed per manifest +const MAX_FINDINGS = 25; // keep the brief bounded + +interface DeprecationNote { + reason: string; + replacement: string | null; +} + +// Curated, conservative registry of packages with a WELL-KNOWN published deprecation (npm-deprecated, a PyPI +// deprecation stub, or an officially retired project) and a maintained successor. Keyed ecosystem → normalized +// package name → note. Not exhaustive by design: only unambiguous, widely-recognized cases so a match is a real +// signal and never a guess. `replacement` is the community-recommended successor, or null when none is standard. +const DEPRECATED: Record> = { + npm: { + request: { reason: "deprecated — no longer maintained since 2020", replacement: "got or axios" }, + "request-promise": { reason: "deprecated with request", replacement: "got" }, + "request-promise-native": { reason: "deprecated with request", replacement: "got" }, + "node-sass": { reason: "deprecated — LibSass is deprecated", replacement: "sass (Dart Sass)" }, + tslint: { reason: "deprecated in favor of ESLint (2019)", replacement: "eslint + typescript-eslint" }, + "gulp-util": { reason: "deprecated — the bundled utility set was unpublished", replacement: null }, + istanbul: { reason: "deprecated — the project was renamed", replacement: "nyc" }, + "babel-preset-es2015": { reason: "deprecated — legacy Babel 6 preset", replacement: "@babel/preset-env" }, + bower: { reason: "deprecated front-end package manager", replacement: "npm or yarn" }, + "phantomjs-prebuilt": { reason: "deprecated — PhantomJS is suspended", replacement: "puppeteer or playwright" }, + }, + PyPI: { + sklearn: { reason: "deprecated PyPI stub for scikit-learn", replacement: "scikit-learn" }, + nose: { reason: "unmaintained — no Python 3.10+ support", replacement: "pytest or nose2" }, + pycrypto: { reason: "unmaintained — known unpatched CVEs", replacement: "pycryptodome" }, + beautifulsoup: { reason: "legacy BeautifulSoup 3, no longer maintained", replacement: "beautifulsoup4" }, + distribute: { reason: "deprecated — merged back into setuptools", replacement: "setuptools" }, + }, +}; + +/** Registry lookup key for a package name. npm names are case-folded; PyPI applies PEP 503 normalization — + * lowercased, with runs of `-`, `_`, and `.` collapsed to a single `-` — so `Foo_Bar` and `foo.bar` resolve + * to the same project. Pure. */ +export function normalizeName(ecosystem: string, name: string): string { + const lower = name.toLowerCase(); + return ecosystem === "PyPI" ? lower.replace(/[-_.]+/g, "-") : lower; +} + +/** Flag each newly-added or upgraded direct dependency the curated list marks deprecated/unmaintained. Reuses the + * shared manifest parser (which only yields deps present after the change), so removals are never flagged. + * Deterministic, no network. Returns [] on an aborted signal or when no changed manifest names a listed package; + * bounded by the manifest, patch-line, and finding caps. */ +export async function scanDeprecatedDependencies( + req: EnrichRequest, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) return []; + const findings: DeprecatedDependencyFinding[] = []; + const changes = extractDependencyChanges(req.files ?? [], { + maxManifestFiles: MAX_MANIFEST_FILES, + maxPatchLinesPerFile: MAX_PATCH_LINES_PER_FILE, + }); + for (const change of changes) { + if (signal?.aborted) break; + const note = DEPRECATED[change.ecosystem]?.[normalizeName(change.ecosystem, change.package)]; + if (!note) continue; + findings.push({ + ecosystem: change.ecosystem, + package: change.package, + version: change.to, + direction: change.from ? "change" : "add", + replacement: note.replacement, + reason: note.reason, + }); + if (findings.length >= MAX_FINDINGS) break; + } + return findings; +} diff --git a/review-enrichment/src/analyzers/registry.ts b/review-enrichment/src/analyzers/registry.ts index 4d7c059363..21960cd780 100644 --- a/review-enrichment/src/analyzers/registry.ts +++ b/review-enrichment/src/analyzers/registry.ts @@ -48,6 +48,7 @@ import { scanUnusedExport } from "./unused-export.js"; import { scanExhaustivenessDrift } from "./exhaustiveness-drift.js"; import { scanFlakyTest } from "./flaky-test.js"; import { scanApiBreak } from "./api-break.js"; +import { scanDeprecatedDependencies } from "./deprecated-dep.js"; import type { AnalyzerDescriptor, AnalyzerFn, @@ -1409,6 +1410,42 @@ export const ANALYZER_DESCRIPTORS = [ }, run: (req, { signal }) => scanApiBreak(req, signal), }), + descriptor({ + name: "deprecatedDep", + title: "Deprecated / unmaintained dependency", + category: "supply-chain", + cost: "local", + defaultEnabled: true, + requires: ["files"], + limits: { maxManifestFiles: 20, maxPatchLinesPerFile: 500, maxFindings: 25 }, + docs: { + summary: + "Flags a direct dependency a PR newly adds or upgrades that is an officially deprecated or unmaintained package with a maintained successor — an adoption risk the review brief should surface.", + looksAt: + "Added/changed dependency names in package.json and requirements.txt patches, matched against a bundled curated list of well-known deprecated packages.", + reports: + "Ecosystem, package, added version, direction (add/change), the documented deprecation reason, and the recommended replacement — never manifest contents.", + network: "Pure local analyzer. No external network call; the curated list is bundled.", + notes: + "Conservative: only an exact match against the bundled list is flagged, so a package it does not name is never reported. Bounded by manifest, patch-line, and finding caps; fail-safe on absent patches or an aborted signal.", + }, + render: (findings, helpers) => { + if (!findings.length) return []; + const lines = [ + "### Deprecated or unmaintained dependencies (newly added or upgraded)", + ]; + for (const item of findings) { + const replacement = item.replacement + ? `; consider ${helpers.safeCodeSpan(item.replacement)}` + : ""; + lines.push( + `- ${helpers.safeCodeSpan(`${item.package}@${item.version}`)} (${helpers.safeCodeSpan(item.ecosystem)}) — ${helpers.promptText(item.reason)}${replacement}`, + ); + } + return lines; + }, + run: (req, { signal }) => scanDeprecatedDependencies(req, signal), + }), ] as const satisfies readonly AnyAnalyzerDescriptor[]; export const ANALYZER_NAMES = ANALYZER_DESCRIPTORS.map( diff --git a/review-enrichment/src/render.ts b/review-enrichment/src/render.ts index 166470a0e4..be451fa054 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -499,6 +499,7 @@ export function renderBrief( lines.push(...renderDescriptorSection("hardcodedUrl", findings.hardcodedUrl)); lines.push(...renderDescriptorSection("commitLint", findings.commitLint)); lines.push(...renderDescriptorSection("apiBreak", findings.apiBreak)); + lines.push(...renderDescriptorSection("deprecatedDep", findings.deprecatedDep)); if (!lines.length) return { promptSection: "", systemSuffix: "" }; diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index b8dd44b0b3..39bb3ab5da 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -591,6 +591,18 @@ export interface CommitLintFinding { reason: "bad-type" | "missing-colon" | "too-long" | "empty"; } +/** A newly-added or upgraded direct dependency the curated list marks deprecated/unmaintained, with a maintained + * successor where one exists — adoption risk + future supply-chain liability (#1511, part of #1499). Reports the + * ecosystem, package, added version, direction, reason, and replacement only — never manifest contents. */ +export interface DeprecatedDependencyFinding { + ecosystem: string; + package: string; + version: string; + direction: "add" | "change"; + replacement: string | null; + reason: string; +} + /** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1477/#1478). */ /** An exported symbol a PR removes or renames in a package public entrypoint — a semver-major break for * downstream consumers shipped without a major version bump (#1510, part of #1499). Reports file, old-file line, @@ -652,6 +664,7 @@ export interface BriefFindings { hardcodedUrl?: HardcodedUrlFinding[]; commitLint?: CommitLintFinding[]; apiBreak?: ApiBreakFinding[]; + deprecatedDep?: DeprecatedDependencyFinding[]; } /** A JSDoc/TSDoc block whose `@param` tags name parameters the adjacent function no longer declares — a diff --git a/review-enrichment/test/analyzer-registry.test.ts b/review-enrichment/test/analyzer-registry.test.ts index ceb52b9717..23da7aafbf 100644 --- a/review-enrichment/test/analyzer-registry.test.ts +++ b/review-enrichment/test/analyzer-registry.test.ts @@ -60,6 +60,7 @@ const EXPECTED_ANALYZERS = [ "flakyTest", "commitLint", "apiBreak", + "deprecatedDep", ]; test("analyzer descriptors cover the runtime registry in stable order", () => { diff --git a/review-enrichment/test/deprecated-dep.test.ts b/review-enrichment/test/deprecated-dep.test.ts new file mode 100644 index 0000000000..884e0c88d6 --- /dev/null +++ b/review-enrichment/test/deprecated-dep.test.ts @@ -0,0 +1,117 @@ +// Units for the deprecated/unmaintained-dependency analyzer (#1511). Own file (not enrichment.test.ts) so concurrent +// analyzer PRs do not collide. Runs against the compiled dist/. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + scanDeprecatedDependencies, + normalizeName, +} from "../dist/analyzers/deprecated-dep.js"; +import { renderBrief } from "../dist/render.js"; + +// A package.json patch that ADDS each [name, version] as a new dependency line inside the dependencies block. +const npmAdd = (deps) => + `@@ -1,3 +1,${3 + deps.length} @@\n "dependencies": {\n${deps + .map(([n, v]) => `+ "${n}": "^${v}",`) + .join("\n")}\n }`; + +// A package.json patch that UPGRADES one dependency from -> to. +const npmChange = (name, from, to) => + `@@ -1,3 +1,3 @@\n "dependencies": {\n- "${name}": "^${from}",\n+ "${name}": "^${to}",\n }`; + +const pkg = (patch) => ({ + repoFullName: "o/r", + prNumber: 1, + files: [{ path: "package.json", patch }], +}); + +test("normalizeName: npm case-folds; PyPI applies PEP 503 separator collapse", () => { + assert.equal(normalizeName("npm", "Node-SASS"), "node-sass"); + assert.equal(normalizeName("PyPI", "Nose"), "nose"); + assert.equal(normalizeName("PyPI", "Foo_Bar.Baz"), "foo-bar-baz"); + assert.equal(normalizeName("npm", "Foo_Bar.Baz"), "foo_bar.baz"); +}); + +test("scanDeprecatedDependencies: flags a newly-added deprecated npm dependency with its successor", async () => { + const findings = await scanDeprecatedDependencies(pkg(npmAdd([["request", "2.88.2"]]))); + assert.equal(findings.length, 1); + assert.equal(findings[0].package, "request"); + assert.equal(findings[0].ecosystem, "npm"); + assert.equal(findings[0].version, "2.88.2"); + assert.equal(findings[0].direction, "add"); + assert.equal(findings[0].replacement, "got or axios"); + assert.match(findings[0].reason, /deprecated/); +}); + +test("scanDeprecatedDependencies: reports an upgrade of a still-deprecated package as direction 'change'", async () => { + const findings = await scanDeprecatedDependencies(pkg(npmChange("request", "2.88.0", "2.88.2"))); + assert.equal(findings.length, 1); + assert.equal(findings[0].direction, "change"); + assert.equal(findings[0].version, "2.88.2"); +}); + +test("scanDeprecatedDependencies: ignores a maintained dependency", async () => { + const findings = await scanDeprecatedDependencies(pkg(npmAdd([["axios", "1.7.0"]]))); + assert.deepEqual(findings, []); +}); + +test("scanDeprecatedDependencies: matches a PyPI package case-insensitively via normalization", async () => { + const findings = await scanDeprecatedDependencies({ + repoFullName: "o/r", + prNumber: 1, + files: [{ path: "requirements.txt", patch: "@@ -1 +1,2 @@\n+Nose==1.3.7\n+BeautifulSoup==3.2.2" }], + }); + const names = findings.map((f) => f.package).sort(); + assert.deepEqual(names, ["BeautifulSoup", "Nose"]); + const nose = findings.find((f) => f.package === "Nose"); + assert.equal(nose.ecosystem, "PyPI"); + assert.equal(nose.replacement, "pytest or nose2"); +}); + +test("scanDeprecatedDependencies: flags a package retired without a standard successor (null replacement)", async () => { + const findings = await scanDeprecatedDependencies(pkg(npmAdd([["gulp-util", "3.0.8"]]))); + assert.equal(findings.length, 1); + assert.equal(findings[0].package, "gulp-util"); + assert.equal(findings[0].replacement, null); +}); + +test("scanDeprecatedDependencies: fail-safe on no files and on an already-aborted signal", async () => { + assert.deepEqual(await scanDeprecatedDependencies({ repoFullName: "o/r", prNumber: 1 }), []); + const controller = new AbortController(); + controller.abort(); + assert.deepEqual( + await scanDeprecatedDependencies(pkg(npmAdd([["request", "2.88.2"]])), controller.signal), + [], + ); +}); + +test("renderBrief: renders deprecatedDep findings with and without a replacement", () => { + const { promptSection } = renderBrief({ + deprecatedDep: [ + { + ecosystem: "npm", + package: "request", + version: "2.88.2", + direction: "add", + replacement: "got or axios", + reason: "deprecated — no longer maintained since 2020", + }, + { + ecosystem: "npm", + package: "gulp-util", + version: "3.0.8", + direction: "add", + replacement: null, + reason: "deprecated — the bundled utility set was unpublished", + }, + ], + }); + assert.match(promptSection, /Deprecated or unmaintained dependencies/); + assert.match(promptSection, /request@2\.88\.2/); + assert.match(promptSection, /consider .*got or axios/); + assert.match(promptSection, /gulp-util@3\.0\.8/); +}); + +test("renderBrief: emits nothing for an empty deprecatedDep list", () => { + const { promptSection } = renderBrief({ deprecatedDep: [] }); + assert.doesNotMatch(promptSection, /Deprecated or unmaintained/); +}); diff --git a/src/review/enrichment-analyzer-names.ts b/src/review/enrichment-analyzer-names.ts index 9df807c72f..a0d39486e8 100644 --- a/src/review/enrichment-analyzer-names.ts +++ b/src/review/enrichment-analyzer-names.ts @@ -54,6 +54,7 @@ export const REES_ANALYZER_NAMES = [ "flakyTest", "commitLint", "apiBreak", + "deprecatedDep", ] as const; export type ReesAnalyzerName = (typeof REES_ANALYZER_NAMES)[number];