From 7c02f03081516560b6bb7235051ed6e06dce38df Mon Sep 17 00:00:00 2001 From: Anil Loutombam Date: Fri, 31 Jul 2026 13:18:55 +0530 Subject: [PATCH 1/4] fix: detect implicit types in package timeline --- server/api/registry/timeline/[...pkg].get.ts | 34 ++++++++++++++++- .../api/registry/timeline/pkg.get.spec.ts | 37 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/server/api/registry/timeline/[...pkg].get.ts b/server/api/registry/timeline/[...pkg].get.ts index 948decc7bf..acd72f89f7 100644 --- a/server/api/registry/timeline/[...pkg].get.ts +++ b/server/api/registry/timeline/[...pkg].get.ts @@ -1,5 +1,5 @@ import { normalizeLicense } from '#shared/utils/npm' -import { hasBuiltInTypes } from '~~/shared/utils/package-analysis' +import { analyzePackage, hasBuiltInTypes } from '~~/shared/utils/package-analysis' const DEFAULT_LIMIT = 25 @@ -82,9 +82,39 @@ export default defineCachedEventHandler( } }) .sort((a, b) => Date.parse(b.time) - Date.parse(a.time)) + const visibleVersions = allVersions.slice(offset, offset + limit) + + const possibleTypeRemovals = visibleVersions.filter(version => { + const versionIndex = allVersions.indexOf(version) + const previousVersion = allVersions[versionIndex + 1] + + return !version.hasTypes && previousVersion?.hasTypes + }) + + await Promise.all( + possibleTypeRemovals.map(async version => { + try { + const { pkg, typesPackage, files } = await fetchPackageWithTypesAndFiles( + packageName, + version.version, + ) + + const analysis = analyzePackage(pkg, { + typesPackage, + files, + }) + + if (analysis.types.kind === 'included') { + version.hasTypes = true + } + } catch { + // Preserve the metadata-only result when the file list is unavailable. + } + }), + ) return { - versions: allVersions.slice(offset, offset + limit), + versions: visibleVersions, total: allVersions.length, } satisfies TimelineResponse } catch (error: unknown) { diff --git a/test/unit/server/api/registry/timeline/pkg.get.spec.ts b/test/unit/server/api/registry/timeline/pkg.get.spec.ts index 44fea5a101..b589cf2f64 100644 --- a/test/unit/server/api/registry/timeline/pkg.get.spec.ts +++ b/test/unit/server/api/registry/timeline/pkg.get.spec.ts @@ -3,7 +3,9 @@ import { createError, type H3Event } from 'h3' import type { Packument, PackumentVersion } from '#shared/types/npm-registry' const fetchNpmPackageMock = vi.fn() +const fetchPackageWithTypesAndFilesMock = vi.fn() vi.stubGlobal('fetchNpmPackage', fetchNpmPackageMock) +vi.stubGlobal('fetchPackageWithTypesAndFiles', fetchPackageWithTypesAndFilesMock) vi.stubGlobal('defineCachedEventHandler', (fn: Function) => fn) vi.stubGlobal('CACHE_MAX_AGE_FIVE_MINUTES', 300) @@ -227,6 +229,41 @@ describe('timeline API', () => { const result = await handler(fakeEvent) expect(result.versions[0]!.hasTypes).toBe(true) }) + it('sets hasTypes when declaration files exist beside an entry point', async () => { + routerParam = 'my-pkg' + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { + '1.0.0': { + types: './dist/index.d.ts', + }, + '2.0.0': { + main: './dist/index.mjs', + }, + }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2025-01-01T00:00:00Z', + }, + }), + ) + + fetchPackageWithTypesAndFilesMock.mockResolvedValue({ + pkg: { + name: 'my-pkg', + version: '2.0.0', + main: './dist/index.mjs', + }, + files: new Set(['dist/index.mjs', 'dist/index.d.mts']), + }) + + const result = await handler(fakeEvent) + + expect(fetchPackageWithTypesAndFilesMock).toHaveBeenCalledWith('my-pkg', '2.0.0') + expect(result.versions[0]!.hasTypes).toBe(true) + expect(result.versions[1]!.hasTypes).toBe(true) + }) it('sets hasTrustedPublisher when trustedPublisher is true', async () => { routerParam = 'my-pkg' From bdeddc4c604fdfb391bb6f95babf51398b09dd65 Mon Sep 17 00:00:00 2001 From: Anil Loutombam Date: Fri, 31 Jul 2026 13:51:12 +0530 Subject: [PATCH 2/4] fix: analyze consecutive implicit type versions --- server/api/registry/timeline/[...pkg].get.ts | 6 +-- .../api/registry/timeline/pkg.get.spec.ts | 52 +++++++++++++++++-- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/server/api/registry/timeline/[...pkg].get.ts b/server/api/registry/timeline/[...pkg].get.ts index acd72f89f7..4988dbf9c8 100644 --- a/server/api/registry/timeline/[...pkg].get.ts +++ b/server/api/registry/timeline/[...pkg].get.ts @@ -85,10 +85,10 @@ export default defineCachedEventHandler( const visibleVersions = allVersions.slice(offset, offset + limit) const possibleTypeRemovals = visibleVersions.filter(version => { - const versionIndex = allVersions.indexOf(version) - const previousVersion = allVersions[versionIndex + 1] + if (version.hasTypes) return false - return !version.hasTypes && previousVersion?.hasTypes + const versionIndex = allVersions.indexOf(version) + return allVersions.slice(versionIndex + 1).some(previousVersion => previousVersion.hasTypes) }) await Promise.all( diff --git a/test/unit/server/api/registry/timeline/pkg.get.spec.ts b/test/unit/server/api/registry/timeline/pkg.get.spec.ts index b589cf2f64..0d7cbf3acc 100644 --- a/test/unit/server/api/registry/timeline/pkg.get.spec.ts +++ b/test/unit/server/api/registry/timeline/pkg.get.spec.ts @@ -229,7 +229,8 @@ describe('timeline API', () => { const result = await handler(fakeEvent) expect(result.versions[0]!.hasTypes).toBe(true) }) - it('sets hasTypes when declaration files exist beside an entry point', async () => { + + it('sets hasTypes for consecutive versions with declaration files', async () => { routerParam = 'my-pkg' fetchNpmPackageMock.mockResolvedValue( @@ -241,6 +242,47 @@ describe('timeline API', () => { '2.0.0': { main: './dist/index.mjs', }, + '3.0.0': { + main: './dist/index.mjs', + }, + }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2025-01-01T00:00:00Z', + '3.0.0': '2026-01-01T00:00:00Z', + }, + }), + ) + + fetchPackageWithTypesAndFilesMock.mockImplementation( + async (packageName: string, version: string) => ({ + pkg: { + name: packageName, + version, + main: './dist/index.mjs', + }, + files: new Set(['dist/index.mjs', 'dist/index.d.mts']), + }), + ) + + const result = await handler(fakeEvent) + + expect(fetchPackageWithTypesAndFilesMock).toHaveBeenCalledWith('my-pkg', '2.0.0') + expect(fetchPackageWithTypesAndFilesMock).toHaveBeenCalledWith('my-pkg', '3.0.0') + expect(result.versions).toHaveLength(3) + expect(result.versions[0]?.hasTypes).toBe(true) + expect(result.versions[1]?.hasTypes).toBe(true) + expect(result.versions[2]?.hasTypes).toBe(true) + }) + + it('keeps hasTypes unset when declarations are removed', async () => { + routerParam = 'my-pkg' + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { + '1.0.0': { types: './dist/index.d.ts' }, + '2.0.0': { main: './dist/index.mjs' }, }, time: { '1.0.0': '2024-01-01T00:00:00Z', @@ -255,14 +297,14 @@ describe('timeline API', () => { version: '2.0.0', main: './dist/index.mjs', }, - files: new Set(['dist/index.mjs', 'dist/index.d.mts']), + files: new Set(['dist/index.mjs']), }) const result = await handler(fakeEvent) - expect(fetchPackageWithTypesAndFilesMock).toHaveBeenCalledWith('my-pkg', '2.0.0') - expect(result.versions[0]!.hasTypes).toBe(true) - expect(result.versions[1]!.hasTypes).toBe(true) + expect(result.versions).toHaveLength(2) + expect(result.versions[0]?.hasTypes).toBeUndefined() + expect(result.versions[1]?.hasTypes).toBe(true) }) it('sets hasTrustedPublisher when trustedPublisher is true', async () => { From 79dbfdab88644b5b324cf5b6fc6b8a327b224392 Mon Sep 17 00:00:00 2001 From: "Willow (GHOST)" Date: Fri, 31 Jul 2026 18:28:28 +0100 Subject: [PATCH 3/4] chore: fmt --- server/api/registry/timeline/[...pkg].get.ts | 21 ++++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/server/api/registry/timeline/[...pkg].get.ts b/server/api/registry/timeline/[...pkg].get.ts index 4988dbf9c8..e5a5a7b9c4 100644 --- a/server/api/registry/timeline/[...pkg].get.ts +++ b/server/api/registry/timeline/[...pkg].get.ts @@ -82,17 +82,15 @@ export default defineCachedEventHandler( } }) .sort((a, b) => Date.parse(b.time) - Date.parse(a.time)) - const visibleVersions = allVersions.slice(offset, offset + limit) - - const possibleTypeRemovals = visibleVersions.filter(version => { - if (version.hasTypes) return false - const versionIndex = allVersions.indexOf(version) - return allVersions.slice(versionIndex + 1).some(previousVersion => previousVersion.hasTypes) - }) + const visibleVersions = allVersions.slice(offset, offset + limit) - await Promise.all( - possibleTypeRemovals.map(async version => { + const possibleTypeRemovals = visibleVersions + .filter((version, index) => { + if (version.hasTypes) return false + return allVersions.slice(index + 1).some(previousVersion => previousVersion.hasTypes) + }) + .map(async version => { try { const { pkg, typesPackage, files } = await fetchPackageWithTypesAndFiles( packageName, @@ -110,8 +108,9 @@ export default defineCachedEventHandler( } catch { // Preserve the metadata-only result when the file list is unavailable. } - }), - ) + }) + + await Promise.all(possibleTypeRemovals) return { versions: visibleVersions, From 52f815c132fa2bb3b9f2e12d2bd8d0fac4ea2319 Mon Sep 17 00:00:00 2001 From: Anil Loutombam Date: Fri, 31 Jul 2026 23:38:42 +0530 Subject: [PATCH 4/4] chore: explain timeline type analysis rationale --- server/api/registry/timeline/[...pkg].get.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/server/api/registry/timeline/[...pkg].get.ts b/server/api/registry/timeline/[...pkg].get.ts index e5a5a7b9c4..1c4be296cb 100644 --- a/server/api/registry/timeline/[...pkg].get.ts +++ b/server/api/registry/timeline/[...pkg].get.ts @@ -82,13 +82,19 @@ export default defineCachedEventHandler( } }) .sort((a, b) => Date.parse(b.time) - Date.parse(a.time)) - const visibleVersions = allVersions.slice(offset, offset + limit) + // File-aware detection is limited to potential removal events: metadata-untyped + // versions with an older typed release. Checking every untyped version would + // require additional registry and file-tree requests. const possibleTypeRemovals = visibleVersions - .filter((version, index) => { + .filter(version => { if (version.hasTypes) return false - return allVersions.slice(index + 1).some(previousVersion => previousVersion.hasTypes) + + const versionIndex = allVersions.indexOf(version) + return allVersions + .slice(versionIndex + 1) + .some(previousVersion => previousVersion.hasTypes) }) .map(async version => { try {