diff --git a/app/components/PackageDependencies.vue b/app/components/PackageDependencies.vue index c7d0f1a2f2..af325eee97 100644 --- a/app/components/PackageDependencies.vue +++ b/app/components/PackageDependencies.vue @@ -1,5 +1,5 @@ + + diff --git a/app/components/PackageVersions.vue b/app/components/PackageVersions.vue index 2c111134e2..bfdc97140d 100644 --- a/app/components/PackageVersions.vue +++ b/app/components/PackageVersions.vue @@ -363,7 +363,7 @@ function getTagVersions(tag: string): VersionDisplay[] {
+
@@ -418,7 +423,7 @@ function getTagVersions(tag: string): VersionDisplay[] {
+
@@ -510,7 +520,7 @@ function getTagVersions(tag: string): VersionDisplay[] {
+
@@ -580,7 +595,7 @@ function getTagVersions(tag: string): VersionDisplay[] { +
@@ -636,7 +656,7 @@ function getTagVersions(tag: string): VersionDisplay[] { +
@@ -690,7 +715,7 @@ function getTagVersions(tag: string): VersionDisplay[] {
+
diff --git a/app/components/PackageVulnerabilityTree.vue b/app/components/PackageVulnerabilityTree.vue index f1e1f29280..6741ae6461 100644 --- a/app/components/PackageVulnerabilityTree.vue +++ b/app/components/PackageVulnerabilityTree.vue @@ -11,7 +11,7 @@ const { data: vulnTree, status, fetch: fetchVulnTree, -} = useVulnerabilityTree( +} = useDependencyAnalysis( () => props.packageName, () => props.version, ) diff --git a/app/composables/useVulnerabilityTree.ts b/app/composables/useDependencyAnalysis.ts similarity index 77% rename from app/composables/useVulnerabilityTree.ts rename to app/composables/useDependencyAnalysis.ts index 223be0a711..36f1baad1d 100644 --- a/app/composables/useVulnerabilityTree.ts +++ b/app/composables/useDependencyAnalysis.ts @@ -1,17 +1,18 @@ -import type { VulnerabilityTreeResult } from '#shared/types/osv' +import type { VulnerabilityTreeResult } from '#shared/types/dependency-analysis' /** - * Shared composable for vulnerability tree data. + * Shared composable for dependency analysis data (vulnerabilities, deprecated packages). * Fetches once and caches the result so multiple components can use it. + * Before: useVulnerabilityTree - but now we use this for both vulnerabilities and deprecated packages. */ -export function useVulnerabilityTree( +export function useDependencyAnalysis( packageName: MaybeRefOrGetter, version: MaybeRefOrGetter, ) { // Build a stable key from the current values const name = toValue(packageName) const ver = toValue(version) - const key = `vuln-tree:v1:${name}@${ver}` + const key = `dep-analysis:v1:${name}@${ver}` // Use useState for SSR-safe caching across components const data = useState(key, () => null) @@ -37,7 +38,7 @@ export function useVulnerabilityTree( data.value = result status.value = 'success' } catch (e) { - error.value = e instanceof Error ? e : new Error('Failed to fetch vulnerabilities') + error.value = e instanceof Error ? e : new Error('Failed to fetch dependency analysis') status.value = 'error' } } diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index 350f548269..f968d00282 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -85,13 +85,13 @@ const displayVersion = computed(() => { return pkg.value.versions[latestTag] ?? null }) -// Fetch vulnerability tree (lazy, client-side) -// This is the same composable used by PackageVulnerabilityTree +// Fetch dependency analysis (lazy, client-side) +// This is the same composable used by PackageVulnerabilityTree and PackageDeprecatedTree const { data: vulnTree, status: vulnTreeStatus, fetch: fetchVulnTree, -} = useVulnerabilityTree(packageName, () => displayVersion.value?.version ?? '') +} = useDependencyAnalysis(packageName, () => displayVersion.value?.version ?? '') onMounted(() => { // Fetch vulnerability tree once displayVersion is available if (displayVersion.value) { @@ -1191,6 +1191,12 @@ defineOgImageComponent('Package', { :package-name="pkg.name" :version="displayVersion.version" /> +
diff --git a/i18n/locales/de-DE.json b/i18n/locales/de-DE.json index 6d6ffbf0f8..bf1390d3d2 100644 --- a/i18n/locales/de-DE.json +++ b/i18n/locales/de-DE.json @@ -271,6 +271,11 @@ "transitive": "Transitive Abhängigkeit" } }, + "deprecated": { + "label": "Veraltet", + "tree_found": "{count} veraltete Abhängigkeit | {count} veraltete Abhängigkeiten", + "show_all": "alle {count} veralteten Pakete anzeigen" + }, "access": { "title": "Team-Zugriff", "refresh": "Team-Zugriff aktualisieren", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 363baa7dc4..da3b00ec56 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -276,6 +276,11 @@ "low": "low" } }, + "deprecated": { + "label": "Deprecated", + "tree_found": "{count} deprecated dependency | {count} deprecated dependencies", + "show_all": "show all {count} deprecated packages" + }, "access": { "title": "Team Access", "refresh": "Refresh team access", diff --git a/i18n/locales/es.json b/i18n/locales/es.json index 38cf950298..b111d37115 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -233,6 +233,11 @@ "low": "baja" } }, + "deprecated": { + "label": "Obsoleto", + "tree_found": "{count} dependencia obsoleta | {count} dependencias obsoletas", + "show_all": "mostrar los {count} paquetes obsoletos" + }, "access": { "title": "Acceso de Equipos", "refresh": "Actualizar acceso de equipos", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 27eb97ff6a..971404afc0 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -225,6 +225,11 @@ "low": "faible" } }, + "deprecated": { + "label": "Obsolète", + "tree_found": "{count} dépendance obsolète | {count} dépendances obsolètes", + "show_all": "afficher les {count} paquets obsolètes" + }, "access": { "title": "Accès des équipes", "refresh": "Actualiser l'accès des équipes", diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index b9d1c6f485..c5bf4bb0b7 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -241,6 +241,11 @@ "low": "bassa" } }, + "deprecated": { + "label": "Deprecato", + "tree_found": "{count} dipendenza deprecata | {count} dipendenze deprecate", + "show_all": "mostra tutti i {count} pacchetti deprecati" + }, "access": { "title": "Accesso Team", "refresh": "Aggiorna accesso team", diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index 37ca2552ad..c4a2433fae 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -276,6 +276,11 @@ "low": "低" } }, + "deprecated": { + "label": "非推奨", + "tree_found": "{count} 件の非推奨の依存関係", + "show_all": "{count} 件の非推奨パッケージをすべて表示" + }, "access": { "title": "チームアクセス", "refresh": "チームアクセスを更新", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index dcb4de3a08..bd2100baea 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -241,6 +241,11 @@ "low": "低" } }, + "deprecated": { + "label": "已弃用", + "tree_found": "{count} 个已弃用的依赖", + "show_all": "显示全部 {count} 个已弃用的包" + }, "access": { "title": "团队权限", "refresh": "刷新团队权限", diff --git a/lunaria/files/de-DE.json b/lunaria/files/de-DE.json index 6d6ffbf0f8..bf1390d3d2 100644 --- a/lunaria/files/de-DE.json +++ b/lunaria/files/de-DE.json @@ -271,6 +271,11 @@ "transitive": "Transitive Abhängigkeit" } }, + "deprecated": { + "label": "Veraltet", + "tree_found": "{count} veraltete Abhängigkeit | {count} veraltete Abhängigkeiten", + "show_all": "alle {count} veralteten Pakete anzeigen" + }, "access": { "title": "Team-Zugriff", "refresh": "Team-Zugriff aktualisieren", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 363baa7dc4..da3b00ec56 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -276,6 +276,11 @@ "low": "low" } }, + "deprecated": { + "label": "Deprecated", + "tree_found": "{count} deprecated dependency | {count} deprecated dependencies", + "show_all": "show all {count} deprecated packages" + }, "access": { "title": "Team Access", "refresh": "Refresh team access", diff --git a/lunaria/files/es-419.json b/lunaria/files/es-419.json index 38cf950298..b111d37115 100644 --- a/lunaria/files/es-419.json +++ b/lunaria/files/es-419.json @@ -233,6 +233,11 @@ "low": "baja" } }, + "deprecated": { + "label": "Obsoleto", + "tree_found": "{count} dependencia obsoleta | {count} dependencias obsoletas", + "show_all": "mostrar los {count} paquetes obsoletos" + }, "access": { "title": "Acceso de Equipos", "refresh": "Actualizar acceso de equipos", diff --git a/lunaria/files/es-ES.json b/lunaria/files/es-ES.json index 38cf950298..b111d37115 100644 --- a/lunaria/files/es-ES.json +++ b/lunaria/files/es-ES.json @@ -233,6 +233,11 @@ "low": "baja" } }, + "deprecated": { + "label": "Obsoleto", + "tree_found": "{count} dependencia obsoleta | {count} dependencias obsoletas", + "show_all": "mostrar los {count} paquetes obsoletos" + }, "access": { "title": "Acceso de Equipos", "refresh": "Actualizar acceso de equipos", diff --git a/lunaria/files/fr-FR.json b/lunaria/files/fr-FR.json index 27eb97ff6a..971404afc0 100644 --- a/lunaria/files/fr-FR.json +++ b/lunaria/files/fr-FR.json @@ -225,6 +225,11 @@ "low": "faible" } }, + "deprecated": { + "label": "Obsolète", + "tree_found": "{count} dépendance obsolète | {count} dépendances obsolètes", + "show_all": "afficher les {count} paquets obsolètes" + }, "access": { "title": "Accès des équipes", "refresh": "Actualiser l'accès des équipes", diff --git a/lunaria/files/it-IT.json b/lunaria/files/it-IT.json index b9d1c6f485..c5bf4bb0b7 100644 --- a/lunaria/files/it-IT.json +++ b/lunaria/files/it-IT.json @@ -241,6 +241,11 @@ "low": "bassa" } }, + "deprecated": { + "label": "Deprecato", + "tree_found": "{count} dipendenza deprecata | {count} dipendenze deprecate", + "show_all": "mostra tutti i {count} pacchetti deprecati" + }, "access": { "title": "Accesso Team", "refresh": "Aggiorna accesso team", diff --git a/lunaria/files/ja-JP.json b/lunaria/files/ja-JP.json index 37ca2552ad..c4a2433fae 100644 --- a/lunaria/files/ja-JP.json +++ b/lunaria/files/ja-JP.json @@ -276,6 +276,11 @@ "low": "低" } }, + "deprecated": { + "label": "非推奨", + "tree_found": "{count} 件の非推奨の依存関係", + "show_all": "{count} 件の非推奨パッケージをすべて表示" + }, "access": { "title": "チームアクセス", "refresh": "チームアクセスを更新", diff --git a/lunaria/files/zh-CN.json b/lunaria/files/zh-CN.json index dcb4de3a08..bd2100baea 100644 --- a/lunaria/files/zh-CN.json +++ b/lunaria/files/zh-CN.json @@ -241,6 +241,11 @@ "low": "低" } }, + "deprecated": { + "label": "已弃用", + "tree_found": "{count} 个已弃用的依赖", + "show_all": "显示全部 {count} 个已弃用的包" + }, "access": { "title": "团队权限", "refresh": "刷新团队权限", diff --git a/server/api/registry/vulnerabilities/[...pkg].get.ts b/server/api/registry/vulnerabilities/[...pkg].get.ts index d9c627deb6..54a6197e9c 100644 --- a/server/api/registry/vulnerabilities/[...pkg].get.ts +++ b/server/api/registry/vulnerabilities/[...pkg].get.ts @@ -5,7 +5,8 @@ import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' /** * GET /api/registry/vulnerabilities/:name or /api/registry/vulnerabilities/:name/v/:version * - * Analyze entire dependency tree for vulnerabilities. + * Analyze entire dependency tree for vulnerabilities and deprecated dependencies. + * I does not rename this endpoint for backward compatibility. */ export default defineCachedEventHandler( async event => { @@ -31,7 +32,7 @@ export default defineCachedEventHandler( } } - return await analyzeVulnerabilityTree(packageName, version) + return await analyzeDependencyTree(packageName, version) } catch (error: unknown) { handleApiError(error, { statusCode: 502, diff --git a/server/utils/vulnerability-tree.ts b/server/utils/dependency-analysis.ts similarity index 84% rename from server/utils/vulnerability-tree.ts rename to server/utils/dependency-analysis.ts index 2e326f437e..5937536b4a 100644 --- a/server/utils/vulnerability-tree.ts +++ b/server/utils/dependency-analysis.ts @@ -6,7 +6,8 @@ import type { DependencyDepth, PackageVulnerabilityInfo, VulnerabilityTreeResult, -} from '#shared/types' + DeprecatedPackageInfo, +} from '#shared/types/dependency-analysis' import { resolveDependencyTree } from './dependency-resolver' /** Result of a single OSV query */ @@ -67,7 +68,7 @@ async function queryOsv( return { status: 'ok', data: { name, version, depth, path, vulnerabilities, counts } } } catch (error) { // oxlint-disable-next-line no-console -- log OSV API failures for debugging - console.warn(`[vuln-tree] OSV query failed for ${name}@${version}:`, error) + console.warn(`[dep-analysis] OSV query failed for ${name}@${version}:`, error) return { status: 'error' } } } @@ -108,10 +109,10 @@ function getSeverityLevel(vuln: OsvVulnerability): OsvSeverityLevel { } /** - * Analyze entire dependency tree for vulnerabilities. + * Analyze entire dependency tree for vulnerabilities and deprecated packages. * @public */ -export const analyzeVulnerabilityTree = defineCachedFunction( +export const analyzeDependencyTree = defineCachedFunction( async (name: string, version: string): Promise => { // Resolve all packages in the tree with depth tracking const resolved = await resolveDependencyTree(name, version, { trackDepth: true }) @@ -119,6 +120,22 @@ export const analyzeVulnerabilityTree = defineCachedFunction( // Convert to array for OSV querying const packages = [...resolved.values()] + // Collect deprecated packages (no API call needed - already in packument data) + const deprecatedPackages: DeprecatedPackageInfo[] = packages + .filter(pkg => pkg.deprecated) + .map(pkg => ({ + name: pkg.name, + version: pkg.version, + depth: pkg.depth!, + path: pkg.path || [], + message: pkg.deprecated!, + })) + .sort((a, b) => { + // Sort by depth (root → direct → transitive) + const depthOrder: Record = { root: 0, direct: 1, transitive: 2 } + return depthOrder[a.depth] - depthOrder[b.depth] + }) + // Query OSV for all packages in parallel batches const vulnerablePackages: PackageVulnerabilityInfo[] = [] let failedQueries = 0 @@ -163,7 +180,7 @@ export const analyzeVulnerabilityTree = defineCachedFunction( if (failedQueries > 0 && failedQueries > packages.length / 2) { // oxlint-disable-next-line no-console -- critical error logging console.error( - `[vuln-tree] Critical: ${failedQueries}/${packages.length} OSV queries failed for ${name}@${version}`, + `[dep-analysis] Critical: ${failedQueries}/${packages.length} OSV queries failed for ${name}@${version}`, ) } @@ -171,6 +188,7 @@ export const analyzeVulnerabilityTree = defineCachedFunction( package: name, version, vulnerablePackages, + deprecatedPackages, totalPackages: packages.length, failedQueries, totalCounts, @@ -179,7 +197,7 @@ export const analyzeVulnerabilityTree = defineCachedFunction( { maxAge: 60 * 60, swr: true, - name: 'vulnerability-tree', + name: 'dependency-analysis', getKey: (name: string, version: string) => `v1:${name}@${version}`, }, ) diff --git a/server/utils/dependency-resolver.ts b/server/utils/dependency-resolver.ts index 4d3f200000..47eaa559bc 100644 --- a/server/utils/dependency-resolver.ts +++ b/server/utils/dependency-resolver.ts @@ -111,6 +111,8 @@ export interface ResolvedPackage { depth?: DependencyDepth /** Dependency path from root (only when trackDepth is enabled) */ path?: string[] + /** Deprecation message if the version is deprecated */ + deprecated?: string } /** @@ -171,6 +173,9 @@ export async function resolveDependencyTree( pkg.depth = level === 0 ? 'root' : level === 1 ? 'direct' : 'transitive' pkg.path = currentPath } + if (versionData.deprecated) { + pkg.deprecated = versionData.deprecated + } resolved.set(key, pkg) } diff --git a/shared/types/osv.ts b/shared/types/dependency-analysis.ts similarity index 82% rename from shared/types/osv.ts rename to shared/types/dependency-analysis.ts index 53ad78dce3..7df81e5b7e 100644 --- a/shared/types/osv.ts +++ b/shared/types/dependency-analysis.ts @@ -1,5 +1,7 @@ /** - * OSV (Open Source Vulnerabilities) API types + * Dependency Analysis Types + * Types for vulnerability scanning (via OSV API) and deprecated package detection. + * * @see https://google.github.io/osv.dev/api/ */ @@ -108,7 +110,21 @@ export interface PackageVulnerabilityInfo { } /** - * Result of vulnerability tree analysis + * Deprecated package info in the dependency tree + */ +export interface DeprecatedPackageInfo { + name: string + version: string + /** Depth in dependency tree: root (0), direct (1), transitive (2+) */ + depth: DependencyDepth + /** Dependency path from root package */ + path: string[] + /** Deprecation message */ + message: string +} + +/** + * Result of dependency tree analysis */ export interface VulnerabilityTreeResult { /** Root package name */ @@ -117,6 +133,8 @@ export interface VulnerabilityTreeResult { version: string /** All packages with vulnerabilities in the tree */ vulnerablePackages: PackageVulnerabilityInfo[] + /** All deprecated packages in the tree */ + deprecatedPackages: DeprecatedPackageInfo[] /** Total packages analyzed */ totalPackages: number /** Number of packages that could not be checked (OSV query failed) */ diff --git a/shared/types/index.ts b/shared/types/index.ts index 228f6e8523..1bb0a8a132 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -1,6 +1,6 @@ export * from './npm-registry' export * from './jsr' -export * from './osv' +export * from './dependency-analysis' export * from './readme' export * from './docs' export * from './deno-doc' diff --git a/test/nuxt/components.spec.ts b/test/nuxt/components.spec.ts index a50dc54a20..7d47dd49c5 100644 --- a/test/nuxt/components.spec.ts +++ b/test/nuxt/components.spec.ts @@ -93,6 +93,7 @@ import PackageTableRow from '~/components/PackageTableRow.vue' import PaginationControls from '~/components/PaginationControls.vue' import ViewModeToggle from '~/components/ViewModeToggle.vue' import PackageVulnerabilityTree from '~/components/PackageVulnerabilityTree.vue' +import PackageDeprecatedTree from '~/components/PackageDeprecatedTree.vue' import DependencyPathPopup from '~/components/DependencyPathPopup.vue' describe('component accessibility audits', () => { @@ -1258,6 +1259,19 @@ describe('component accessibility audits', () => { }) }) + describe('PackageDeprecatedTree', () => { + it('should have no accessibility violations in idle state', async () => { + const component = await mountSuspended(PackageDeprecatedTree, { + props: { + packageName: 'vue', + version: '3.5.0', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('DependencyPathPopup', () => { it('should have no accessibility violations with short path', async () => { const component = await mountSuspended(DependencyPathPopup, { diff --git a/test/unit/vulnerability-tree.spec.ts b/test/unit/dependency-analysis.spec.ts similarity index 62% rename from test/unit/vulnerability-tree.spec.ts rename to test/unit/dependency-analysis.spec.ts index 5090874c68..fd2a5818ee 100644 --- a/test/unit/vulnerability-tree.spec.ts +++ b/test/unit/dependency-analysis.spec.ts @@ -5,7 +5,7 @@ vi.stubGlobal('defineCachedFunction', (fn: Function) => fn) vi.stubGlobal('$fetch', vi.fn()) // Import module under test -const { analyzeVulnerabilityTree } = await import('../../server/utils/vulnerability-tree') +const { analyzeDependencyTree } = await import('../../server/utils/dependency-analysis') // Mock the dependency resolver vi.mock('../../server/utils/dependency-resolver', () => ({ @@ -14,12 +14,12 @@ vi.mock('../../server/utils/dependency-resolver', () => ({ const { resolveDependencyTree } = await import('../../server/utils/dependency-resolver') -describe('vulnerability-tree', () => { +describe('dependency-analysis', () => { beforeEach(() => { vi.clearAllMocks() }) - describe('analyzeVulnerabilityTree', () => { + describe('analyzeDependencyTree', () => { it('returns empty result when no packages have vulnerabilities', async () => { const mockResolved = new Map([ [ @@ -39,7 +39,7 @@ describe('vulnerability-tree', () => { // Mock OSV API returning no vulnerabilities vi.mocked($fetch).mockResolvedValue({ vulns: [] }) - const result = await analyzeVulnerabilityTree('test-pkg', '1.0.0') + const result = await analyzeDependencyTree('test-pkg', '1.0.0') expect(result.package).toBe('test-pkg') expect(result.version).toBe('1.0.0') @@ -81,7 +81,7 @@ describe('vulnerability-tree', () => { .mockResolvedValueOnce({ vulns: [] }) .mockRejectedValueOnce(new Error('OSV API error')) - const result = await analyzeVulnerabilityTree('test-pkg', '1.0.0') + const result = await analyzeDependencyTree('test-pkg', '1.0.0') expect(result.failedQueries).toBe(1) expect(result.totalPackages).toBe(2) @@ -113,7 +113,7 @@ describe('vulnerability-tree', () => { ], }) - const result = await analyzeVulnerabilityTree('vuln-pkg', '1.0.0') + const result = await analyzeDependencyTree('vuln-pkg', '1.0.0') expect(result.vulnerablePackages).toHaveLength(1) expect(result.totalCounts).toEqual({ total: 4, critical: 1, high: 1, moderate: 1, low: 1 }) @@ -160,7 +160,7 @@ describe('vulnerability-tree', () => { ], }) // vuln-dep has vuln - const result = await analyzeVulnerabilityTree('root', '1.0.0') + const result = await analyzeDependencyTree('root', '1.0.0') expect(result.vulnerablePackages).toHaveLength(1) const vulnPkg = result.vulnerablePackages[0] @@ -228,7 +228,7 @@ describe('vulnerability-tree', () => { ], }) - const result = await analyzeVulnerabilityTree('root', '1.0.0') + const result = await analyzeDependencyTree('root', '1.0.0') expect(result.vulnerablePackages).toHaveLength(3) // Should be sorted: root first, then direct, then transitive @@ -263,7 +263,7 @@ describe('vulnerability-tree', () => { ], }) - const result = await analyzeVulnerabilityTree('pkg', '1.0.0') + const result = await analyzeDependencyTree('pkg', '1.0.0') expect(result.vulnerablePackages[0].vulnerabilities[0].url).toBe( 'https://github.com/advisories/GHSA-xxxx-yyyy-zzzz', @@ -297,7 +297,7 @@ describe('vulnerability-tree', () => { ], }) - const result = await analyzeVulnerabilityTree('pkg', '1.0.0') + const result = await analyzeDependencyTree('pkg', '1.0.0') expect(result.vulnerablePackages[0].vulnerabilities[0].url).toBe( 'https://nvd.nist.gov/vuln/detail/CVE-2024-12345', @@ -326,7 +326,7 @@ describe('vulnerability-tree', () => { ], }) - const result = await analyzeVulnerabilityTree('pkg', '1.0.0') + const result = await analyzeDependencyTree('pkg', '1.0.0') expect(result.vulnerablePackages[0].vulnerabilities[0].url).toBe( 'https://osv.dev/vulnerability/PYSEC-2024-001', @@ -362,12 +362,190 @@ describe('vulnerability-tree', () => { ], }) - const result = await analyzeVulnerabilityTree('pkg', '1.0.0') + const result = await analyzeDependencyTree('pkg', '1.0.0') expect(result.totalCounts.critical).toBe(1) expect(result.totalCounts.high).toBe(1) expect(result.totalCounts.moderate).toBe(1) expect(result.totalCounts.low).toBe(1) }) + + it('collects deprecated packages from the dependency tree', async () => { + const mockResolved = new Map([ + [ + 'root@1.0.0', + { + name: 'root', + version: '1.0.0', + size: 1000, + optional: false, + depth: 'root' as const, + path: ['root@1.0.0'], + }, + ], + [ + 'deprecated-pkg@2.0.0', + { + name: 'deprecated-pkg', + version: '2.0.0', + size: 500, + optional: false, + depth: 'direct' as const, + path: ['root@1.0.0', 'deprecated-pkg@2.0.0'], + deprecated: 'This package is deprecated. Use new-pkg instead.', + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + vi.mocked($fetch).mockResolvedValue({ vulns: [] }) + + const result = await analyzeDependencyTree('root', '1.0.0') + + expect(result.deprecatedPackages).toHaveLength(1) + expect(result.deprecatedPackages[0]).toEqual({ + name: 'deprecated-pkg', + version: '2.0.0', + depth: 'direct', + path: ['root@1.0.0', 'deprecated-pkg@2.0.0'], + message: 'This package is deprecated. Use new-pkg instead.', + }) + }) + + it('returns empty deprecatedPackages when none are deprecated', async () => { + const mockResolved = new Map([ + [ + 'root@1.0.0', + { + name: 'root', + version: '1.0.0', + size: 1000, + optional: false, + depth: 'root' as const, + path: ['root@1.0.0'], + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + vi.mocked($fetch).mockResolvedValue({ vulns: [] }) + + const result = await analyzeDependencyTree('root', '1.0.0') + + expect(result.deprecatedPackages).toHaveLength(0) + }) + + it('sorts deprecated packages by depth (root → direct → transitive)', async () => { + const mockResolved = new Map([ + [ + 'root@1.0.0', + { + name: 'root', + version: '1.0.0', + size: 1000, + optional: false, + depth: 'root' as const, + path: ['root@1.0.0'], + deprecated: 'Root is deprecated', + }, + ], + [ + 'transitive-dep@1.0.0', + { + name: 'transitive-dep', + version: '1.0.0', + size: 300, + optional: false, + depth: 'transitive' as const, + path: ['root@1.0.0', 'direct-dep@1.0.0', 'transitive-dep@1.0.0'], + deprecated: 'Transitive is deprecated', + }, + ], + [ + 'direct-dep@1.0.0', + { + name: 'direct-dep', + version: '1.0.0', + size: 500, + optional: false, + depth: 'direct' as const, + path: ['root@1.0.0', 'direct-dep@1.0.0'], + deprecated: 'Direct is deprecated', + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + vi.mocked($fetch).mockResolvedValue({ vulns: [] }) + + const result = await analyzeDependencyTree('root', '1.0.0') + + expect(result.deprecatedPackages).toHaveLength(3) + expect(result.deprecatedPackages[0].name).toBe('root') + expect(result.deprecatedPackages[0].depth).toBe('root') + expect(result.deprecatedPackages[1].name).toBe('direct-dep') + expect(result.deprecatedPackages[1].depth).toBe('direct') + expect(result.deprecatedPackages[2].name).toBe('transitive-dep') + expect(result.deprecatedPackages[2].depth).toBe('transitive') + }) + + it('returns both vulnerabilities and deprecated packages together', async () => { + const mockResolved = new Map([ + [ + 'root@1.0.0', + { + name: 'root', + version: '1.0.0', + size: 1000, + optional: false, + depth: 'root' as const, + path: ['root@1.0.0'], + }, + ], + [ + 'vuln-pkg@1.0.0', + { + name: 'vuln-pkg', + version: '1.0.0', + size: 500, + optional: false, + depth: 'direct' as const, + path: ['root@1.0.0', 'vuln-pkg@1.0.0'], + }, + ], + [ + 'deprecated-pkg@1.0.0', + { + name: 'deprecated-pkg', + version: '1.0.0', + size: 300, + optional: false, + depth: 'direct' as const, + path: ['root@1.0.0', 'deprecated-pkg@1.0.0'], + deprecated: 'Use something else', + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + + // root and deprecated-pkg have no vulns, vuln-pkg has one + vi.mocked($fetch) + .mockResolvedValueOnce({ vulns: [] }) // root + .mockResolvedValueOnce({ + vulns: [ + { + id: 'GHSA-vuln', + summary: 'A vulnerability', + database_specific: { severity: 'HIGH' }, + }, + ], + }) // vuln-pkg + .mockResolvedValueOnce({ vulns: [] }) // deprecated-pkg + + const result = await analyzeDependencyTree('root', '1.0.0') + + expect(result.vulnerablePackages).toHaveLength(1) + expect(result.vulnerablePackages[0].name).toBe('vuln-pkg') + expect(result.deprecatedPackages).toHaveLength(1) + expect(result.deprecatedPackages[0].name).toBe('deprecated-pkg') + expect(result.totalPackages).toBe(3) + }) }) })