diff --git a/README.md b/README.md index 96029ed..1b5eac4 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,34 @@ inup [options] -c, --check Exit non-zero if updates exist, without writing (for CI) --apply Non-interactively write upgrades + install (for CI/automation) --target With --apply: how far to bump — minor (default) | patch | latest +--minimum-release-age Only offer versions published at least this many minutes ago --debug Write verbose debug logs ``` +### Release-age cooldown (supply-chain guard) + +Freshly published versions are the ones most likely to be a compromised release nobody has +caught yet. With a cooldown, inup simply doesn't offer versions younger than the window — in +the TUI, in reports, and in `--apply`: + +```bash +inup --minimum-release-age 10080 # ignore versions younger than 7 days +``` + +Or persist it in `.inuprc` (minutes, matching pnpm's setting of the same name), with optional +per-package exemptions: + +```json +{ + "minimumReleaseAge": 10080, + "minimumReleaseAgeExclude": ["@myco/*", "internal-tool"] +} +``` + +Registries that don't expose publish times are unaffected (the policy only acts on positive +evidence). Enabling the cooldown fetches the full registry metadata instead of the abbreviated +format, so the first run is somewhat slower; subsequent runs are cushioned by inup's ETag cache. + ## CI & Scripting Run inup non-interactively to gate builds on outdated or vulnerable dependencies, generate a diff --git a/src/cli.ts b/src/cli.ts index 1aa0400..b0865b5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -33,6 +33,7 @@ export interface CliOptions { check?: boolean apply?: boolean target?: string + minimumReleaseAge?: string } export async function runCli(options: CliOptions): Promise { @@ -57,6 +58,19 @@ export async function runCli(options: CliOptions): Promise { process.exit(1) } + // Validate --minimum-release-age the same way. Undefined means "defer to .inuprc". + let cliMinimumReleaseAge: number | undefined + if (options.minimumReleaseAge !== undefined) { + cliMinimumReleaseAge = Number.parseInt(options.minimumReleaseAge, 10) + if (!Number.isInteger(cliMinimumReleaseAge) || cliMinimumReleaseAge < 0) { + console.error(chalk.red(`Invalid minimum release age: ${options.minimumReleaseAge}`)) + console.error( + chalk.yellow('Expected a non-negative number of minutes, e.g. --minimum-release-age 10080') + ) + process.exit(1) + } + } + // The dirty-tree prompt would hang without a TTY; headless is read-only anyway, so skip it. if (interactive) { const gitState = getGitWorkingTreeState(cwd) @@ -130,6 +144,9 @@ export async function runCli(options: CliOptions): Promise { projectConfig.showOptionalDependencyVulnerabilities ?? false, debug: options.debug || process.env.INUP_DEBUG === '1', saveExact: options.saveExact ?? false, + // CLI wins over .inuprc for the scalar; the exclusion list only comes from config. + minimumReleaseAge: cliMinimumReleaseAge ?? projectConfig.minimumReleaseAge ?? 0, + minimumReleaseAgeExclude: projectConfig.minimumReleaseAgeExclude, // Adaptive concurrency defaults ON; it's an internal/dev toggle with no public // flag. Set INUP_ADAPTIVE=0 to disable (e.g. for A/B perf comparisons). adaptive: process.env.INUP_ADAPTIVE !== '0', @@ -201,6 +218,10 @@ program '--apply', 'non-interactively write upgrades to package.json and run install (honors .inuprc ignore/exclude)' ) + .option( + '--minimum-release-age ', + 'only offer versions published at least this many minutes ago (supply-chain cooldown; also via .inuprc)' + ) .option( '--target ', 'with --apply: how far to bump — minor | patch | latest (default: minor, in-range only)', diff --git a/src/features/upgrade/package-detector.ts b/src/features/upgrade/package-detector.ts index 2a9dbb5..15cd19d 100644 --- a/src/features/upgrade/package-detector.ts +++ b/src/features/upgrade/package-detector.ts @@ -20,7 +20,7 @@ import type { StreamOutdatedPackagesInitialPayload, UpgradeOptions, } from '../../shared/types' -import { findClosestMinorVersion } from '../../shared/versions' +import { filterVersionsByReleaseAge, findClosestMinorVersion } from '../../shared/versions' import { getPerformanceTracker, isPerfLoggingEnabled } from '../debug' interface PreparedDependencies { @@ -37,6 +37,8 @@ export class PackageDetector { private scanDirs: string[] private ignorePackages: string[] private maxDepth: number + private minimumReleaseAge: number + private minimumReleaseAgeExclude: string[] private readonly batchSize = 10 private readonly maxConcurrency = 10 @@ -49,6 +51,8 @@ export class PackageDetector { this.ignorePackages = options?.ignorePackages || [] this.maxDepth = options?.maxDepth ?? 10 this.adaptive = options?.adaptive ?? true + this.minimumReleaseAge = options?.minimumReleaseAge ?? 0 + this.minimumReleaseAgeExclude = options?.minimumReleaseAgeExclude ?? [] this.packageJsonPath = findPackageJson(this.cwd) if (this.packageJsonPath) { this.packageJson = readPackageJson(this.packageJsonPath) @@ -130,6 +134,9 @@ export class PackageDetector { batchSize: this.batchSize, maxConcurrency: this.maxConcurrency, adaptive: this.adaptive, + // Publish times live only in the full packument; fetch it only when the + // release-age policy actually needs them. + fullMetadata: this.minimumReleaseAge > 0, onControlTick: (tick) => performanceTracker.recordControlTick(tick), onPackageTiming: isPerfLoggingEnabled() ? (name, latencyMs) => performanceTracker.recordPackageTiming({ name, latencyMs }) @@ -392,7 +399,10 @@ export class PackageDetector { return this.createFailedPackageInfo(dep) } - const { latestVersion, allVersions } = packageData + const { latestVersion, allVersions, deprecated, enginesNode } = this.applyReleaseAgePolicy( + dep, + packageData + ) const closestMinorVersion = findClosestMinorVersion(dep.version, allVersions) const installedClean = semver.coerce(dep.version)?.version || dep.version @@ -433,8 +443,8 @@ export class PackageDetector { hasRangeUpdate, hasMajorUpdate, allVersions, - deprecated: packageData.deprecated, - enginesNode: packageData.enginesNode, + deprecated, + enginesNode, } } catch (error) { debugLog.error('PackageDetector', `error processing ${dep.name}`, error) @@ -443,6 +453,62 @@ export class PackageDetector { }) } + /** + * Enforce the release-age cooldown (`minimumReleaseAge`, minutes): versions published more + * recently than the window are treated as if they don't exist yet, so neither the TUI nor + * --apply can pick them. Freshly published versions are the most likely to be a compromised + * release nobody has caught yet. + * + * When the true latest is gated away, the health signals tied to it (deprecation, engines) + * are dropped rather than misattributed to the older effective latest. If EVERY version is + * too young (brand-new package), the installed version becomes the effective latest — the + * package simply reports "nothing to upgrade to (yet)". + */ + private applyReleaseAgePolicy( + dep: DependencyEntry, + packageData: PackageVersionData + ): PackageVersionData { + if (this.minimumReleaseAge <= 0) return packageData + if ( + this.minimumReleaseAgeExclude.length > 0 && + isPackageIgnored(dep.name, this.minimumReleaseAgeExclude) + ) { + return packageData + } + + const eligible = filterVersionsByReleaseAge( + packageData.allVersions, + packageData.publishTimes, + this.minimumReleaseAge + ) + if (eligible.length === packageData.allVersions.length) return packageData + + const gatedCount = packageData.allVersions.length - eligible.length + const effectiveLatest = + eligible.slice().sort(semver.rcompare)[0] ?? + (semver.coerce(dep.version)?.version || dep.version) + const latestUnchanged = effectiveLatest === packageData.latestVersion + + const gateKey = `${dep.name}@${dep.version}` + if (!this.loggedReleaseAgeGates.has(gateKey)) { + this.loggedReleaseAgeGates.add(gateKey) + debugLog.info( + 'PackageDetector', + `release-age gate: ${gatedCount} version(s) of ${dep.name} younger than ${this.minimumReleaseAge}min hidden (effective latest: ${effectiveLatest})` + ) + } + + return { + ...packageData, + latestVersion: effectiveLatest, + allVersions: eligible, + deprecated: latestUnchanged ? packageData.deprecated : undefined, + enginesNode: latestUnchanged ? packageData.enginesNode : undefined, + } + } + + private readonly loggedReleaseAgeGates = new Set() + private createFailedPackageInfo(dep: DependencyEntry): PackageInfo { return { name: dep.name, diff --git a/src/shared/config/project-config.ts b/src/shared/config/project-config.ts index 926e867..b00affa 100644 --- a/src/shared/config/project-config.ts +++ b/src/shared/config/project-config.ts @@ -24,6 +24,20 @@ export interface InupProjectConfig { */ scanDirs?: string[] + /** + * Release-age cooldown in MINUTES (matching pnpm's setting of the same name): versions + * published more recently than this are not offered as upgrade targets. A supply-chain + * guard against freshly compromised releases. 0 or absent disables the policy. + * Example: 10080 = 7 days. + */ + minimumReleaseAge?: number + + /** + * Packages exempt from `minimumReleaseAge`. Supports exact names and the same glob + * patterns as `ignore` (e.g. "@myco/*"). + */ + minimumReleaseAgeExclude?: string[] + /** * Show vulnerability badges for peerDependencies in the package list. * Defaults to false so peer dependency risk stays hidden unless explicitly enabled. @@ -100,6 +114,19 @@ function normalizeConfig(config: InupProjectConfig): InupProjectConfig { } } + // JSON.parse can only yield finite numbers, so type + sign checks are sufficient. + if (typeof config.minimumReleaseAge === 'number' && config.minimumReleaseAge >= 0) { + normalized.minimumReleaseAge = config.minimumReleaseAge + } + + if (config.minimumReleaseAgeExclude) { + if (Array.isArray(config.minimumReleaseAgeExclude)) { + normalized.minimumReleaseAgeExclude = config.minimumReleaseAgeExclude.filter( + (item) => typeof item === 'string' + ) + } + } + if (typeof config.showPeerDependencyVulnerabilities === 'boolean') { normalized.showPeerDependencyVulnerabilities = config.showPeerDependencyVulnerabilities } diff --git a/src/shared/registry/npm-registry.ts b/src/shared/registry/npm-registry.ts index eac490e..94a9386 100644 --- a/src/shared/registry/npm-registry.ts +++ b/src/shared/registry/npm-registry.ts @@ -31,6 +31,8 @@ export interface PackageVersionData { allVersions: string[] deprecated?: string // npm deprecation message for the latest version, if any enginesNode?: string // declared engines.node range for the latest version, if any + /** ISO publish time per version; only present when the full packument was fetched. */ + publishTimes?: Record } const inFlightLookups = new InflightMap() @@ -80,10 +82,13 @@ const DEFAULT_FIXED_CONCURRENCY = 10 async function getFreshPackageData( packageName: string, currentVersion: string | undefined, + fullMetadata: boolean, onAttempt?: AttemptObserver ): Promise { - const cacheKey = `${packageName}@${currentVersion ?? ''}` - return inFlightLookups.dedupe(cacheKey, () => fetchPackageFromRegistry(packageName, onAttempt)) + const cacheKey = `${packageName}@${currentVersion ?? ''}${fullMetadata ? '#full' : ''}` + return inFlightLookups.dedupe(cacheKey, () => + fetchPackageFromRegistry(packageName, fullMetadata, onAttempt) + ) } const encodeRegistryPath = (packageName: string, pathPrefix: string): string => { @@ -112,18 +117,23 @@ export type AttemptObserver = (outcome: RegistryAttemptOutcome) => void async function attemptRegistryFetch( target: RegistryTarget, - path: string + path: string, + fullMetadata: boolean ): Promise { const startedAt = Date.now() // Conditional request: if we have a stored ETag for this packument, ask the // registry to validate it. Unchanged → 304 (no body) and we reuse stored data. // This still hits the registry every run, so data is never served stale. // Keys are origin-qualified so two registries can never collide on a path. - const cacheKey = `${target.origin}${path}` + // Full-packument responses parse to richer data (publish times), so they get + // their own cache entry — a 304 must never revive an abbreviated-format body. + const cacheKey = `${target.origin}${path}${fullMetadata ? '#full' : ''}` const cached = readEtag(cacheKey) try { const requestHeaders: Record = { - accept: 'application/vnd.npm.install-v1+json', + // The abbreviated install-v1 format is much smaller but has no `time` field; + // release-age policies need publish times, hence the full packument. + accept: fullMetadata ? 'application/json' : 'application/vnd.npm.install-v1+json', 'accept-encoding': 'gzip, deflate, br', } if (target.authHeader) { @@ -204,11 +214,12 @@ async function attemptRegistryFetch( async function fetchFromRegistryWithRetries( target: RegistryTarget, path: string, + fullMetadata: boolean, onAttempt?: AttemptObserver ): Promise { let lastOutcome: RegistryAttemptOutcome = { kind: 'transient' } for (let attempt = 0; attempt < MAX_REGISTRY_ATTEMPTS; attempt++) { - const outcome = await attemptRegistryFetch(target, path) + const outcome = await attemptRegistryFetch(target, path, fullMetadata) onAttempt?.(outcome) if (outcome.kind === 'success' || outcome.kind === 'not-found') { return outcome @@ -229,13 +240,14 @@ async function fetchFromRegistryWithRetries( async function fetchPackageFromRegistry( packageName: string, + fullMetadata: boolean, onAttempt?: AttemptObserver ): Promise { // Scoped packages may live on a different registry (with credentials) than // unscoped ones — resolved from the npm config chain, memoized per scope. const target = registryTargetFor(packageName) const path = encodeRegistryPath(packageName, target.pathPrefix) - const outcome = await fetchFromRegistryWithRetries(target, path, onAttempt) + const outcome = await fetchFromRegistryWithRetries(target, path, fullMetadata, onAttempt) if (outcome.kind === 'success') { return outcome.data @@ -279,6 +291,11 @@ export async function fetchPackageVersions( onControlTick?: (tick: ControlTick) => void /** Per-package successful round-trip latency, for perf diagnostics. */ onPackageTiming?: (name: string, latencyMs: number) => void + /** + * Fetch the FULL packument instead of the abbreviated install-v1 format. Larger payloads, + * but includes per-version publish times — required by release-age policies. Default: false. + */ + fullMetadata?: boolean } & FetchPackageVersionsOptions = {} ): Promise> { const packageData = new Map() @@ -377,6 +394,7 @@ export async function fetchPackageVersions( const data = await getFreshPackageData( packageName, options.currentVersions?.get(packageName), + options.fullMetadata ?? false, observerFor(packageName) ) packageData.set(packageName, data) diff --git a/src/shared/types/domain.ts b/src/shared/types/domain.ts index a17ab03..3754787 100644 --- a/src/shared/types/domain.ts +++ b/src/shared/types/domain.ts @@ -106,6 +106,8 @@ export interface UpgradeOptions extends VulnerabilityDisplayOptions { debug?: boolean // Write verbose debug log to /tmp/inup-debug-YYYY-MM-DD.log saveExact?: boolean // Write bare versions instead of preserving the range prefix (^/~) adaptive?: boolean // Adaptive registry concurrency (AIMD). Defaults to true. + minimumReleaseAge?: number // Minutes a version must have been public before it's offered. 0/absent disables. + minimumReleaseAgeExclude?: string[] // Package names/patterns exempt from minimumReleaseAge } export interface PackageJson { diff --git a/src/shared/versions.ts b/src/shared/versions.ts index f667971..e387a37 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -25,15 +25,37 @@ export interface ParsedVersions { allVersions: string[] deprecated?: string // npm deprecation message for the latest version, if any enginesNode?: string // declared engines.node range for the latest version, if any + /** + * ISO publish timestamp per version, from the packument's `time` field. Only present when the + * FULL packument was fetched (the abbreviated install-v1 format has no `time`), i.e. when a + * release-age policy is active. Restricted to the versions in `allVersions`. + */ + publishTimes?: Record } export function parseVersions(raw: string): ParsedVersions { - const data = JSON.parse(raw) as { versions?: Record } + const data = JSON.parse(raw) as { + versions?: Record + time?: Record + } const versions = data.versions || {} const allVersions = Object.keys(versions).filter((v) => /^[0-9]+\.[0-9]+\.[0-9]+$/.test(v)) const sortedVersions = allVersions.sort(semver.rcompare) const latestVersion = sortedVersions.length > 0 ? sortedVersions[0] : 'unknown' + // Publish times exist only in the full packument; keep just the entries for versions we track + // (`time` also carries 'created'/'modified' and prerelease keys). + let publishTimes: Record | undefined + if (data.time) { + publishTimes = {} + for (const version of allVersions) { + const publishedAt = data.time[version] + if (typeof publishedAt === 'string') { + publishTimes[version] = publishedAt + } + } + } + // Surface health signals for the latest version straight from the abbreviated // packument we already fetched — no extra request. Both fields are optional. const latestManifest = versions[latestVersion] as @@ -42,7 +64,33 @@ export function parseVersions(raw: string): ParsedVersions { const deprecated = normalizeDeprecatedMessage(latestManifest?.deprecated) const enginesNode = extractEnginesNode(latestManifest?.engines) - return { latestVersion, allVersions, deprecated, enginesNode } + return { latestVersion, allVersions, deprecated, enginesNode, publishTimes } +} + +/** + * Drop versions published more recently than the cooldown window (`minimumReleaseAge`, minutes). + * + * This is a supply-chain guard: freshly published versions are the ones most likely to be a + * compromised release that hasn't been caught yet. Versions without a (parsable) publish + * timestamp are kept — the policy only acts on positive evidence, so registries that don't + * expose `time` degrade to a no-op rather than hiding everything. + */ +export function filterVersionsByReleaseAge( + allVersions: string[], + publishTimes: Record | undefined, + minimumReleaseAgeMinutes: number, + now: number = Date.now() +): string[] { + if (!publishTimes) return allVersions + + const cutoff = now - minimumReleaseAgeMinutes * 60_000 + return allVersions.filter((version) => { + const publishedAt = publishTimes[version] + if (!publishedAt) return true + const timestamp = Date.parse(publishedAt) + if (Number.isNaN(timestamp)) return true + return timestamp <= cutoff + }) } /** diff --git a/test/unit/features/upgrade/package-detector.test.ts b/test/unit/features/upgrade/package-detector.test.ts index 40b5347..15f6780 100644 --- a/test/unit/features/upgrade/package-detector.test.ts +++ b/test/unit/features/upgrade/package-detector.test.ts @@ -787,3 +787,207 @@ describe('PackageDetector edge paths', () => { ) }) }) + +describe('PackageDetector release-age cooldown (minimumReleaseAge)', () => { + const DAY_MS = 24 * 60 * 60_000 + const iso = (msAgo: number) => new Date(Date.now() - msAgo).toISOString() + + const dep = (name: string, version: string, packageJsonPath = '/repo/package.json') => ({ + name, + version, + type: 'dependencies', + packageJsonPath, + }) + + /** Route each package to its own version data through the streaming callback. */ + const fetchWith = (dataByName: Record) => { + mocks.fetchPackageVersions.mockImplementation( + async (packageNames: string[], options: { onBatchReady: (batch: any[]) => void }) => { + options.onBatchReady( + packageNames.map((packageName, itemIndex) => ({ + packageName, + data: dataByName[packageName], + completed: itemIndex + 1, + total: packageNames.length, + batchIndex: 0, + itemIndex, + })) + ) + return new Map(Object.entries(dataByName)) + } + ) + } + + beforeEach(() => { + vi.mocked(debugLog.info).mockClear() + mocks.loadPnpmCatalogs.mockReturnValue(null) + mocks.findPackageJson.mockReturnValue('/repo/package.json') + mocks.readPackageJson.mockReturnValue({ name: 'fixture' }) + mocks.findAllPackageJsonFilesAsync.mockReset() + mocks.findAllPackageJsonFilesAsync.mockResolvedValue(['/repo/package.json']) + mocks.findClosestMinorVersion.mockReset() + mocks.findClosestMinorVersion.mockImplementation( + (version: string, versions: string[]) => versions[0] ?? version + ) + mocks.fetchPackageVersions.mockReset() + }) + + it('hides too-young versions, recomputes the latest, and drops its health signals', async () => { + mocks.collectAllDependenciesAsync.mockResolvedValue([dep('zod', '^1.0.0')]) + fetchWith({ + zod: { + latestVersion: '1.2.0', + allVersions: ['1.2.0', '1.1.0', '1.0.0'], + publishTimes: { + '1.2.0': iso(60 * 60_000), // 1 hour old — inside the 1-day window + '1.1.0': iso(30 * DAY_MS), + '1.0.0': iso(60 * DAY_MS), + }, + deprecated: 'this describes 1.2.0, not the effective latest', + enginesNode: '>=20', + }, + }) + + const detector = new PackageDetector({ + cwd: '/repo', + minimumReleaseAge: 1440, + // Non-matching exclusions must not disable the gate. + minimumReleaseAgeExclude: ['some-other-pkg'], + }) + const packages = await detector.getOutdatedPackages() + + expect(packages).toHaveLength(1) + expect(packages[0]).toMatchObject({ + name: 'zod', + latestVersion: '1.1.0', + rangeVersion: '1.1.0', + allVersions: ['1.1.0', '1.0.0'], + isOutdated: true, + hasMajorUpdate: false, + }) + // Signals described the gated 1.2.0 — they must not be misattributed to 1.1.0. + expect(packages[0].deprecated).toBeUndefined() + expect(packages[0].enginesNode).toBeUndefined() + + // The policy needs publish times, which only the full packument carries. + const fetchOptions = mocks.fetchPackageVersions.mock.calls[0][1] + expect(fetchOptions.fullMetadata).toBe(true) + }) + + it('keeps the health signals when only a non-latest backport is gated', async () => { + mocks.collectAllDependenciesAsync.mockResolvedValue([dep('zod', '^1.0.0')]) + fetchWith({ + zod: { + latestVersion: '2.0.0', + allVersions: ['2.0.0', '1.0.5', '1.0.0'], + publishTimes: { + '2.0.0': iso(30 * DAY_MS), + '1.0.5': iso(60 * 60_000), // young backport + '1.0.0': iso(60 * DAY_MS), + }, + deprecated: 'legit signal for 2.0.0', + enginesNode: '>=18', + }, + }) + + const detector = new PackageDetector({ cwd: '/repo', minimumReleaseAge: 1440 }) + const packages = await detector.getOutdatedPackages() + + expect(packages[0]).toMatchObject({ + latestVersion: '2.0.0', + allVersions: ['2.0.0', '1.0.0'], + deprecated: 'legit signal for 2.0.0', + enginesNode: '>=18', + }) + }) + + it('treats a package whose every version is too young as having nothing to offer', async () => { + mocks.collectAllDependenciesAsync.mockResolvedValue([ + dep('brand-new', '^1.0.0'), + // Uncoercible specifier: the fallback keeps the raw specifier as effective latest. + dep('tagged', 'latest'), + ]) + fetchWith({ + 'brand-new': { + latestVersion: '1.1.0', + allVersions: ['1.1.0'], + publishTimes: { '1.1.0': iso(60_000) }, + }, + tagged: { + latestVersion: '3.0.0', + allVersions: ['3.0.0'], + publishTimes: { '3.0.0': iso(60_000) }, + }, + }) + + const detector = new PackageDetector({ cwd: '/repo', minimumReleaseAge: 1440 }) + const packages = await detector.getOutdatedPackages() + + const byName = Object.fromEntries(packages.map((pkg) => [pkg.name, pkg])) + expect(byName['brand-new']).toMatchObject({ + latestVersion: '1.0.0', + allVersions: [], + isOutdated: false, + hasRangeUpdate: false, + hasMajorUpdate: false, + }) + expect(byName.tagged).toMatchObject({ latestVersion: 'latest', isOutdated: false }) + }) + + it('exempts packages matching minimumReleaseAgeExclude', async () => { + const { isPackageIgnored } = await import('../../../../src/shared/config') + vi.mocked(isPackageIgnored).mockImplementation((name: string) => name === 'zod') + try { + mocks.collectAllDependenciesAsync.mockResolvedValue([dep('zod', '^1.0.0')]) + fetchWith({ + zod: { + latestVersion: '1.2.0', + allVersions: ['1.2.0', '1.0.0'], + publishTimes: { '1.2.0': iso(60_000), '1.0.0': iso(60 * DAY_MS) }, + }, + }) + + const detector = new PackageDetector({ + cwd: '/repo', + minimumReleaseAge: 1440, + minimumReleaseAgeExclude: ['zod'], + }) + const packages = await detector.getOutdatedPackages() + + // Excluded → the young latest stays visible. + expect(packages[0]).toMatchObject({ latestVersion: '1.2.0' }) + } finally { + vi.mocked(isPackageIgnored).mockImplementation(() => false) + } + }) + + it('is a no-op when the registry provides no publish times, and logs each gate once', async () => { + mocks.collectAllDependenciesAsync.mockResolvedValue([ + dep('no-times', '^1.0.0'), + // The same gated package referenced from two manifests logs once. + dep('gated', '^1.0.0', '/repo/packages/a/package.json'), + dep('gated', '^1.0.0', '/repo/packages/b/package.json'), + ]) + fetchWith({ + 'no-times': { latestVersion: '9.9.9', allVersions: ['9.9.9', '1.0.0'] }, + gated: { + latestVersion: '1.2.0', + allVersions: ['1.2.0', '1.0.0'], + publishTimes: { '1.2.0': iso(60_000), '1.0.0': iso(60 * DAY_MS) }, + }, + }) + + const detector = new PackageDetector({ cwd: '/repo', minimumReleaseAge: 1440 }) + const packages = await detector.getOutdatedPackages() + + const byName = new Map(packages.map((pkg) => [pkg.name, pkg])) + // Abbreviated-style data without times is untouched by the policy. + expect(byName.get('no-times')).toMatchObject({ latestVersion: '9.9.9' }) + expect(byName.get('gated')).toMatchObject({ latestVersion: '1.0.0' }) + + const gateLogs = vi + .mocked(debugLog.info) + .mock.calls.filter((call) => String(call[1]).includes('release-age gate')) + expect(gateLogs).toHaveLength(1) + }) +}) diff --git a/test/unit/shared/config/project-config.test.ts b/test/unit/shared/config/project-config.test.ts index ddd5b2c..9d369da 100644 --- a/test/unit/shared/config/project-config.test.ts +++ b/test/unit/shared/config/project-config.test.ts @@ -119,6 +119,43 @@ describe('project-config', () => { const config = loadProjectConfig(testDir) expect(config.showOptionalDependencyVulnerabilities).toBe(true) }) + + it('loads minimumReleaseAge and its exclusion list', () => { + writeFileSync( + join(testDir, '.inuprc'), + JSON.stringify({ + minimumReleaseAge: 10080, + minimumReleaseAgeExclude: ['@myco/*', 42, 'internal-tool', null], + }) + ) + + const config = loadProjectConfig(testDir) + expect(config.minimumReleaseAge).toBe(10080) + // Non-string entries are filtered, like the ignore list. + expect(config.minimumReleaseAgeExclude).toEqual(['@myco/*', 'internal-tool']) + }) + + it('accepts minimumReleaseAge of 0 (explicitly disabled)', () => { + writeFileSync(join(testDir, '.inuprc'), JSON.stringify({ minimumReleaseAge: 0 })) + + expect(loadProjectConfig(testDir).minimumReleaseAge).toBe(0) + }) + + it('drops invalid minimumReleaseAge values', () => { + for (const bad of [-5, '10080', null]) { + writeFileSync(join(testDir, '.inuprc'), `{"minimumReleaseAge": ${JSON.stringify(bad)}}`) + expect(loadProjectConfig(testDir).minimumReleaseAge).toBeUndefined() + } + }) + + it('drops a non-array minimumReleaseAgeExclude', () => { + writeFileSync( + join(testDir, '.inuprc'), + JSON.stringify({ minimumReleaseAgeExclude: 'not-an-array' }) + ) + + expect(loadProjectConfig(testDir).minimumReleaseAgeExclude).toBeUndefined() + }) }) describe('isPackageIgnored()', () => { diff --git a/test/unit/shared/registry/npm-registry.test.ts b/test/unit/shared/registry/npm-registry.test.ts index 0ae01ac..5258c0b 100644 --- a/test/unit/shared/registry/npm-registry.test.ts +++ b/test/unit/shared/registry/npm-registry.test.ts @@ -119,6 +119,40 @@ describe('npm-registry', () => { expect(opts.headers.authorization).toBeUndefined() }) + it('requests the abbreviated packument by default', async () => { + requestMock.mockResolvedValue(makeOkBody({ versions: { '1.0.0': {} } })) + + await fetchPackageVersions(['demo-pkg']) + + const opts = poolRequestSpy.mock.calls[0][0] as { headers: Record } + expect(opts.headers.accept).toBe('application/vnd.npm.install-v1+json') + }) + + it('fullMetadata requests the full packument and surfaces publish times', async () => { + requestMock.mockResolvedValue( + makeOkBody({ + versions: { '1.0.0': {}, '1.1.0': {} }, + time: { + created: '2020-01-01T00:00:00.000Z', + '1.0.0': '2020-01-01T00:00:00.000Z', + '1.1.0': '2024-01-02T00:00:00.000Z', + }, + }) + ) + + const result = await fetchPackageVersions(['demo-pkg'], { fullMetadata: true }) + + const opts = poolRequestSpy.mock.calls[0][0] as { headers: Record } + expect(opts.headers.accept).toBe('application/json') + expect(result.get('demo-pkg')).toMatchObject({ + latestVersion: '1.1.0', + publishTimes: { + '1.0.0': '2020-01-01T00:00:00.000Z', + '1.1.0': '2024-01-02T00:00:00.000Z', + }, + }) + }) + it('routes scoped packages to their npmrc registry with its authorization header', async () => { registryTargetMock.mockReturnValueOnce({ origin: 'https://registry.example.com', diff --git a/test/unit/shared/versions.test.ts b/test/unit/shared/versions.test.ts index 2be5df6..217e70e 100644 --- a/test/unit/shared/versions.test.ts +++ b/test/unit/shared/versions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { applyVersionPrefix, extractMajorVersion, + filterVersionsByReleaseAge, findClosestMinorVersion, getOptimizedRangeVersion, isVersionOutdated, @@ -33,6 +34,74 @@ describe('version utils', () => { expect(result.latestVersion).toBe('1.1.0') expect(result.deprecated).toBeUndefined() expect(result.enginesNode).toBeUndefined() + // The abbreviated packument has no `time` field. + expect(result.publishTimes).toBeUndefined() + }) + + it('extracts publish times for tracked versions from a full packument', () => { + const raw = JSON.stringify({ + versions: { '1.0.0': {}, '1.1.0': {} }, + time: { + created: '2020-01-01T00:00:00.000Z', + modified: '2024-01-02T00:00:00.000Z', + '1.0.0': '2020-01-01T00:00:00.000Z', + '1.1.0': '2024-01-02T00:00:00.000Z', + '2.0.0-beta.1': '2024-06-01T00:00:00.000Z', + }, + }) + + const result = parseVersions(raw) + // Only entries for versions in allVersions — no created/modified/prerelease keys. + expect(result.publishTimes).toEqual({ + '1.0.0': '2020-01-01T00:00:00.000Z', + '1.1.0': '2024-01-02T00:00:00.000Z', + }) + }) + + it('skips versions whose time entry is missing or not a string', () => { + const raw = JSON.stringify({ + versions: { '1.0.0': {}, '1.1.0': {} }, + time: { '1.0.0': 12345 }, + }) + + expect(parseVersions(raw).publishTimes).toEqual({}) + }) + }) + + describe('filterVersionsByReleaseAge()', () => { + const DAY_MS = 24 * 60 * 60_000 + const now = Date.parse('2026-07-06T12:00:00.000Z') + const times = { + '1.0.0': new Date(now - 30 * DAY_MS).toISOString(), + '1.1.0': new Date(now - 2 * DAY_MS).toISOString(), + '1.2.0': new Date(now - 60 * 60_000).toISOString(), // 1 hour old + } + + it('drops versions younger than the cooldown window', () => { + // 7 days in minutes: only the 30-day-old version survives. + expect(filterVersionsByReleaseAge(['1.0.0', '1.1.0', '1.2.0'], times, 7 * 1440, now)).toEqual( + ['1.0.0'] + ) + // 1 day in minutes: the 2-day-old version also survives. + expect(filterVersionsByReleaseAge(['1.0.0', '1.1.0', '1.2.0'], times, 1440, now)).toEqual([ + '1.0.0', + '1.1.0', + ]) + }) + + it('is a no-op without publish times (abbreviated packument / registry without time)', () => { + expect(filterVersionsByReleaseAge(['1.0.0', '1.1.0'], undefined, 1440, now)).toEqual([ + '1.0.0', + '1.1.0', + ]) + }) + + it('keeps versions with missing or unparsable timestamps', () => { + const partial = { '1.1.0': 'not-a-date' } + expect(filterVersionsByReleaseAge(['1.0.0', '1.1.0'], partial, 1440, now)).toEqual([ + '1.0.0', + '1.1.0', + ]) }) })