diff --git a/packages/nx/src/plugins/js/project-graph/affected/lock-file-changes.spec.ts b/packages/nx/src/plugins/js/project-graph/affected/lock-file-changes.spec.ts index b0e58e3ecc4..2a00ca32443 100644 --- a/packages/nx/src/plugins/js/project-graph/affected/lock-file-changes.spec.ts +++ b/packages/nx/src/plugins/js/project-graph/affected/lock-file-changes.spec.ts @@ -326,6 +326,143 @@ some-other-external-package@^4.0.1: tempFs.cleanup(); }); + describe('pnpm snapshot dependency changes', () => { + const lockfile = (dependencies: string) => `lockfileVersion: '9.0' +importers: + .: {} +packages: + host@1.0.0: + resolution: {integrity: sha512-host} + dep@1.0.0: + resolution: {integrity: sha512-one} + dep@2.0.0: + resolution: {integrity: sha512-two} +snapshots: + host@1.0.0: +${dependencies} + dep@1.0.0: {} + dep@2.0.0: {} +`; + + beforeEach(() => { + graph.externalNodes = { + 'npm:host': { + type: 'npm', + name: 'npm:host', + data: { packageName: 'host', version: '1.0.0' }, + }, + 'npm:dep@1.0.0': { + type: 'npm', + name: 'npm:dep@1.0.0', + data: { packageName: 'dep', version: '1.0.0' }, + }, + 'npm:dep@2.0.0': { + type: 'npm', + name: 'npm:dep@2.0.0', + data: { packageName: 'dep', version: '2.0.0' }, + }, + }; + }); + + const touched = (base: string, head: string, file = 'pnpm-lock.yaml') => + getTouchedProjectsFromLockFile( + [{ file, getChanges: () => [new LockFileChange(base, head)] }], + graph.nodes, + autoNxJson, + undefined, + graph + ); + + it.each(['pnpm-lock.yaml', 'pnpm-lock.yml'])( + 'detects redirects between existing versions in %s', + (file) => { + expect( + touched( + lockfile(' dependencies:\n dep: 1.0.0'), + lockfile(' dependencies:\n dep: 2.0.0'), + file + ) + ).toEqual(['npm:host']); + } + ); + + it('detects optional dependency redirects', () => { + expect( + touched( + lockfile(' optionalDependencies:\n dep: 1.0.0'), + lockfile(' optionalDependencies:\n dep: 2.0.0') + ) + ).toEqual(['npm:host']); + }); + + it.each([false, true])( + 'detects dependency additions and removals (reverse: %s)', + (reverse) => { + const before = lockfile(' dependencies: {}'); + const after = lockfile(' dependencies:\n dep: 1.0.0'); + expect( + touched(reverse ? after : before, reverse ? before : after) + ).toEqual(['npm:host']); + } + ); + + it('ignores dependency key ordering', () => { + expect( + touched( + lockfile( + ' dependencies:\n first: dep@1.0.0\n second: dep@2.0.0' + ), + lockfile( + ' dependencies:\n second: dep@2.0.0\n first: dep@1.0.0' + ) + ) + ).toEqual([]); + }); + + it('keeps dependency edges associated with their peer context', () => { + const base = lockfile(' dependencies:\n dep: 1.0.0').replace( + 'snapshots:\n host@1.0.0:\n dependencies:\n dep: 1.0.0', + 'snapshots:\n host@1.0.0(dep@1.0.0):\n dependencies:\n dep: 1.0.0\n host@1.0.0(dep@2.0.0):\n dependencies:\n dep: 2.0.0' + ); + const head = base + .replace(' dep: 1.0.0', ' dep: temporary') + .replace(' dep: 2.0.0', ' dep: 1.0.0') + .replace(' dep: temporary', ' dep: 2.0.0'); + expect(touched(base, head)).toEqual(['npm:host']); + }); + + it('detects dependency redirects in v6 package snapshots', () => { + const base = `lockfileVersion: '6.0' +importers: + .: {} +packages: + /host@1.0.0: + resolution: {integrity: sha512-host} + dependencies: + dep: 1.0.0 + /dep@1.0.0: + resolution: {integrity: sha512-one} + /dep@2.0.0: + resolution: {integrity: sha512-two} +`; + expect( + touched(base, base.replace(' dep: 1.0.0', ' dep: 2.0.0')) + ).toEqual(['npm:host']); + }); + + it('detects redirects alongside an unrelated integrity change', () => { + expect( + touched( + lockfile(' dependencies:\n dep: 1.0.0'), + lockfile(' dependencies:\n dep: 2.0.0').replace( + 'sha512-one', + 'sha512-changed' + ) + ).sort() + ).toEqual(['npm:dep@1.0.0', 'npm:dep@2.0.0', 'npm:host']); + }); + }); + AUTO_CASES.forEach(({ lockFile, base, head }) => { describe(`"${lockFile}"`, () => { it('should not return changes when the lock file is untouched', () => { diff --git a/packages/nx/src/plugins/js/project-graph/affected/lock-file-changes.ts b/packages/nx/src/plugins/js/project-graph/affected/lock-file-changes.ts index b7ad60bdcff..cf9c9222e4f 100644 --- a/packages/nx/src/plugins/js/project-graph/affected/lock-file-changes.ts +++ b/packages/nx/src/plugins/js/project-graph/affected/lock-file-changes.ts @@ -1,3 +1,6 @@ +import { getPnpmLockfileNodes } from '../../lock-file/pnpm-parser'; +import { parseAndNormalizePnpmLockfile } from '../../lock-file/utils/pnpm-normalizer'; +import { sortObjectByKeys } from '../../../../utils/object-sort'; import { TouchedProjectLocator } from '../../../../project-graph/affected/affected-project-graph-models'; import { FileChange, @@ -134,21 +137,15 @@ function getChangedPackageNames( // the iteration keeps the contract open in case multiple ranges are ever // emitted for the same file. for (const change of changes) { - const baseFingerprints = collectPackageFingerprints( - getLockFileNodesForName( - file, - change.baseContent, - hashArray([change.baseContent]), - packageJson - ).nodes + const baseFingerprints = getPackageFingerprints( + file, + change.baseContent, + packageJson ); - const headFingerprints = collectPackageFingerprints( - getLockFileNodesForName( - file, - change.headContent, - hashArray([change.headContent]), - packageJson - ).nodes + const headFingerprints = getPackageFingerprints( + file, + change.headContent, + packageJson ); for (const [name, fingerprints] of headFingerprints) { @@ -173,6 +170,43 @@ function getChangedPackageNames( } } +/** + * pnpm's external node hashes describe package contents, not which dependency + * versions each snapshot resolves to. Include those edges in affected detection + * without changing the external node hashes used elsewhere in Nx. + */ +function getPackageFingerprints( + file: string, + content: string, + packageJson: PackageJson | undefined +): Map> { + const hash = hashArray([content]); + if (file !== 'pnpm-lock.yaml' && file !== 'pnpm-lock.yml') { + return collectPackageFingerprints( + getLockFileNodesForName(file, content, hash, packageJson).nodes + ); + } + + const { nodes, keyMap } = getPnpmLockfileNodes(content, hash); + const fingerprints = collectPackageFingerprints(nodes); + // The normalizer folds v9 snapshots into packages and also supports older + // pnpm lockfiles. The parser's keyMap handles aliases and peer contexts. + const lockfile = parseAndNormalizePnpmLockfile(content); + for (const [key, snapshot] of Object.entries(lockfile.packages ?? {})) { + const fingerprint = JSON.stringify({ + snapshot: key, + dependencies: sortObjectByKeys(snapshot.dependencies ?? {}), + optionalDependencies: sortObjectByKeys( + snapshot.optionalDependencies ?? {} + ), + }); + for (const node of keyMap.get(key) ?? []) { + fingerprints.get(node.data.packageName)?.add(fingerprint); + } + } + return fingerprints; +} + /** * Build a map of packageName -> set of versions present in the * external-node record returned by a lock-file parser. We include both