diff --git a/CHANGELOG.md b/CHANGELOG.md index 628b882..5134852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ ### Fixed +- Deprecation detection now works on pnpm 11 and pnpm 12, not just pnpm 10. + - pnpm 12 removed the `pnpm install --resolution-only` flag Dependicus used to find deprecated packages, so `dependicus update` failed outright against any pnpm 12 workspace and produced no output at all. + - pnpm 11 and pnpm 12 skip re-resolving when the lockfile and `node_modules` already agree, which left every package looking undeprecated. Dependicus now asks pnpm to resolve anyway. + - Deprecation warnings are read from pnpm's machine-readable reporter rather than scraped from console text, and `pnpm why` output is understood in both its old and new shapes, so the deprecated flag and the list of deprecated transitive dependencies are correct on every supported pnpm version. - Fix version numbers without `.` failing to match open tickets, resulting in duplicates ### Removed diff --git a/src/core/constants.ts b/src/core/constants.ts index da9aa75..c0dc7a8 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -5,6 +5,6 @@ export const WORKER_COUNT = 4; // maxBuffer limits for execSync calls. Node defaults to 1MB, which isn't // enough for commands like `pnpm -r list --json` or `pnpm view --json`. export const BUFFER_SIZES = { - SMALL: 10 * 1024 * 1024, // 10MB — bounded output (pnpm list, pnpm install --resolution-only) - LARGE: 50 * 1024 * 1024, // 50MB — registry queries, pnpm why + SMALL: 10 * 1024 * 1024, // 10MB — bounded output (pnpm list) + LARGE: 50 * 1024 * 1024, // 50MB — registry queries, pnpm why, pnpm install ndjson output } as const; diff --git a/src/providers-node/services/DeprecationService.test.ts b/src/providers-node/services/DeprecationService.test.ts new file mode 100644 index 0000000..012d0b5 --- /dev/null +++ b/src/providers-node/services/DeprecationService.test.ts @@ -0,0 +1,342 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { CacheService } from '../../core/index'; + +vi.mock('node:child_process', () => ({ + spawnSync: vi.fn(), + execFile: vi.fn(), +})); + +import { spawnSync } from 'node:child_process'; +import { DeprecationService } from './DeprecationService'; + +const mockSpawnSync = vi.mocked(spawnSync); + +function createMockCacheService(overrides: Partial = {}): CacheService { + return { + isCacheValid: vi.fn().mockResolvedValue(false), + readCache: vi.fn().mockResolvedValue(''), + writeCache: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as CacheService; +} + +function spawnResult({ stdout = '', stderr = '', status = 0 } = {}) { + return { stdout, stderr, status, error: undefined } as unknown as ReturnType; +} + +/** A `pnpm:deprecation` ndjson line as pnpm 10 through 12 emit it. */ +function deprecationEvent(pkgName: string, pkgVersion: string, depth = 0): string { + return JSON.stringify({ + time: 1789169555812, + name: 'pnpm:deprecation', + level: 'debug', + pkgName, + pkgVersion, + pkgId: `${pkgName}@${pkgVersion}`, + prefix: '/repo/packages/app', + deprecated: 'no longer supported', + depth, + }); +} + +const unrelatedEvents = [ + JSON.stringify({ name: 'pnpm:scope', level: 'debug', selected: 2, total: 2 }), + JSON.stringify({ name: 'pnpm:stage', level: 'debug', stage: 'resolution_done' }), +].join('\n'); + +describe('DeprecationService', () => { + let tempDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + tempDir = mkdtempSync(join(tmpdir(), 'deprecation-service-test-')); + writeFileSync(join(tempDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n'); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('getDeprecatedPackages', () => { + it('runs a pnpm install that works on pnpm 10 through 12', async () => { + mockSpawnSync.mockReturnValue(spawnResult()); + const service = new DeprecationService(createMockCacheService(), tempDir); + + await service.getDeprecatedPackages(); + + expect(mockSpawnSync).toHaveBeenCalledWith( + 'pnpm', + [ + 'install', + '--lockfile-only', + '--no-frozen-lockfile', + '--no-prefer-frozen-lockfile', + '--config.optimistic-repeat-install=false', + '--reporter=ndjson', + ], + expect.objectContaining({ cwd: tempDir }), + ); + }); + + it('reads deprecation events from stdout, where pnpm 10 and 11 write them', async () => { + mockSpawnSync.mockReturnValue( + spawnResult({ + stdout: [ + unrelatedEvents, + deprecationEvent('request', '2.88.2'), + deprecationEvent('har-validator', '5.1.5', 1), + ].join('\n'), + }), + ); + const service = new DeprecationService(createMockCacheService(), tempDir); + + const deprecated = await service.getDeprecatedPackages(); + + expect(deprecated).toEqual(new Set(['request@2.88.2', 'har-validator@5.1.5'])); + }); + + it('reads deprecation events from stderr, where pnpm 12 writes them', async () => { + mockSpawnSync.mockReturnValue( + spawnResult({ + stderr: [unrelatedEvents, deprecationEvent('glob', '7.2.3')].join('\n'), + }), + ); + const service = new DeprecationService(createMockCacheService(), tempDir); + + const deprecated = await service.getDeprecatedPackages(); + + expect(deprecated).toEqual(new Set(['glob@7.2.3'])); + }); + + it('handles scoped packages', async () => { + mockSpawnSync.mockReturnValue( + spawnResult({ + stderr: deprecationEvent('@babel/plugin-proposal-optional-chaining', '7.21.0'), + }), + ); + const service = new DeprecationService(createMockCacheService(), tempDir); + + const deprecated = await service.getDeprecatedPackages(); + + expect(deprecated).toEqual( + new Set(['@babel/plugin-proposal-optional-chaining@7.21.0']), + ); + }); + + it('falls back to the pnpm 12 text warnings when no events are present', async () => { + mockSpawnSync.mockReturnValue( + spawnResult({ + stdout: [ + 'Scope: all 2 workspace projects', + 'packages/app | [WARN] deprecated glob@7.2.3', + 'packages/app | [WARN] deprecated @babel/core@6.26.3', + '[WARN] 2 deprecated subdependencies found: inflight@1.0.6, uuid@3.4.0', + 'Done in 378ms using pnpm v12.4.1', + ].join('\n'), + }), + ); + const service = new DeprecationService(createMockCacheService(), tempDir); + + const deprecated = await service.getDeprecatedPackages(); + + expect(deprecated).toEqual( + new Set(['glob@7.2.3', '@babel/core@6.26.3', 'inflight@1.0.6', 'uuid@3.4.0']), + ); + }); + + it('falls back to the pnpm 10 text warnings when no events are present', async () => { + mockSpawnSync.mockReturnValue( + spawnResult({ + stdout: [ + 'services/api | WARN deprecated elevenlabs@1.59.0', + ' WARN 2 deprecated subdependencies found: inflight@1.0.6, uuid@3.4.0', + ].join('\n'), + }), + ); + const service = new DeprecationService(createMockCacheService(), tempDir); + + const deprecated = await service.getDeprecatedPackages(); + + expect(deprecated).toEqual( + new Set(['elevenlabs@1.59.0', 'inflight@1.0.6', 'uuid@3.4.0']), + ); + }); + + it('throws when pnpm exits non-zero', async () => { + mockSpawnSync.mockReturnValue( + spawnResult({ status: 1, stderr: "error: unexpected argument '--nope'" }), + ); + const service = new DeprecationService(createMockCacheService(), tempDir); + + await expect(service.getDeprecatedPackages()).rejects.toThrow('exited with code 1'); + }); + + it('caches the output and reuses it on the next call', async () => { + const cacheService = createMockCacheService(); + mockSpawnSync.mockReturnValue( + spawnResult({ stdout: deprecationEvent('request', '2.88.2') }), + ); + const service = new DeprecationService(cacheService, tempDir); + + await service.getDeprecatedPackages(); + + expect(cacheService.writeCache).toHaveBeenCalledWith( + 'pnpm-install-deprecations', + expect.stringContaining('pnpm:deprecation'), + join(tempDir, 'pnpm-lock.yaml'), + ); + }); + + it('reads from the cache instead of running pnpm when the cache is valid', async () => { + const cacheService = createMockCacheService({ + isCacheValid: vi.fn().mockResolvedValue(true), + readCache: vi.fn().mockResolvedValue(deprecationEvent('glob', '7.2.3')), + }); + const service = new DeprecationService(cacheService, tempDir); + + const deprecated = await service.getDeprecatedPackages(); + + expect(mockSpawnSync).not.toHaveBeenCalled(); + expect(deprecated).toEqual(new Set(['glob@7.2.3'])); + }); + }); + + describe('getDeprecationMap', () => { + // parsePnpmWhyOutput is private, so exercise it through the map, which + // reads `pnpm why` output straight from the cache. + async function mapFromWhyOutput(whyOutput: string): Promise> { + const cacheService = createMockCacheService({ + isCacheValid: vi.fn().mockResolvedValue(true), + readCache: vi.fn(async (key: string) => + key === 'pnpm-install-deprecations' + ? deprecationEvent('har-validator', '5.1.5', 1) + : whyOutput, + ), + } as unknown as Partial); + const service = new DeprecationService(cacheService, tempDir); + return service.getDeprecationMap(); + } + + it('parses the pnpm 10 and 11 project-tree shape', async () => { + const whyOutput = JSON.stringify([ + { name: 'ws-root', version: '1.0.0', path: '/repo', private: true }, + { + name: 'app', + version: '1.0.0', + path: '/repo/packages/app', + dependencies: { + request: { + from: 'request', + version: '2.88.2', + dependencies: { + 'har-validator': { from: 'har-validator', version: '5.1.5' }, + }, + }, + }, + devDependencies: { + jest: { from: 'jest', version: '29.0.0' }, + }, + }, + ]); + + const map = await mapFromWhyOutput(whyOutput); + + expect(map.get('har-validator@5.1.5')).toEqual(['request', 'jest']); + }); + + it('parses the pnpm 12 dependents-tree shape', async () => { + const whyOutput = JSON.stringify([ + { + name: 'har-validator', + version: '5.1.5', + path: '/repo/node_modules/.pnpm/har-validator@5.1.5/node_modules/har-validator', + dependents: [ + { + name: 'request', + version: '2.88.2', + dependents: [ + { name: 'app', version: '1.0.0', depField: 'dependencies' }, + ], + }, + ], + }, + ]); + + const map = await mapFromWhyOutput(whyOutput); + + expect(map.get('har-validator@5.1.5')).toEqual(['request']); + }); + + it('treats a pnpm 12 package a project depends on directly as its own direct dep', async () => { + const whyOutput = JSON.stringify([ + { + name: 'har-validator', + version: '5.1.5', + dependents: [{ name: 'app', version: '1.0.0', depField: 'dependencies' }], + }, + ]); + + const map = await mapFromWhyOutput(whyOutput); + + expect(map.get('har-validator@5.1.5')).toEqual(['har-validator']); + }); + + it('collects every direct dependency in a branching pnpm 12 dependents tree', async () => { + const whyOutput = JSON.stringify([ + { + name: 'har-validator', + version: '5.1.5', + dependents: [ + { + name: 'request', + version: '2.88.2', + dependents: [ + { name: 'app', version: '1.0.0', depField: 'dependencies' }, + ], + }, + { + name: 'deep', + version: '1.0.0', + dependents: [ + { + name: 'tooling', + version: '1.0.0', + dependents: [ + { + name: 'api', + version: '1.0.0', + depField: 'devDependencies', + }, + ], + }, + ], + }, + ], + }, + ]); + + const map = await mapFromWhyOutput(whyOutput); + + expect(map.get('har-validator@5.1.5')).toEqual(['request', 'tooling']); + }); + + it('omits packages whose why output names no direct dependency', async () => { + const whyOutput = JSON.stringify([ + { name: 'har-validator', version: '5.1.5', dependents: [] }, + ]); + + const map = await mapFromWhyOutput(whyOutput); + + expect(map.has('har-validator@5.1.5')).toBe(false); + }); + + it('survives unparseable why output', async () => { + const map = await mapFromWhyOutput('not json'); + + expect(map.size).toBe(0); + }); + }); +}); diff --git a/src/providers-node/services/DeprecationService.ts b/src/providers-node/services/DeprecationService.ts index ab2e821..fbc3f60 100644 --- a/src/providers-node/services/DeprecationService.ts +++ b/src/providers-node/services/DeprecationService.ts @@ -1,4 +1,4 @@ -import { execFile, execSync } from 'node:child_process'; +import { execFile, spawnSync } from 'node:child_process'; import { copyFileSync, existsSync, renameSync } from 'node:fs'; import { join } from 'node:path'; import { promisify } from 'node:util'; @@ -12,6 +12,52 @@ import { const execFileAsync = promisify(execFile); +/** + * Arguments to the pnpm install that surfaces deprecation warnings. pnpm 10, + * 11 and 12 all accept them; pnpm 12 dropped the older `--resolution-only`. + * + * `--lockfile-only` resolves the dependency graph without downloading packages + * or writing `node_modules`. `--no-prefer-frozen-lockfile` and + * `optimistic-repeat-install=false` together stop pnpm from short-circuiting + * when the lockfile and `node_modules` are already in sync, which it otherwise + * does without fetching the registry metadata the warnings come from. + * + * `--reporter=ndjson` turns the warnings into structured `pnpm:deprecation` + * events instead of human-readable text whose format shifts between releases. + */ +const RESOLUTION_ARGS = [ + 'install', + '--lockfile-only', + '--no-frozen-lockfile', + '--no-prefer-frozen-lockfile', + '--config.optimistic-repeat-install=false', + '--reporter=ndjson', +]; + +/** A `pnpm:deprecation` event from pnpm's ndjson reporter. */ +interface PnpmDeprecationEvent { + name?: string; + pkgName?: string; + pkgVersion?: string; + pkgId?: string; +} + +/** + * A node in the dependents tree that pnpm 12's `why --json` returns. Entries + * carrying a `depField` are workspace projects rather than packages. + */ +interface PnpmWhyDependent { + name?: string; + depField?: string; + dependents?: PnpmWhyDependent[]; +} + +/** A top-level entry in `pnpm why --json` output, in either supported shape. */ +interface PnpmWhyEntry extends PnpmWhyDependent { + dependencies?: Record; + devDependencies?: Record; +} + export class DeprecationService { private deprecatedPackages: Set | undefined = undefined; private deprecationMap: Map | undefined = undefined; // package@version -> direct deps @@ -34,14 +80,15 @@ export class DeprecationService { return this.deprecatedPackages; } - const cacheKey = 'pnpm-install-resolution'; + const cacheKey = 'pnpm-install-deprecations'; + const command = `pnpm ${RESOLUTION_ARGS.join(' ')}`; let output: string; if (await this.cacheService.isCacheValid(cacheKey, this.lockfilePath)) { - process.stderr.write('Using cached pnpm install --resolution-only output\n'); + process.stderr.write('Using cached pnpm deprecation output\n'); output = await this.cacheService.readCache(cacheKey); } else { - process.stderr.write('Running: pnpm install --resolution-only --no-frozen-lockfile\n'); + process.stderr.write(`Running: ${command}\n`); // Backup lockfile before modifying const lockfileBackup = `${this.lockfilePath}.bak`; @@ -50,20 +97,28 @@ export class DeprecationService { } try { - output = execSync('pnpm install --resolution-only --no-frozen-lockfile', { + const result = spawnSync('pnpm', RESOLUTION_ARGS, { encoding: 'utf-8', - maxBuffer: BUFFER_SIZES.SMALL, - cwd: join(this.lockfilePath, '..'), // Run in repo root - stdio: ['pipe', 'pipe', 'pipe'], // Capture stderr + // The ndjson reporter emits a line per resolved package, so + // this runs to tens of megabytes on a large monorepo. + maxBuffer: BUFFER_SIZES.LARGE, + cwd: this.repoRoot, }); - await this.cacheService.writeCache(cacheKey, output, this.lockfilePath); + + if (result.error) { + throw result.error; + } + + // pnpm 10 and 11 write the ndjson stream to stdout, pnpm 12 + // writes it to stderr, so read both. + output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + + if (result.status !== 0) { + const failure = (result.stderr || result.stdout || '').trim().slice(-2000); + throw new Error(`${command} exited with code ${result.status}:\n${failure}`); + } } catch (error) { - const err = error as Error & { stderr?: Buffer }; - process.stderr.write( - `Error running pnpm install --resolution-only --no-frozen-lockfile:\n${ - err.stderr?.toString() || err.message - }\n`, - ); + process.stderr.write(`Error running ${command}:\n${(error as Error).message}\n`); throw error; } finally { // Restore lockfile from backup (rename is atomic on the same filesystem) @@ -71,6 +126,10 @@ export class DeprecationService { renameSync(lockfileBackup, this.lockfilePath); } } + + // Cache after restoring the lockfile so the stored hash describes + // the lockfile the next run will see, not the one pnpm just wrote. + await this.cacheService.writeCache(cacheKey, output, this.lockfilePath); } this.deprecatedPackages = this.parseDeprecatedPackages(output); @@ -81,19 +140,67 @@ export class DeprecationService { * Parse pnpm install output to extract deprecated packages. */ private parseDeprecatedPackages(output: string): Set { + const fromEvents = this.parseDeprecationEvents(output); + if (fromEvents.size > 0) { + return fromEvents; + } + // The ndjson reporter can be overridden by pnpm config, so fall back to + // reading the warnings the human-readable reporter prints. + return this.parseDeprecationWarnings(output); + } + + /** + * Extract deprecated packages from `pnpm:deprecation` ndjson reporter events. + */ + private parseDeprecationEvents(output: string): Set { + const deprecated = new Set(); + + for (const line of output.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('{') || !trimmed.includes('pnpm:deprecation')) { + continue; + } + + let event: PnpmDeprecationEvent; + try { + event = JSON.parse(trimmed) as PnpmDeprecationEvent; + } catch { + continue; + } + + if (event.name !== 'pnpm:deprecation') { + continue; + } + if (event.pkgName && event.pkgVersion) { + deprecated.add(`${event.pkgName}@${event.pkgVersion}`); + } else if (event.pkgId) { + deprecated.add(event.pkgId); + } + } + + return deprecated; + } + + /** + * Extract deprecated packages from pnpm's human-readable install warnings. + * pnpm 12 brackets the level ("[WARN]") where earlier versions didn't. + */ + private parseDeprecationWarnings(output: string): Set { const deprecated = new Set(); const lines = output.split('\n'); for (const line of lines) { // Direct deprecated dependencies: "services/api | WARN deprecated elevenlabs@1.59.0" - const directMatch = line.match(/\|\s+WARN\s+deprecated\s+([^@\s]+@[\d.]+[^\s]*)/); + const directMatch = line.match( + /\|\s+\[?WARN\]?\s+deprecated\s+(@?[^@\s]+@[\d.]+[^\s]*)/, + ); if (directMatch && directMatch[1]) { deprecated.add(directMatch[1]); } // Transitive deprecated dependencies: " WARN 56 deprecated subdependencies found: pkg@version, ..." const transitiveMatch = line.match( - /WARN\s+\d+\s+deprecated subdependencies found:\s+(.+)/, + /\[?WARN\]?\s+\d+\s+deprecated subdependencies found:\s+(.+)/, ); if (transitiveMatch && transitiveMatch[1]) { const packages = transitiveMatch[1].split(',').map((p) => p.trim()); @@ -218,19 +325,33 @@ export class DeprecationService { /** * Parse pnpm -r why JSON output to extract direct dependencies. - * The output shows which packages have the queried package in their dependency tree. - * We extract the top-level dependencies from each package that reference the queried package. + * + * pnpm 10 and 11 return one entry per workspace project, each holding a + * dependency tree pruned to the paths that reach the queried package, so + * the top-level keys are the direct dependencies we want. + * + * pnpm 12 returns one entry per matched package with a `dependents` tree + * pointing back up toward the workspace projects, so the direct dependency + * is whichever node a workspace project depends on. */ private parsePnpmWhyOutput(output: string): string[] { try { - const packages = JSON.parse(output); + const entries = JSON.parse(output) as PnpmWhyEntry[]; + if (!Array.isArray(entries)) { + return []; + } const directDeps = new Set(); - for (const pkg of packages) { + for (const entry of entries) { + if (Array.isArray(entry?.dependents)) { + this.collectDirectDependents(entry, directDeps); + continue; + } + // Look at direct dependencies and devDependencies const allDeps = { - ...pkg.dependencies, - ...pkg.devDependencies, + ...entry?.dependencies, + ...entry?.devDependencies, }; // Extract all top-level dependency names @@ -246,6 +367,23 @@ export class DeprecationService { } } + /** + * Walk a pnpm 12 dependents tree, collecting the name of every node that a + * workspace project depends on directly. + */ + private collectDirectDependents(node: PnpmWhyDependent, directDeps: Set): void { + for (const dependent of node.dependents ?? []) { + // A dependent with a `depField` is a workspace project listing this + // node in its manifest, which makes this node a direct dependency. + if (dependent.depField && node.name) { + directDeps.add(node.name); + } + if (dependent.dependents?.length) { + this.collectDirectDependents(dependent, directDeps); + } + } + } + /** * Get deprecated transitive dependencies that a direct dependency brings in. * Excludes deprecated packages that are themselves direct dependencies.