Skip to content

Commit 73423c8

Browse files
feat(enrichment): add deprecated / unmaintained dependency analyzer (#3637)
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 Co-authored-by: e11734937-beep <e11734937-beep@users.noreply.github.com>
1 parent ae02b36 commit 73423c8

10 files changed

Lines changed: 311 additions & 2 deletions

File tree

.env.example

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,27 +69,29 @@ GITTENSORY_REVIEW_ENRICHMENT=false
6969
# blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch,commitHygiene
7070
# pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber
7171
# conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting,errorSwallow,unsafeAny,a11y
72-
# i18n,unusedExport,exhaustiveness,flakyTest,commitLint,apiBreak
72+
# i18n,unusedExport,exhaustiveness,flakyTest,commitLint,apiBreak,deprecatedDep
7373
#
7474
# Profile defaults:
7575
# fast: dependency,dependencyDiff,lockfileDrift,secret,license,installScript,heavyDependency
7676
# hardcodedUrl,actionPin,eol,redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild
7777
# testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber,conflictMarker
7878
# debugLeftover,sizeSmell,floatingPromise,deepNesting,errorSwallow,unsafeAny,a11y,i18n,apiBreak
79+
# deprecatedDep
7980
# balanced (default): dependency,dependencyDiff,lockfileDrift,secret,license,installScript
8081
# heavyDependency,hardcodedUrl,actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight
8182
# typosquat,commitSignature,iacMisconfig,nativeBuild,history,docCommentDrift,duplication
8283
# churnHotspot,blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch
8384
# commitHygiene,pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology
8485
# todoMarker,magicNumber,conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting
8586
# errorSwallow,unsafeAny,a11y,i18n,unusedExport,exhaustiveness,flakyTest,commitLint,apiBreak
87+
# deprecatedDep
8688
# deep: dependency,dependencyDiff,lockfileDrift,secret,license,installScript,heavyDependency
8789
# hardcodedUrl,actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat
8890
# commitSignature,iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot
8991
# blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch,commitHygiene
9092
# pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber
9193
# conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting,errorSwallow,unsafeAny,a11y
92-
# i18n,unusedExport,exhaustiveness,flakyTest,commitLint,apiBreak
94+
# i18n,unusedExport,exhaustiveness,flakyTest,commitLint,apiBreak,deprecatedDep
9395
# END GENERATED REES ANALYZERS
9496

9597
# Submitter-reputation spend control (internal-only): downgrades new/burst/low-rep

apps/gittensory-ui/src/lib/rees-analyzers.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,6 +1243,31 @@ export const REES_ANALYZERS = [
12431243
"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.",
12441244
},
12451245
},
1246+
{
1247+
name: "deprecatedDep",
1248+
title: "Deprecated / unmaintained dependency",
1249+
category: "supply-chain",
1250+
cost: "local",
1251+
defaultEnabled: true,
1252+
profiles: ["fast", "balanced", "deep"],
1253+
requires: ["files"],
1254+
limits: {
1255+
maxManifestFiles: 20,
1256+
maxPatchLinesPerFile: 500,
1257+
maxFindings: 25,
1258+
},
1259+
docs: {
1260+
summary:
1261+
"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.",
1262+
looksAt:
1263+
"Added/changed dependency names in package.json and requirements.txt patches, matched against a bundled curated list of well-known deprecated packages.",
1264+
reports:
1265+
"Ecosystem, package, added version, direction (add/change), the documented deprecation reason, and the recommended replacement — never manifest contents.",
1266+
network: "Pure local analyzer. No external network call; the curated list is bundled.",
1267+
notes:
1268+
"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.",
1269+
},
1270+
},
12461271
] as const satisfies readonly ReesAnalyzerDoc[];
12471272

12481273
export const REES_ANALYZER_NAMES = REES_ANALYZERS.map((analyzer) => analyzer.name);

review-enrichment/analyzer-metadata.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1400,6 +1400,33 @@
14001400
"network": "Pure local analyzer. No external network call.",
14011401
"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."
14021402
}
1403+
},
1404+
{
1405+
"name": "deprecatedDep",
1406+
"title": "Deprecated / unmaintained dependency",
1407+
"category": "supply-chain",
1408+
"cost": "local",
1409+
"defaultEnabled": true,
1410+
"profiles": [
1411+
"fast",
1412+
"balanced",
1413+
"deep"
1414+
],
1415+
"requires": [
1416+
"files"
1417+
],
1418+
"limits": {
1419+
"maxManifestFiles": 20,
1420+
"maxPatchLinesPerFile": 500,
1421+
"maxFindings": 25
1422+
},
1423+
"docs": {
1424+
"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.",
1425+
"looksAt": "Added/changed dependency names in package.json and requirements.txt patches, matched against a bundled curated list of well-known deprecated packages.",
1426+
"reports": "Ecosystem, package, added version, direction (add/change), the documented deprecation reason, and the recommended replacement — never manifest contents.",
1427+
"network": "Pure local analyzer. No external network call; the curated list is bundled.",
1428+
"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."
1429+
}
14031430
}
14041431
]
14051432
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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+
}

review-enrichment/src/analyzers/registry.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { scanUnusedExport } from "./unused-export.js";
4848
import { scanExhaustivenessDrift } from "./exhaustiveness-drift.js";
4949
import { scanFlakyTest } from "./flaky-test.js";
5050
import { scanApiBreak } from "./api-break.js";
51+
import { scanDeprecatedDependencies } from "./deprecated-dep.js";
5152
import type {
5253
AnalyzerDescriptor,
5354
AnalyzerFn,
@@ -1409,6 +1410,42 @@ export const ANALYZER_DESCRIPTORS = [
14091410
},
14101411
run: (req, { signal }) => scanApiBreak(req, signal),
14111412
}),
1413+
descriptor({
1414+
name: "deprecatedDep",
1415+
title: "Deprecated / unmaintained dependency",
1416+
category: "supply-chain",
1417+
cost: "local",
1418+
defaultEnabled: true,
1419+
requires: ["files"],
1420+
limits: { maxManifestFiles: 20, maxPatchLinesPerFile: 500, maxFindings: 25 },
1421+
docs: {
1422+
summary:
1423+
"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.",
1424+
looksAt:
1425+
"Added/changed dependency names in package.json and requirements.txt patches, matched against a bundled curated list of well-known deprecated packages.",
1426+
reports:
1427+
"Ecosystem, package, added version, direction (add/change), the documented deprecation reason, and the recommended replacement — never manifest contents.",
1428+
network: "Pure local analyzer. No external network call; the curated list is bundled.",
1429+
notes:
1430+
"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.",
1431+
},
1432+
render: (findings, helpers) => {
1433+
if (!findings.length) return [];
1434+
const lines = [
1435+
"### Deprecated or unmaintained dependencies (newly added or upgraded)",
1436+
];
1437+
for (const item of findings) {
1438+
const replacement = item.replacement
1439+
? `; consider ${helpers.safeCodeSpan(item.replacement)}`
1440+
: "";
1441+
lines.push(
1442+
`- ${helpers.safeCodeSpan(`${item.package}@${item.version}`)} (${helpers.safeCodeSpan(item.ecosystem)}) — ${helpers.promptText(item.reason)}${replacement}`,
1443+
);
1444+
}
1445+
return lines;
1446+
},
1447+
run: (req, { signal }) => scanDeprecatedDependencies(req, signal),
1448+
}),
14121449
] as const satisfies readonly AnyAnalyzerDescriptor[];
14131450

14141451
export const ANALYZER_NAMES = ANALYZER_DESCRIPTORS.map(

review-enrichment/src/render.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,7 @@ export function renderBrief(
499499
lines.push(...renderDescriptorSection("hardcodedUrl", findings.hardcodedUrl));
500500
lines.push(...renderDescriptorSection("commitLint", findings.commitLint));
501501
lines.push(...renderDescriptorSection("apiBreak", findings.apiBreak));
502+
lines.push(...renderDescriptorSection("deprecatedDep", findings.deprecatedDep));
502503

503504
if (!lines.length) return { promptSection: "", systemSuffix: "" };
504505

review-enrichment/src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,18 @@ export interface CommitLintFinding {
591591
reason: "bad-type" | "missing-colon" | "too-long" | "empty";
592592
}
593593

594+
/** A newly-added or upgraded direct dependency the curated list marks deprecated/unmaintained, with a maintained
595+
* successor where one exists — adoption risk + future supply-chain liability (#1511, part of #1499). Reports the
596+
* ecosystem, package, added version, direction, reason, and replacement only — never manifest contents. */
597+
export interface DeprecatedDependencyFinding {
598+
ecosystem: string;
599+
package: string;
600+
version: string;
601+
direction: "add" | "change";
602+
replacement: string | null;
603+
reason: string;
604+
}
605+
594606
/** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1477/#1478). */
595607
/** An exported symbol a PR removes or renames in a package public entrypoint — a semver-major break for
596608
* downstream consumers shipped without a major version bump (#1510, part of #1499). Reports file, old-file line,
@@ -652,6 +664,7 @@ export interface BriefFindings {
652664
hardcodedUrl?: HardcodedUrlFinding[];
653665
commitLint?: CommitLintFinding[];
654666
apiBreak?: ApiBreakFinding[];
667+
deprecatedDep?: DeprecatedDependencyFinding[];
655668
}
656669

657670
/** A JSDoc/TSDoc block whose `@param` tags name parameters the adjacent function no longer declares — a

review-enrichment/test/analyzer-registry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const EXPECTED_ANALYZERS = [
6060
"flakyTest",
6161
"commitLint",
6262
"apiBreak",
63+
"deprecatedDep",
6364
];
6465

6566
test("analyzer descriptors cover the runtime registry in stable order", () => {

0 commit comments

Comments
 (0)