diff --git a/e2e/release/src/preserve-matching-dependency-ranges.test.ts b/e2e/release/src/preserve-matching-dependency-ranges.test.ts index e5f78f28704..7883370e608 100644 --- a/e2e/release/src/preserve-matching-dependency-ranges.test.ts +++ b/e2e/release/src/preserve-matching-dependency-ranges.test.ts @@ -140,7 +140,7 @@ describe('nx release preserve matching dependency ranges', () => { {project-name} 📄 Resolved the current version as 1.0.0 from manifest: {project-name}/package.json {project-name} ❓ Applied explicit semver value "1.1.0", from the given specifier, to get new version 1.1.0 {project-name} ✍️ New version 1.1.0 written to manifest: {project-name}/package.json - {project-name} ✍️ Updated 3 dependencies in manifest: {project-name}/package.json + {project-name} ✍️ Updated 2 dependencies in manifest: {project-name}/package.json NX Running release version for project: {project-name} {project-name} 📄 Resolved the current version as 1.0.0 from manifest: {project-name}/package.json {project-name} ❓ Applied version 1.1.0 directly, because the project is a member of a fixed release group containing {project-name} @@ -320,7 +320,7 @@ describe('nx release preserve matching dependency ranges', () => { {project-name} 📄 Resolved the current version as 1.0.0 from manifest: {project-name}/package.json {project-name} ❓ Applied semver relative bump "minor", from the given specifier, to get new version 1.1.0 {project-name} ✍️ New version 1.1.0 written to manifest: {project-name}/package.json - {project-name} ✍️ Updated 3 dependencies in manifest: {project-name}/package.json + {project-name} ✍️ Updated 2 dependencies in manifest: {project-name}/package.json NX Running release version for project: {project-name} {project-name} 📄 Resolved the current version as 1.0.0 from manifest: {project-name}/package.json {project-name} ❓ Applied version 1.1.0 directly, because the project is a member of a fixed release group containing {project-name} diff --git a/packages/devkit/internal.ts b/packages/devkit/internal.ts index c813f8e72b6..5b5962fcb96 100644 --- a/packages/devkit/internal.ts +++ b/packages/devkit/internal.ts @@ -156,6 +156,8 @@ export { type PackageJson, type PackageJsonDependencySection, type PackageManagerCommands, + type ParsedDependencySpecifier, + type ProjectPackageDependencies, type ProjectRootMappings, RemoteCacheV2, type RunCommandsOptions, @@ -264,6 +266,7 @@ export { normalizeTargetDependencyWithStringProjects, nxVersion, orange, + parseDependencySpecifier, parseExecutor, parseVersionFromPackageManagerField, preventRecursionInGraphConstruction, @@ -283,6 +286,7 @@ export { requireWithTsconfigFallback, resetWorkspaceContext, resolveCommandSyntacticSugar, + resolveWorkspaceDependencyTarget, runNxSync, safeExecFileSync, safeSpawn, diff --git a/packages/eslint-plugin/src/rules/dependency-checks.spec.ts b/packages/eslint-plugin/src/rules/dependency-checks.spec.ts index c5fd48fbb78..74400ef5c3e 100644 --- a/packages/eslint-plugin/src/rules/dependency-checks.spec.ts +++ b/packages/eslint-plugin/src/rules/dependency-checks.spec.ts @@ -3787,6 +3787,283 @@ describe('Dependency checks (eslint)', () => { ); }); }); + + describe('workspace package aliases', () => { + const aliasGraphNodes = (packageDependencies: any) => ({ + liba: { + name: 'liba', + type: 'lib' as const, + data: { + root: 'libs/liba', + metadata: { + js: { + packageName: '@mycompany/liba', + packageDependencies, + }, + }, + targets: { build: {} }, + }, + }, + libb: { + name: 'libb', + type: 'lib' as const, + data: { + root: 'libs/libb', + metadata: { + js: { + packageName: '@mycompany/libb', + packageVersion: '0.0.1', + isInPackageManagerWorkspaces: true, + }, + }, + targets: { build: {} }, + }, + }, + }); + + const aliasFileSys = (packageJson: unknown) => ({ + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './libs/libb/package.json': JSON.stringify( + { name: '@mycompany/libb', version: '0.0.1' }, + null, + 2 + ), + './package.json': JSON.stringify(rootPackageJson, null, 2), + }); + + it('should not report an aliased workspace dependency as missing or obsolete', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + 'any-alias': 'workspace:@mycompany/libb@*', + }, + }; + vol.fromJSON(aliasFileSys(packageJson), '/root'); + + const failures = runRule( + {}, + `/root/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: aliasGraphNodes({ + dependencies: { + 'any-alias': { + rawSpecifier: 'workspace:@mycompany/libb@*', + requestedPackageName: '@mycompany/libb', + }, + }, + }), + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'libb', type: 'static' }], + }, + }, + { + liba: [createFile(`libs/liba/src/main.ts`, ['libb'])], + libb: [createFile(`libs/libb/src/index.ts`)], + } + ); + + expect(failures).toEqual([]); + }); + + it('should not report a satisfying npm alias of a workspace dependency', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + 'any-alias': 'npm:@mycompany/libb@^0.0.1', + }, + }; + vol.fromJSON(aliasFileSys(packageJson), '/root'); + + const failures = runRule( + {}, + `/root/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: aliasGraphNodes({ + dependencies: { + 'any-alias': { + rawSpecifier: 'npm:@mycompany/libb@^0.0.1', + requestedPackageName: '@mycompany/libb', + }, + }, + }), + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'libb', type: 'static' }], + }, + }, + { + liba: [createFile(`libs/liba/src/main.ts`, ['libb'])], + libb: [createFile(`libs/libb/src/index.ts`)], + } + ); + + expect(failures).toEqual([]); + }); + + it('should resolve a key reused across collections to the production entry', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + 'any-alias': 'workspace:@mycompany/libb@*', + }, + devDependencies: { + 'any-alias': 'workspace:@mycompany/other@*', + }, + }; + vol.fromJSON(aliasFileSys(packageJson), '/root'); + + const nodes = { + ...aliasGraphNodes({ + dependencies: { + 'any-alias': { + rawSpecifier: 'workspace:@mycompany/libb@*', + requestedPackageName: '@mycompany/libb', + }, + }, + devDependencies: { + 'any-alias': { + rawSpecifier: 'workspace:@mycompany/other@*', + requestedPackageName: '@mycompany/other', + }, + }, + }), + other: { + name: 'other', + type: 'lib' as const, + data: { + root: 'libs/other', + metadata: { + js: { + packageName: '@mycompany/other', + packageVersion: '1.0.0', + isInPackageManagerWorkspaces: true, + }, + }, + targets: { build: {} }, + }, + }, + }; + + const failures = runRule( + {}, + `/root/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'libb', type: 'static' }], + }, + }, + { + liba: [createFile(`libs/liba/src/main.ts`, ['libb'])], + libb: [createFile(`libs/libb/src/index.ts`)], + } + ); + + expect(failures).toEqual([]); + }); + + it('should not fix an npm alias mismatch to a nested workspace specifier', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + 'any-alias': 'npm:@mycompany/libb@^9.0.0', + }, + }; + const fileSys = { + ...aliasFileSys(packageJson), + './package.json': JSON.stringify( + { + ...rootPackageJson, + dependencies: { + ...rootPackageJson.dependencies, + '@mycompany/libb': 'workspace:*', + }, + }, + null, + 2 + ), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `/root/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: aliasGraphNodes({ + dependencies: { + 'any-alias': { + rawSpecifier: 'npm:@mycompany/libb@^9.0.0', + requestedPackageName: '@mycompany/libb', + }, + }, + }), + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'libb', type: 'static' }], + }, + }, + { + liba: [createFile(`libs/liba/src/main.ts`, ['libb'])], + libb: [createFile(`libs/libb/src/index.ts`)], + } + ); + + expect(failures.length).toEqual(1); + const content = JSON.stringify(packageJson, null, 2); + const result = + content.slice(0, failures[0].fix.range[0]) + + failures[0].fix.text + + content.slice(failures[0].fix.range[1]); + expect(result).toContain('"any-alias": "npm:@mycompany/libb@0.0.1"'); + }); + + it('should still report an aliased entry whose target is not depended on', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '^16.0.0', + 'any-alias': 'workspace:@mycompany/libb@*', + }, + }; + vol.fromJSON(aliasFileSys(packageJson), '/root'); + + const failures = runRule( + {}, + `/root/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: aliasGraphNodes({ + dependencies: { + 'any-alias': { + rawSpecifier: 'workspace:@mycompany/libb@*', + requestedPackageName: '@mycompany/libb', + }, + }, + }), + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [createFile(`libs/liba/src/main.ts`, ['npm:external1'])], + libb: [createFile(`libs/libb/src/index.ts`)], + } + ); + + expect(failures.length).toEqual(1); + expect(failures[0].message).toContain( + 'The "any-alias" package is not used' + ); + }); + }); }); function createFile(f: string, deps?: FileDataDependency[]): FileData { diff --git a/packages/eslint-plugin/src/rules/dependency-checks.ts b/packages/eslint-plugin/src/rules/dependency-checks.ts index d96fe0e4d08..2e565ed8371 100644 --- a/packages/eslint-plugin/src/rules/dependency-checks.ts +++ b/packages/eslint-plugin/src/rules/dependency-checks.ts @@ -1,5 +1,8 @@ import { NX_VERSION, normalizePath, workspaceRoot } from '@nx/devkit'; -import { getCatalogManager } from '@nx/devkit/internal'; +import { + getCatalogManager, + parseDependencySpecifier, +} from '@nx/devkit/internal'; import { findNpmDependencies } from '@nx/js/internal'; import { ESLintUtils } from '@typescript-eslint/utils'; import { AST } from 'jsonc-eslint-parser'; @@ -168,7 +171,61 @@ export default ESLintUtils.RuleCreator( runtimeHelpers, } ); - const expectedDependencyNames = Object.keys(npmDependencies); + let expectedDependencyNames = Object.keys(npmDependencies); + + const packageJson = JSON.parse(context.sourceCode.getText()); + const projPackageJsonDeps = getProductionDependencies(packageJson); + + // findNpmDependencies uses canonical names; remap to manifest alias keys to + // avoid duplicate missing/obsolete reports. + const packageNameByAliasKey = new Map(); + const sourcePackageDependencies = + sourceProject.data.metadata?.js?.packageDependencies; + if (sourcePackageDependencies) { + // Apply manifest precedence: dependencies override duplicate keys in + // other sections. + for (const collection of [ + 'optionalDependencies', + 'peerDependencies', + 'devDependencies', + 'dependencies', + ] as const) { + for (const [key, entry] of Object.entries( + sourcePackageDependencies[collection] ?? {} + )) { + if (key !== entry.requestedPackageName) { + packageNameByAliasKey.set(key, entry.requestedPackageName); + } + } + } + const aliasKeysByPackageName = new Map(); + for (const [key, packageName] of packageNameByAliasKey) { + const keys = aliasKeysByPackageName.get(packageName) ?? []; + keys.push(key); + aliasKeysByPackageName.set(packageName, keys); + } + for (const [packageName, aliasKeys] of aliasKeysByPackageName) { + if (!(packageName in npmDependencies)) { + continue; + } + const presentAliasKeys = aliasKeys.filter( + (key) => projPackageJsonDeps[key] + ); + if (!presentAliasKeys.length) { + continue; + } + expectedDependencyNames.push(...presentAliasKeys); + if (!projPackageJsonDeps[packageName]) { + expectedDependencyNames = expectedDependencyNames.filter( + (d) => d !== packageName + ); + } + } + } + + const getInstalledVersion = (packageName: string): string | undefined => + npmDependencies[packageName] ?? + npmDependencies[packageNameByAliasKey.get(packageName)]; // Packages eligible for `workspace:*` rewrites under // `peerDepsVersionStrategy: 'workspace'`. Must be both a workspace project @@ -182,9 +239,6 @@ export default ESLintUtils.RuleCreator( } } - const packageJson = JSON.parse(context.sourceCode.getText()); - const projPackageJsonDeps = getProductionDependencies(packageJson); - const rootPackageJsonDeps = getAllDependencies(rootPackageJson); const catalogManager = getCatalogManager(workspaceRoot); @@ -394,31 +448,57 @@ export default ESLintUtils.RuleCreator( resolvedPackageRange = resolved; } + const installedVersion = getInstalledVersion(packageName); + if (installedVersion === undefined) { + return; + } + + const parsedRange = parseDependencySpecifier(resolvedPackageRange); + const comparableRange = + parsedRange.protocol === 'npm' + ? (parsedRange.range ?? '*') + : resolvedPackageRange; + if ( - npmDependencies[packageName].startsWith('file:') || - resolvedPackageRange.startsWith('file:') || - npmDependencies[packageName] === '*' || - resolvedPackageRange === '*' || + installedVersion.startsWith('file:') || + comparableRange.startsWith('file:') || + installedVersion === '*' || + comparableRange === '*' || resolvedPackageRange.startsWith('workspace:') || - satisfies(npmDependencies[packageName], resolvedPackageRange, { + satisfies(installedVersion, comparableRange, { includePrerelease: true, }) ) { return; } + const canonicalPackageName = packageNameByAliasKey.get(packageName); + let fixedVersion = + rootPackageJsonDeps[canonicalPackageName ?? packageName] || + installedVersion; + if ( + canonicalPackageName && + parseDependencySpecifier(fixedVersion).protocol !== 'plain' + ) { + // npm aliases require a plain registry range; fall back when the root + // uses another protocol. + fixedVersion = installedVersion; + } + context.report({ node: node as any, messageId: 'versionMismatch', data: { packageName: packageName, - version: npmDependencies[packageName], + version: installedVersion, }, fix: (fixer) => fixer.replaceText( node as any, `"${packageName}": "${ - rootPackageJsonDeps[packageName] || npmDependencies[packageName] + canonicalPackageName + ? `npm:${canonicalPackageName}@${fixedVersion}` + : fixedVersion }"` ), }); diff --git a/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.spec.ts b/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.spec.ts index e740985431d..9f71bcf999a 100644 --- a/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.spec.ts +++ b/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.spec.ts @@ -766,4 +766,99 @@ describe('copyWorkspaceModules', () => { ) ).toBe(true); }); + describe('workspace package aliases', () => { + function mockAliasWorkspace() { + tempFs.createFilesSync({ + 'libs/lib-a/package.json': JSON.stringify({ + name: '@myorg/lib-a', + version: '1.0.0', + dependencies: { 'custom-inner': 'workspace:@myorg/lib-b@*' }, + }), + 'libs/lib-b/package.json': JSON.stringify({ + name: '@myorg/lib-b', + version: '1.0.0', + }), + }); + mockGetWorkspacePackages.mockReturnValue( + new Map([ + [ + '@myorg/lib-a', + { + name: '@myorg/lib-a', + type: 'lib', + data: { + root: 'libs/lib-a', + metadata: { + js: { packageName: '@myorg/lib-a', packageVersion: '1.0.0' }, + }, + }, + } as any, + ], + [ + '@myorg/lib-b', + { + name: '@myorg/lib-b', + type: 'lib', + data: { + root: 'libs/lib-b', + metadata: { + js: { packageName: '@myorg/lib-b', packageVersion: '1.0.0' }, + }, + }, + } as any, + ], + ]) + ); + } + + async function runWithDependencies(dependencies: Record) { + mockAliasWorkspace(); + tempFs.createFilesSync({ + [`${PROJECT_ROOT}/package.json`]: JSON.stringify({ + name: 'app', + version: '0.0.1', + dependencies, + }), + }); + tempFs.createDirSync('dist/app'); + await runExecutor(); + } + + function modulePath(...segments: string[]) { + return join( + tempFs.tempDir, + 'dist', + 'app', + 'workspace_modules', + ...segments + ); + } + + it('copies a workspace dependency referenced through a workspace alias', async () => { + await runWithDependencies({ 'custom-lib': 'workspace:@myorg/lib-a@*' }); + + expect(existsSync(modulePath('@myorg/lib-a', 'package.json'))).toBe(true); + }); + + it('copies a workspace dependency referenced through an npm alias', async () => { + await runWithDependencies({ 'custom-lib': 'npm:@myorg/lib-a@1.0.0' }); + + expect(existsSync(modulePath('@myorg/lib-a', 'package.json'))).toBe(true); + }); + + it('rewrites aliased nested workspace dependencies to the target module dir and copies the target', async () => { + await runWithDependencies({ '@myorg/lib-a': 'workspace:*' }); + + expect(existsSync(modulePath('@myorg/lib-b', 'package.json'))).toBe(true); + expect(readCopiedManifest('@myorg/lib-a').dependencies).toEqual({ + 'custom-inner': 'file:../lib-b', + }); + }); + + it('does not copy anything for an npm alias to a registry package', async () => { + await runWithDependencies({ 'custom-lib': 'npm:lodash@^4.17.21' }); + + expect(existsSync(modulePath('@myorg'))).toBe(false); + }); + }); }); diff --git a/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.ts b/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.ts index 0f8fee64fa0..37f492b2e25 100644 --- a/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.ts +++ b/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.ts @@ -14,6 +14,7 @@ import { interpolate, movePeerDependencyToDependencies, relocatePrunedLocalPathSpec, + resolveWorkspaceDependencyTarget, warnUnshippableLocalPathSpec, } from '@nx/devkit/internal'; import { type CopyWorkspaceModulesOptions } from './schema'; @@ -193,17 +194,22 @@ function handleWorkspaceModules( catalogManager ); - // Rewrite sibling workspace-module deps to file: paths and recurse. A peer - // is moved into dependencies (pnpm rejects a file: spec under - // peerDependencies), dropping its now-orphaned optional/required marker. + // Preserve alias keys while targeting resolved module dirs. Move file: + // peers to dependencies, removing orphaned peer metadata, because pnpm + // rejects them under peerDependencies. for (const section of TRANSITIVE_INSTALL_SECTIONS) { const deps = copiedPackageJson[section]; if (!deps) { continue; } for (const depName of Object.keys(deps)) { - if (workspaceModules.has(depName)) { - const fileSpec = `file:${calculateRelativePath(pkgName, depName)}`; + const target = resolveWorkspaceDependencyTarget( + depName, + deps[depName], + workspaceModules + ); + if (target) { + const fileSpec = `file:${calculateRelativePath(pkgName, target)}`; if (section === 'peerDependencies') { movePeerDependencyToDependencies( copiedPackageJson, @@ -214,7 +220,7 @@ function handleWorkspaceModules( deps[depName] = fileSpec; } packageJsonModified = true; - processModule(depName); + processModule(target); continue; } // A non-workspace file:/link: dep must keep resolving after the module @@ -265,17 +271,22 @@ function handleWorkspaceModules( } } - // Seed from every section the app declares a workspace module in. Copied - // modules recurse over production sections only (see processModule). - // processModule dedups via processedModules, so a module listed in several - // sections is copied once. + // Seed app dependencies from every install section. Recurse through copied + // modules' production sections, deduplicating resolved targets. for (const section of WORKSPACE_MODULE_INSTALL_SECTIONS) { const deps = packageJson[section]; if (!deps) { continue; } - for (const pkgName of Object.keys(deps)) { - processModule(pkgName); + for (const [pkgName, pkgVersion] of Object.entries(deps)) { + const target = resolveWorkspaceDependencyTarget( + pkgName, + pkgVersion, + workspaceModules + ); + if (target) { + processModule(target); + } } } } diff --git a/packages/js/src/executors/prune-lockfile/prune-lockfile.spec.ts b/packages/js/src/executors/prune-lockfile/prune-lockfile.spec.ts index d6e8070df80..81e30b5df95 100644 --- a/packages/js/src/executors/prune-lockfile/prune-lockfile.spec.ts +++ b/packages/js/src/executors/prune-lockfile/prune-lockfile.spec.ts @@ -1,6 +1,6 @@ import { type ExecutorContext } from '@nx/devkit'; import { TempFs } from '@nx/devkit/internal-testing-utils'; -import { readFileSync } from 'fs'; +import { readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; import { generatePrunedDeployOutput, @@ -505,6 +505,181 @@ describe('pruneLockfileExecutor - workspace module dependencies', () => { }); }); +describe('pruneLockfileExecutor - workspace module manifest rewrite', () => { + const mockGetWorkspacePackagesFromGraph = + require('nx/src/plugins/js/utils/get-workspace-packages-from-graph') + .getWorkspacePackagesFromGraph as jest.Mock; + + let tempFs: TempFs; + + beforeEach(() => { + tempFs = new TempFs('prune-lockfile-aliases'); + mockWorkspaceRoot = tempFs.tempDir; + mockGetWorkspacePackagesFromGraph.mockReturnValue( + new Map([ + [ + '@myorg/lib-a', + { + name: '@myorg/lib-a', + type: 'lib', + data: { + root: 'libs/lib-a', + metadata: { + js: { packageName: '@myorg/lib-a', packageVersion: '1.0.0' }, + }, + }, + }, + ], + ]) + ); + }); + + afterEach(() => { + tempFs.cleanup(); + jest.clearAllMocks(); + mockGetWorkspacePackagesFromGraph.mockReturnValue(new Map()); + }); + + async function runWithDependencies(dependencies: Record) { + tempFs.createFilesSync({ + 'package.json': JSON.stringify({ name: 'root', version: '0.0.0' }), + 'package-lock.json': JSON.stringify({ name: 'root', lockfileVersion: 3 }), + [`${PROJECT_ROOT}/package.json`]: JSON.stringify({ + name: 'app', + version: '0.0.1', + dependencies, + }), + }); + tempFs.createDirSync('dist/app'); + + await pruneLockfileExecutor( + { + buildTarget: 'app:build', + outputPath: join(tempFs.tempDir, 'dist/app'), + }, + { + root: tempFs.tempDir, + cwd: tempFs.tempDir, + isVerbose: false, + projectGraph: { + nodes: { + app: { name: 'app', type: 'app', data: { root: PROJECT_ROOT } }, + '@myorg/lib-a': { + name: '@myorg/lib-a', + type: 'lib', + data: { + root: 'libs/lib-a', + metadata: { + js: { packageName: '@myorg/lib-a', packageVersion: '1.0.0' }, + }, + }, + }, + }, + dependencies: {}, + externalNodes: {}, + }, + } as unknown as ExecutorContext + ); + + return JSON.parse( + readFileSync(join(tempFs.tempDir, 'dist', 'app', 'package.json'), 'utf-8') + ).dependencies; + } + + it('rewrites a workspace alias entry to the target module dir under the alias key', async () => { + const dependencies = await runWithDependencies({ + 'custom-lib': 'workspace:@myorg/lib-a@*', + }); + + expect(dependencies).toEqual({ + 'custom-lib': 'file:./workspace_modules/@myorg/lib-a', + }); + }); + + it('rewrites an npm alias entry targeting a workspace package to the target module dir', async () => { + const dependencies = await runWithDependencies({ + 'custom-lib': 'npm:@myorg/lib-a@1.0.0', + }); + + expect(dependencies).toEqual({ + 'custom-lib': 'file:./workspace_modules/@myorg/lib-a', + }); + }); + + it('rewrites a plain workspace dependency to its own module dir', async () => { + const dependencies = await runWithDependencies({ + '@myorg/lib-a': 'workspace:*', + }); + + expect(dependencies).toEqual({ + '@myorg/lib-a': 'file:./workspace_modules/@myorg/lib-a', + }); + }); + + it('leaves an npm alias to a registry package untouched', async () => { + const dependencies = await runWithDependencies({ + 'custom-lib': 'npm:lodash@^4.17.21', + }); + + expect(dependencies).toEqual({ + 'custom-lib': 'npm:lodash@^4.17.21', + }); + }); + + it('rewrites an alias even after the real npm stringifier processed the manifest', async () => { + const rootLockFile = { + name: 'root', + version: '0.0.0', + lockfileVersion: 3, + packages: { + '': { name: 'root', version: '0.0.0' }, + 'libs/lib-a': { name: '@myorg/lib-a', version: '1.0.0' }, + 'node_modules/@myorg/lib-a': { resolved: 'libs/lib-a', link: true }, + }, + }; + const { pruneProjectGraph } = jest.requireActual< + typeof import('nx/src/plugins/js/lock-file/project-graph-pruning') + >('nx/src/plugins/js/lock-file/project-graph-pruning'); + const { stringifyNpmLockfile } = jest.requireActual< + typeof import('nx/src/plugins/js/lock-file/npm-parser') + >('nx/src/plugins/js/lock-file/npm-parser'); + // Compose the real prune/stringify path with an inline root lockfile; + // stringification must preserve aliases for the executor's later rewrite. + ( + jest.requireMock('nx/src/plugins/js/lock-file/lock-file') + .generatePrunedDeployOutput as jest.Mock + ).mockImplementationOnce((pkgJson, graph, _projectRoot, opts) => { + writeFileSync( + join(opts.outputDirectory, 'package-lock.json'), + stringifyNpmLockfile( + pruneProjectGraph(graph, pkgJson), + JSON.stringify(rootLockFile), + pkgJson + ) + ); + }); + + const dependencies = await runWithDependencies({ + 'custom-lib': 'npm:@myorg/lib-a@1.0.0', + }); + + expect(dependencies).toEqual({ + 'custom-lib': 'file:./workspace_modules/@myorg/lib-a', + }); + const lockfile = JSON.parse( + readFileSync( + join(tempFs.tempDir, 'dist', 'app', 'package-lock.json'), + 'utf-8' + ) + ); + expect(lockfile.packages['node_modules/custom-lib']).toEqual({ + version: 'file:./workspace_modules/@myorg/lib-a', + resolved: 'workspace_modules/@myorg/lib-a', + link: true, + }); + }); +}); + describe('resolveCatalogReferences', () => { const mockGetCatalogManager = getCatalogManager as jest.MockedFunction< typeof getCatalogManager diff --git a/packages/js/src/executors/prune-lockfile/prune-lockfile.ts b/packages/js/src/executors/prune-lockfile/prune-lockfile.ts index f8bc47ce3eb..e48e247830a 100644 --- a/packages/js/src/executors/prune-lockfile/prune-lockfile.ts +++ b/packages/js/src/executors/prune-lockfile/prune-lockfile.ts @@ -18,6 +18,7 @@ import { movePeerDependencyToDependencies, type PackageJson, type PackageJsonDependencySection, + resolveWorkspaceDependencyTarget, } from '@nx/devkit/internal'; import { type PruneLockfileOptions } from './schema'; import { stripGlobToBaseDir } from '../../utils/strip-glob-to-base-dir'; @@ -71,11 +72,16 @@ function rewriteWorkspaceModuleSpecifiers( if (!deps) { continue; } - for (const pkgName of Object.keys(deps)) { - if (!workspacePackages.has(pkgName)) { + for (const [pkgName, pkgVersion] of Object.entries(deps)) { + const target = resolveWorkspaceDependencyTarget( + pkgName, + pkgVersion, + workspacePackages + ); + if (!target) { continue; } - const fileSpec = `file:./workspace_modules/${pkgName}`; + const fileSpec = `file:./workspace_modules/${target}`; if (section === 'peerDependencies') { movePeerDependencyToDependencies(packageJson, pkgName, fileSpec); } else { diff --git a/packages/js/src/release/version-actions.spec.ts b/packages/js/src/release/version-actions.spec.ts index 75c3d8d7d67..769308bc233 100644 --- a/packages/js/src/release/version-actions.spec.ts +++ b/packages/js/src/release/version-actions.spec.ts @@ -462,6 +462,592 @@ describe('JsVersionActions', () => { ); }); }); + + describe('workspace and npm package aliases', () => { + it('reads the inner range of a workspace alias entry', async () => { + const tree = createTreeWithEmptyWorkspace(); + tree.write( + 'packages/my-lib/package.json', + `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "workspace:dependency@^1.0.0" + } +} +` + ); + const versionActions = await createVersionActions(tree); + + const result = await versionActions.readCurrentVersionOfDependency( + tree, + createProjectGraph(), + 'dependency' + ); + + expect(result).toEqual({ + currentVersion: '^1.0.0', + dependencyCollection: 'dependencies', + }); + }); + + it('reads the inner range of an npm alias entry', async () => { + const tree = createTreeWithEmptyWorkspace(); + tree.write( + 'packages/my-lib/package.json', + `{ + "name": "my-lib", + "version": "1.0.0", + "devDependencies": { + "my-alias": "npm:dependency@~1.2.0" + } +} +` + ); + const versionActions = await createVersionActions(tree); + + const result = await versionActions.readCurrentVersionOfDependency( + tree, + createProjectGraph(), + 'dependency' + ); + + expect(result).toEqual({ + currentVersion: '~1.2.0', + dependencyCollection: 'devDependencies', + }); + }); + + it('prefers the entry keyed by the package name over aliased entries', async () => { + const tree = createTreeWithEmptyWorkspace(); + tree.write( + 'packages/my-lib/package.json', + `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "npm:dependency@~1.0.0", + "dependency": "^1.0.0" + } +} +` + ); + const versionActions = await createVersionActions(tree); + + const result = await versionActions.readCurrentVersionOfDependency( + tree, + createProjectGraph(), + 'dependency' + ); + + expect(result).toEqual({ + currentVersion: '^1.0.0', + dependencyCollection: 'dependencies', + }); + }); + + it('does not read an entry keyed by the package name that aliases another package', async () => { + const tree = createTreeWithEmptyWorkspace(); + tree.write( + 'packages/my-lib/package.json', + `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "dependency": "workspace:other-package@*" + }, + "devDependencies": { + "my-alias": "workspace:dependency@^1.0.0" + } +} +` + ); + const versionActions = await createVersionActions(tree); + + const result = await versionActions.readCurrentVersionOfDependency( + tree, + createProjectGraph(), + 'dependency' + ); + + expect(result).toEqual({ + currentVersion: '^1.0.0', + dependencyCollection: 'devDependencies', + }); + }); + + it('does not read an aliased entry without an inner range', async () => { + const tree = createTreeWithEmptyWorkspace(); + tree.write( + 'packages/my-lib/package.json', + `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "npm:dependency" + } +} +` + ); + const versionActions = await createVersionActions(tree); + + const result = await versionActions.readCurrentVersionOfDependency( + tree, + createProjectGraph(), + 'dependency' + ); + + expect(result).toEqual({ + currentVersion: null, + dependencyCollection: null, + }); + }); + + it('rewrites a workspace alias to a registry-compatible npm alias when local protocols are not preserved', async () => { + const tree = createTreeWithEmptyWorkspace(); + tree.write( + 'packages/my-lib/package.json', + `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "workspace:dependency@^1.0.0" + } +} +` + ); + const versionActions = await createVersionActions(tree); + + await versionActions.updateProjectDependencies( + tree, + createProjectGraph(), + { dependency: '2.0.0' } + ); + + expect(tree.read('packages/my-lib/package.json', 'utf-8')).toBe(`{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "npm:dependency@^2.0.0" + } +} +`); + }); + + it('preserves a workspace alias when local protocols are preserved', async () => { + const tree = createTreeWithEmptyWorkspace(); + const manifest = `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "workspace:dependency@^1.0.0" + } +} +`; + tree.write('packages/my-lib/package.json', manifest); + const versionActions = await createVersionActions(tree, { + preserveLocalDependencyProtocols: true, + }); + + await versionActions.updateProjectDependencies( + tree, + createProjectGraph(), + { dependency: '2.0.0' } + ); + + expect(tree.read('packages/my-lib/package.json', 'utf-8')).toBe(manifest); + }); + + it('resolves an aliased dependency outside the release set to a registry-compatible npm alias', async () => { + const tree = createTreeWithEmptyWorkspace(); + tree.write( + 'packages/my-lib/package.json', + `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "workspace:dependency@^", + "dependency": "workspace:~" + } +} +` + ); + const versionActions = await createVersionActions(tree); + const resolveCurrentVersion = jest.fn().mockResolvedValue('2.5.0'); + + const logs = await versionActions.updateProjectDependencies( + tree, + createProjectGraph(), + {}, + resolveCurrentVersion + ); + + expect(tree.read('packages/my-lib/package.json', 'utf-8')).toBe(`{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "npm:dependency@^2.5.0", + "dependency": "~2.5.0" + } +} +`); + expect(resolveCurrentVersion).toHaveBeenCalledTimes(1); + expect(resolveCurrentVersion).toHaveBeenCalledWith('dependency'); + expect(logs).toEqual([ + '✍️ Updated 1 dependency in manifest: packages/my-lib/package.json', + ]); + }); + + it('updates the inner range of an npm alias entry', async () => { + const tree = createTreeWithEmptyWorkspace(); + tree.write( + 'packages/my-lib/package.json', + `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "npm:dependency@~1.0.0" + } +} +` + ); + const versionActions = await createVersionActions(tree); + + await versionActions.updateProjectDependencies( + tree, + createProjectGraph(), + { dependency: '^2.0.0' } + ); + + expect(tree.read('packages/my-lib/package.json', 'utf-8')).toBe(`{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "npm:dependency@~2.0.0" + } +} +`); + }); + + it('updates plain and aliased entries referencing the same package', async () => { + const tree = createTreeWithEmptyWorkspace(); + tree.write( + 'packages/my-lib/package.json', + `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "dependency": "^1.0.0" + }, + "devDependencies": { + "my-alias": "npm:dependency@^1.0.0" + } +} +` + ); + const versionActions = await createVersionActions(tree); + + await versionActions.updateProjectDependencies( + tree, + createProjectGraph(), + { dependency: '^2.0.0' } + ); + + expect(tree.read('packages/my-lib/package.json', 'utf-8')).toBe(`{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "dependency": "^2.0.0" + }, + "devDependencies": { + "my-alias": "npm:dependency@^2.0.0" + } +} +`); + }); + + it('does not rewrite an entry keyed by the package name that aliases another package', async () => { + const tree = createTreeWithEmptyWorkspace(); + const manifest = `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "dependency": "workspace:other-package@*" + } +} +`; + tree.write('packages/my-lib/package.json', manifest); + const versionActions = await createVersionActions(tree); + + await versionActions.updateProjectDependencies( + tree, + createProjectGraph(), + { dependency: '2.0.0' } + ); + + expect(tree.read('packages/my-lib/package.json', 'utf-8')).toBe(manifest); + }); + + it('does not rewrite an aliased entry without an inner range', async () => { + const tree = createTreeWithEmptyWorkspace(); + tree.write( + 'packages/my-lib/package.json', + `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "dependency": "1.0.0", + "my-alias": "npm:dependency" + } +} +` + ); + const versionActions = await createVersionActions(tree); + + await versionActions.updateProjectDependencies( + tree, + createProjectGraph(), + { dependency: '2.0.0' } + ); + + expect(tree.read('packages/my-lib/package.json', 'utf-8')).toBe(`{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "dependency": "2.0.0", + "my-alias": "npm:dependency" + } +} +`); + }); + + it('preserves an npm alias whose inner range already contains the new version', async () => { + const tree = createTreeWithEmptyWorkspace(); + const manifest = `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "npm:dependency@^1.0.0" + } +} +`; + tree.write('packages/my-lib/package.json', manifest); + const versionActions = await createVersionActions(tree, { + preserveMatchingDependencyRanges: true, + }); + + const logs = await versionActions.updateProjectDependencies( + tree, + createProjectGraph(), + { dependency: '1.1.0' } + ); + + expect(tree.read('packages/my-lib/package.json', 'utf-8')).toBe(manifest); + expect(logs).toEqual([]); + }); + + it('does not log an update when no entry was written', async () => { + const tree = createTreeWithEmptyWorkspace(); + const manifest = `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "npm:dependency", + "unrelated": "^1.0.0" + } +} +`; + tree.write('packages/my-lib/package.json', manifest); + const versionActions = await createVersionActions(tree); + + const logs = await versionActions.updateProjectDependencies( + tree, + createProjectGraph(), + { dependency: '2.0.0', 'other-dependency': '3.0.0' } + ); + + expect(tree.read('packages/my-lib/package.json', 'utf-8')).toBe(manifest); + expect(logs).toEqual([]); + }); + + it('processes later manifests when all entries in an earlier manifest are preserved', async () => { + const tree = createTreeWithEmptyWorkspace(); + const sourceManifest = `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "workspace:dependency@^1.0.0" + } +} +`; + tree.write('packages/my-lib/package.json', sourceManifest); + tree.write('dist/my-lib/package.json', sourceManifest); + const versionActions = await createVersionActions(tree, { + manifestRootsToUpdate: [ + { + path: 'packages/my-lib', + preserveLocalDependencyProtocols: true, + }, + { + path: 'dist/my-lib', + preserveLocalDependencyProtocols: false, + }, + ], + }); + + const logMessages = await versionActions.updateProjectDependencies( + tree, + createProjectGraph(), + { dependency: '2.0.0' } + ); + + expect(tree.read('packages/my-lib/package.json', 'utf-8')).toBe( + sourceManifest + ); + expect(tree.read('dist/my-lib/package.json', 'utf-8')).toBe(`{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "npm:dependency@^2.0.0" + } +} +`); + expect(logMessages).toEqual([ + '✍️ Updated 1 dependency in manifest: dist/my-lib/package.json', + ]); + }); + + it('counts a dependency preserved in multiple collections once', async () => { + const tree = createTreeWithEmptyWorkspace(); + const manifest = `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "workspace:dependency@^1.0.0" + }, + "devDependencies": { + "other-alias": "workspace:dependency@^1.0.0" + } +} +`; + tree.write('packages/my-lib/package.json', manifest); + const versionActions = await createVersionActions(tree, { + preserveLocalDependencyProtocols: true, + }); + + const logMessages = await versionActions.updateProjectDependencies( + tree, + createProjectGraph(), + { dependency: '2.0.0' } + ); + + expect(tree.read('packages/my-lib/package.json', 'utf-8')).toBe(manifest); + expect(logMessages).toEqual([]); + }); + + it('preserves each aliased entry\'s own prefix with versionPrefix "auto"', async () => { + const tree = createTreeWithEmptyWorkspace(); + tree.write( + 'packages/my-lib/package.json', + `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "caret-alias": "npm:dependency@^1.0.0", + "tilde-alias": "npm:dependency@~1.0.0" + } +} +` + ); + const versionActions = await createVersionActions(tree, { + versionPrefix: 'auto', + }); + + await versionActions.updateProjectDependencies( + tree, + createProjectGraph(), + { dependency: '^2.0.0' } + ); + + expect(tree.read('packages/my-lib/package.json', 'utf-8')).toBe(`{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "caret-alias": "npm:dependency@^2.0.0", + "tilde-alias": "npm:dependency@~2.0.0" + } +} +`); + }); + + it('preserves each plain entry\'s own prefix with versionPrefix "auto"', async () => { + const tree = createTreeWithEmptyWorkspace(); + tree.write( + 'packages/my-lib/package.json', + `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "dependency": "^1.0.0" + }, + "devDependencies": { + "my-alias": "npm:dependency@~1.0.0" + }, + "peerDependencies": { + "dependency": "1.0.0" + } +} +` + ); + const versionActions = await createVersionActions(tree, { + versionPrefix: 'auto', + }); + + await versionActions.updateProjectDependencies( + tree, + createProjectGraph(), + { dependency: '^2.0.0' } + ); + + expect(tree.read('packages/my-lib/package.json', 'utf-8')).toBe(`{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "dependency": "^2.0.0" + }, + "devDependencies": { + "my-alias": "npm:dependency@~2.0.0" + }, + "peerDependencies": { + "dependency": "2.0.0" + } +} +`); + }); + + it('rejects a dependency version outside a preserved npm alias inner range', async () => { + const tree = createTreeWithEmptyWorkspace(); + const manifest = `{ + "name": "my-lib", + "version": "1.0.0", + "dependencies": { + "my-alias": "npm:dependency@^1.0.0" + } +} +`; + tree.write('packages/my-lib/package.json', manifest); + const versionActions = await createVersionActions(tree, { + preserveMatchingDependencyRanges: true, + }); + + await expect( + versionActions.updateProjectDependencies(tree, createProjectGraph(), { + dependency: '2.0.0', + }) + ).rejects.toThrow('is outside the current range'); + expect(tree.read('packages/my-lib/package.json', 'utf-8')).toBe(manifest); + }); + }); }); async function createVersionActions( diff --git a/packages/js/src/release/version-actions.ts b/packages/js/src/release/version-actions.ts index fe0bdff1031..c8ce1a056d5 100644 --- a/packages/js/src/release/version-actions.ts +++ b/packages/js/src/release/version-actions.ts @@ -1,4 +1,7 @@ -import { getCatalogManager } from '@nx/devkit/internal'; +import { + getCatalogManager, + parseDependencySpecifier, +} from '@nx/devkit/internal'; import { detectPackageManager, PackageManager, @@ -17,6 +20,7 @@ import type { ResolveVersionForDependency } from 'nx/release'; import type { AfterAllProjectsVersioned, NxReleaseVersionConfiguration, + ParsedDependencySpecifier, } from '@nx/devkit/internal'; import { parseRegistryOptions } from '../utils/npm-config'; import { updateLockFile } from './utils/update-lock-file'; @@ -179,11 +183,22 @@ export default class JsVersionActions extends VersionActions { let dependencyCollection = null; if (dependencyPackageName) { for (const depType of dependencyTypes) { - if (json[depType]?.[dependencyPackageName]) { - currentVersion = json[depType][dependencyPackageName]; - dependencyCollection = depType; - break; + if (!json[depType]) { + continue; + } + const entries = findDependencyEntriesForPackage( + json[depType], + dependencyPackageName + ).filter((entry) => getEntryVersionSpec(entry)); + if (entries.length === 0) { + continue; } + // Prefer the entry keyed by the package name itself over aliased entries + const entry = + entries.find((e) => e.key === dependencyPackageName) ?? entries[0]; + currentVersion = getEntryVersionSpec(entry); + dependencyCollection = depType; + break; } } @@ -268,6 +283,7 @@ export default class JsVersionActions extends VersionActions { const manifestUpdates: Array<{ manifestPath: string; updates: Array<{ path: string[]; value: string }>; + numDependenciesUpdated: number; }> = []; const catalogUpdates: Array<{ packageName: string; @@ -292,91 +308,147 @@ export default class JsVersionActions extends VersionActions { : this.finalConfigForProject.preserveMatchingDependencyRanges || dependencyTypes; - for (const depType of dependencyTypes) { - if (json[depType]) { - for (const [dependencyName, currentVersion] of Object.entries( - json[depType] - )) { - const targetProject = localDependencyProjects.get(dependencyName); - if (!targetProject) { + let numDependenciesUpdated = 0; + + for (const [dep, version] of Object.entries(dependenciesToUpdate)) { + const collections = dependencyTypes.filter((depType) => json[depType]); + if (collections.length === 0) { + continue; + } + const packageName = + projectGraph.nodes[dep].data.metadata?.js?.packageName; + let updatedEntries = 0; + for (const depType of collections) { + const entries = findDependencyEntriesForPackage( + json[depType], + packageName + ); + for (const entry of entries) { + const rawSpecifier = entry.rawSpecifier; + if (catalogManager?.isCatalogReference(rawSpecifier)) { + // collect the catalog updates so we can update the catalog definitions later + const catalogRef = + catalogManager.parseCatalogReference(rawSpecifier)!; + catalogUpdates.push({ + packageName, + version, + catalogName: catalogRef.catalogName, + }); continue; } - - let version = dependenciesToUpdate[targetProject.projectName]; - if (version !== undefined) { - if (catalogManager?.isCatalogReference(currentVersion)) { - // collect the catalog updates so we can update the catalog definitions later - const catalogRef = - catalogManager.parseCatalogReference(currentVersion)!; - catalogUpdates.push({ - packageName: dependencyName, - version, - catalogName: catalogRef.catalogName, - }); - continue; - } - - if ( - manifestToUpdate.preserveLocalDependencyProtocols && - this.isLocalDependencyProtocol(currentVersion) - ) { - continue; - } - - if (this.isLocalDependencyProtocol(currentVersion)) { - version = this.applyVersionPrefix(currentVersion, version); - } - + if ( + manifestToUpdate.preserveLocalDependencyProtocols && + this.isLocalDependencyProtocol(rawSpecifier) + ) { + continue; + } + const isAlias = entry.parsed.requestedPackageName !== null; + const versionSpec = getEntryVersionSpec(entry); + // A rangeless alias such as npm:pkg floats to the latest version. + if (!versionSpec) { + continue; + } + let entryVersion = version; + if (!isAlias && this.isLocalDependencyProtocol(rawSpecifier)) { + entryVersion = this.applyVersionPrefix(rawSpecifier, version); + } else if (this.finalConfigForProject.versionPrefix === 'auto') { + // versionPrefix "auto" preserves each declaration's prefix, not + // the prefix chosen while reading the dependency. + const prefix = versionSpec.match(/^([~^=])/)?.[1] ?? ''; + entryVersion = `${prefix}${version.replace(/^[~^=]/, '')}`; + } + if ( + preserveMatchingDependencyRanges.includes(depType) && + !this.isLocalDependencyProtocol(rawSpecifier) + ) { + // If the dependency is specified using a range, do some additional processing to determine whether to update the version if ( - preserveMatchingDependencyRanges.includes(depType) && - !this.isLocalDependencyProtocol(currentVersion) + isValidRange(versionSpec) && + !isMatchingDependencyRange(entryVersion, versionSpec) ) { - // If the dependency is specified using a range, do some additional processing to determine whether to update the version - if ( - isValidRange(currentVersion) && - !isMatchingDependencyRange(version, currentVersion) - ) { - throw new Error( - `"preserveMatchingDependencyRanges" is enabled for "${depType}" and the new version "${version}" is outside the current range for "${dependencyName}" in manifest "${manifestToUpdate.manifestPath}". Please update the range before releasing.` - ); - } else if (isValidRange(currentVersion)) { - continue; - } - } - } else if ( - resolveVersionForDependency && - !manifestToUpdate.preserveLocalDependencyProtocols && - this.isLocalDependencyProtocol(currentVersion) - ) { - try { - version = await this.resolveLocalDependencySpecifier( - dependencyName, - currentVersion, - targetProject, - resolveVersion - ); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); throw new Error( - `Unable to replace local dependency protocol "${currentVersion}" for "${dependencyName}" in manifest "${manifestToUpdate.manifestPath}". ${message}` + `"preserveMatchingDependencyRanges" is enabled for "${depType}" and the new version "${entryVersion}" is outside the current range for "${packageName}" in manifest "${manifestToUpdate.manifestPath}". Please update the range before releasing.` ); + } else if (isValidRange(versionSpec)) { + continue; } } + updates.push({ + path: [depType, entry.key], + // Replace only the alias range. Convert workspace aliases to npm + // aliases when local protocols are not preserved. + value: isAlias + ? `npm:${packageName}@${entryVersion}` + : entryVersion, + }); + updatedEntries++; + } + } + if (updatedEntries > 0) { + numDependenciesUpdated++; + } + } - if (version !== undefined) { - updates.push({ - path: [depType, dependencyName], - value: version, - }); + if ( + resolveVersionForDependency && + !manifestToUpdate.preserveLocalDependencyProtocols + ) { + const resolvedDependencyProjects = new Set(); + for (const depType of dependencyTypes) { + if (!json[depType]) { + continue; + } + for (const [dependencyName, rawSpecifier] of Object.entries( + json[depType] + )) { + if ( + typeof rawSpecifier !== 'string' || + !this.isLocalDependencyProtocol(rawSpecifier) + ) { + continue; } + const parsed = parseDependencySpecifier(rawSpecifier); + const packageName = parsed.requestedPackageName ?? dependencyName; + const targetProject = localDependencyProjects.get(packageName); + if ( + !targetProject || + dependenciesToUpdate[targetProject.projectName] !== undefined + ) { + continue; + } + const isAlias = parsed.requestedPackageName !== null; + let version: string; + try { + version = await this.resolveLocalDependencySpecifier( + dependencyName, + // resolveLocalDependencySpecifier expects a + // workspace:-prefixed specifier; wrap an alias's inner range + // in workspace: before calling it. + isAlias ? `workspace:${parsed.range ?? ''}` : rawSpecifier, + targetProject, + resolveVersion + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + throw new Error( + `Unable to replace local dependency protocol "${rawSpecifier}" for "${dependencyName}" in manifest "${manifestToUpdate.manifestPath}". ${message}` + ); + } + updates.push({ + path: [depType, dependencyName], + value: isAlias ? `npm:${packageName}@${version}` : version, + }); + resolvedDependencyProjects.add(targetProject.projectName); } } + numDependenciesUpdated += resolvedDependencyProjects.size; } manifestUpdates.push({ manifestPath: manifestToUpdate.manifestPath, updates, + numDependenciesUpdated, }); } @@ -389,11 +461,15 @@ export default class JsVersionActions extends VersionActions { manifestUpdate.manifestPath, manifestUpdate.updates ); - if (manifestUpdate.updates.length > 0) { + + if (manifestUpdate.numDependenciesUpdated > 0) { const depText = - manifestUpdate.updates.length === 1 ? 'dependency' : 'dependencies'; + manifestUpdate.numDependenciesUpdated === 1 + ? 'dependency' + : 'dependencies'; + logMessages.push( - `✍️ Updated ${manifestUpdate.updates.length} ${depText} in manifest: ${manifestUpdate.manifestPath}` + `✍️ Updated ${manifestUpdate.numDependenciesUpdated} ${depText} in manifest: ${manifestUpdate.manifestPath}` ); } } @@ -422,10 +498,6 @@ export default class JsVersionActions extends VersionActions { return lookup; } - // This lookup requires the dependency key to match the package name. - // Package aliases need relationship-specific manifest data that the - // project graph does not retain. Support is tracked in - // https://github.com/nrwl/nx/issues/36630. lookup = new Map(); for (const [projectName, node] of Object.entries(projectGraph.nodes)) { const packageName = node.data.metadata?.js?.packageName; @@ -625,3 +697,33 @@ export default class JsVersionActions extends VersionActions { return true; } } + +interface ManifestDependencyEntry { + key: string; + rawSpecifier: string; + parsed: ParsedDependencySpecifier; +} + +/** Finds plain and aliased entries whose resolved package name matches packageName. */ +function findDependencyEntriesForPackage( + dependencies: Record, + packageName: string +): ManifestDependencyEntry[] { + const entries: ManifestDependencyEntry[] = []; + for (const [key, rawSpecifier] of Object.entries(dependencies)) { + if (typeof rawSpecifier !== 'string') { + continue; + } + const parsed = parseDependencySpecifier(rawSpecifier); + if ((parsed.requestedPackageName ?? key) === packageName) { + entries.push({ key, rawSpecifier, parsed }); + } + } + return entries; +} + +function getEntryVersionSpec(entry: ManifestDependencyEntry): string | null { + return entry.parsed.requestedPackageName !== null + ? entry.parsed.range + : entry.rawSpecifier; +} diff --git a/packages/js/src/utils/package-json/update-package-json.spec.ts b/packages/js/src/utils/package-json/update-package-json.spec.ts index c0c199b0d12..ac64055f5e1 100644 --- a/packages/js/src/utils/package-json/update-package-json.spec.ts +++ b/packages/js/src/utils/package-json/update-package-json.spec.ts @@ -858,4 +858,101 @@ describe('updatePackageJson', () => { 'link:local_path_modules/vendor/thing' ); }); + + describe('workspace dependencies', () => { + const lib3Node = { + type: 'lib' as const, + name: 'lib3', + data: { + root: 'libs/lib3', + targets: { + build: { + outputs: ['{workspaceRoot}/dist/libs/lib3'], + }, + }, + }, + }; + + const createContext = ( + packageDependencies?: Record + ): ExecutorContext => { + const graph: ProjectGraph = { + ...projectGraph, + nodes: { + ...projectGraph.nodes, + '@org/lib1': { + ...projectGraph.nodes['@org/lib1'], + data: { + ...projectGraph.nodes['@org/lib1'].data, + metadata: { + js: { + packageName: '@org/lib1', + packageDependencies, + }, + }, + }, + }, + lib3: lib3Node, + }, + }; + return { ...context, projectGraph: graph }; + }; + + const runUpdate = (testContext: ExecutorContext) => { + vol.fromJSON( + { + 'package.json': JSON.stringify(rootPackageJson, null, 2), + 'libs/lib1/package.json': JSON.stringify( + { name: '@org/lib1', version: '0.0.3' }, + null, + 2 + ), + 'dist/libs/lib3/package.json': JSON.stringify( + { name: 'lib3-package', version: '1.2.3' }, + null, + 2 + ), + }, + '/root' + ); + const options: UpdatePackageJsonOption = { + outputPath: 'dist/libs/lib1', + projectRoot: 'libs/lib1', + main: 'libs/lib1/main.ts', + updateBuildableProjectDepsInPackageJson: true, + }; + const dependencies: DependentBuildableProjectNode[] = [ + { name: 'lib3-package', outputs: [], node: lib3Node }, + ]; + updatePackageJson(options, testContext, undefined, dependencies, {}); + return JSON.parse( + vol.readFileSync('dist/libs/lib1/package.json', 'utf-8').toString() + ); + }; + + it('should add a plain workspace dependency under its package name', () => { + const distPackageJson = runUpdate(createContext(undefined)); + + expect(distPackageJson.dependencies).toEqual({ + 'lib3-package': '1.2.3', + }); + }); + + it('should add an aliased workspace dependency under its alias key as an npm alias', () => { + const distPackageJson = runUpdate( + createContext({ + dependencies: { + 'any-alias': { + rawSpecifier: 'workspace:lib3-package@*', + requestedPackageName: 'lib3-package', + }, + }, + }) + ); + + expect(distPackageJson.dependencies).toEqual({ + 'any-alias': 'npm:lib3-package@1.2.3', + }); + }); + }); }); diff --git a/packages/js/src/utils/package-json/update-package-json.ts b/packages/js/src/utils/package-json/update-package-json.ts index 946e7ac4fc7..3321a63d37a 100644 --- a/packages/js/src/utils/package-json/update-package-json.ts +++ b/packages/js/src/utils/package-json/update-package-json.ts @@ -3,6 +3,7 @@ import { fileExists, generatePrunedDeployOutput, type PackageJson, + type ProjectPackageDependencies, readFileMapCache, } from '@nx/devkit/internal'; @@ -23,7 +24,6 @@ import { import { DependentBuildableProjectNode } from '../buildable-libs-utils'; import { existsSync } from 'node:fs'; import { basename, dirname, join, parse, relative } from 'path'; - import { getRelativeDirectoryToProjectRoot } from '../get-main-file-dir'; import { stripGlobToBaseDir } from '../strip-glob-to-base-dir'; @@ -181,11 +181,19 @@ function addMissingDependencies( return; } - if ( - !packageJson.dependencies?.[packageName] && - !packageJson.devDependencies?.[packageName] && - !packageJson.peerDependencies?.[packageName] - ) { + // Preserve every source alias key; emit registry-installable npm aliases + // in build output. + const manifestKeys = getManifestKeysForPackage( + projectGraph.nodes[projectName]?.data.metadata?.js?.packageDependencies, + packageName + ); + const missingKeys = manifestKeys.filter( + (key) => + !packageJson.dependencies?.[key] && + !packageJson.devDependencies?.[key] && + !packageJson.peerDependencies?.[key] + ); + if (missingKeys.length > 0) { const outputs = getOutputsForTargetAndConfiguration( { project: projectName, @@ -206,13 +214,37 @@ function addMissingDependencies( const version = readJsonFile(depPackageJsonPath).version; packageJson[propType] ??= {}; - packageJson[propType][packageName] = version; + for (const key of missingKeys) { + packageJson[propType][key] = + key === packageName ? version : `npm:${packageName}@${version}`; + } } } } }); } +/** Returns source keys targeting packageName, or packageName when none are recorded. */ +function getManifestKeysForPackage( + packageDependencies: ProjectPackageDependencies | undefined, + packageName: string +): string[] { + const keys = new Set(); + if (packageDependencies) { + for (const collection of Object.values(packageDependencies)) { + for (const [key, entry] of Object.entries(collection)) { + if (entry.requestedPackageName === packageName) { + keys.add(key); + } + } + } + } + if (keys.size === 0) { + keys.add(packageName); + } + return [...keys]; +} + interface Exports { '.': string; diff --git a/packages/nx/plugins/package-json.spec.ts b/packages/nx/plugins/package-json.spec.ts new file mode 100644 index 00000000000..a8528092e61 --- /dev/null +++ b/packages/nx/plugins/package-json.spec.ts @@ -0,0 +1,95 @@ +import '../src/internal-testing-utils/mock-fs'; + +import { join } from 'node:path'; +import { vol } from 'memfs'; +import { workspaceDataDirectory } from '../src/utils/cache-directory'; +import { readJsonFile } from '../src/utils/fileutils'; +import { setWorkspaceRoot } from '../src/utils/workspace-root'; + +import * as plugin from './package-json'; + +describe('nx-all-package-jsons-plugin', () => { + const context = { + workspaceRoot: '/root', + nxJsonConfiguration: {}, + configFiles: [], + } as any; + + beforeEach(() => { + setWorkspaceRoot('/root'); + // Force npm detection; npm_config_user_agent otherwise makes fixture + // output runner-dependent. + vol.fromJSON({ 'package-lock.json': '{}' }, '/root'); + }); + + afterEach(() => { + vol.reset(); + }); + + it('should attach workspace package dependency descriptors and keep the persisted cache clean', async () => { + vol.fromJSON( + { + 'package.json': JSON.stringify({ name: 'root' }), + 'packages/app/package.json': JSON.stringify({ + name: 'app', + version: '1.0.0', + dependencies: { + 'alias-lib': 'workspace:lib@*', + }, + }), + 'packages/lib/package.json': JSON.stringify({ + name: 'lib', + version: '1.0.0', + }), + }, + '/root' + ); + + const configFiles = [ + 'packages/app/package.json', + 'packages/lib/package.json', + ]; + const expectedPackageDependencies = { + dependencies: { + 'alias-lib': { + rawSpecifier: 'workspace:lib@*', + requestedPackageName: 'lib', + }, + }, + }; + + const getAppProject = (results: any) => { + const entry = results.find( + ([f]: [string]) => f === 'packages/app/package.json' + ); + return Object.values(entry[1].projects)[0] as any; + }; + + const firstRun = await plugin.createNodes[1]( + configFiles, + undefined, + context + ); + expect(getAppProject(firstRun).metadata.js.packageDependencies).toEqual( + expectedPackageDependencies + ); + + const cacheOnDisk = readJsonFile<{ entries: Record }>( + join(workspaceDataDirectory, 'package-json.hash') + ); + const persistedProjects = Object.values(cacheOnDisk.entries); + expect(persistedProjects.length).toBeGreaterThan(0); + for (const project of persistedProjects) { + expect(project.metadata?.js?.packageDependencies).toBeUndefined(); + } + + const secondRun = await plugin.createNodes[1]( + configFiles, + undefined, + context + ); + expect(getAppProject(secondRun).metadata.js.packageDependencies).toEqual( + expectedPackageDependencies + ); + }); +}); diff --git a/packages/nx/plugins/package-json.ts b/packages/nx/plugins/package-json.ts index 658db704e6d..5c901f13efe 100644 --- a/packages/nx/plugins/package-json.ts +++ b/packages/nx/plugins/package-json.ts @@ -1,9 +1,11 @@ import { createNodesFromFiles, NxPlugin } from '../src/project-graph/plugins'; import { workspaceRoot } from '../src/utils/workspace-root'; import { + attachPackageDependencies, buildPackageJsonWorkspacesMatcher, buildPackageJsonPatterns, createNodeFromPackageJson, + preloadWorkspacePackages, } from '../src/plugins/package-json'; import { workspaceDataDirectory } from '../src/utils/cache-directory'; import { join } from 'path'; @@ -50,15 +52,31 @@ const plugin: NxPlugin = { context.workspaceRoot ); + // Every matched package.json becomes a project here, so they all + // participate in dependency-version resolution. + const { packageJsonContents, getWorkspacePackageVersion } = + preloadWorkspacePackages( + [...configFiles], + () => true, + (f) => readJsonFile(join(workspaceRoot, f)) + ); + const result = createNodesFromFiles( - (packageJsonPath) => - createNodeFromPackageJson( - packageJsonPath, - workspaceRoot, - cache, - isInPackageJsonWorkspaces(packageJsonPath), - packageManagerCommand - ), + (packageJsonPath) => { + const json = packageJsonContents.get(packageJsonPath); + return attachPackageDependencies( + createNodeFromPackageJson( + packageJsonPath, + workspaceRoot, + cache, + isInPackageJsonWorkspaces(packageJsonPath), + packageManagerCommand, + json + ), + json, + getWorkspacePackageVersion + ); + }, configFiles, options, context diff --git a/packages/nx/src/config/workspace-json-project-json.ts b/packages/nx/src/config/workspace-json-project-json.ts index 39e93a429d2..661d82b36d5 100644 --- a/packages/nx/src/config/workspace-json-project-json.ts +++ b/packages/nx/src/config/workspace-json-project-json.ts @@ -130,6 +130,33 @@ export interface ProjectConfiguration { metadata?: ProjectMetadata; } +export type PackageDependencyCollection = + | 'dependencies' + | 'devDependencies' + | 'optionalDependencies' + | 'peerDependencies'; + +export interface PackageDependencyEntry { + rawSpecifier: string; + /** + * The resolved workspace package name, which can differ from the manifest + * key for workspace:@ and npm:[@] aliases. + */ + requestedPackageName: string; +} + +/** + * Dependencies from a project's package.json that resolve to workspace + * packages by package name and range, keyed by dependency collection and + * manifest key. `file:` entries are not included. + */ +export type ProjectPackageDependencies = { + [collection in PackageDependencyCollection]?: Record< + string, + PackageDependencyEntry + >; +}; + export interface ProjectMetadata { [k: string]: any; @@ -156,6 +183,7 @@ export interface ProjectMetadata { packageExports?: PackageJson['exports']; packageMain?: string; isInPackageManagerWorkspaces?: boolean; + packageDependencies?: ProjectPackageDependencies; }; } diff --git a/packages/nx/src/devkit-internals.ts b/packages/nx/src/devkit-internals.ts index a77eb2990a3..5ba6969b60c 100644 --- a/packages/nx/src/devkit-internals.ts +++ b/packages/nx/src/devkit-internals.ts @@ -196,7 +196,13 @@ export { TargetProjectLocator, isBuiltinModuleImport, } from './plugins/js/project-graph/build-dependencies/target-project-locator'; -export { getWorkspacePackagesFromGraph } from './plugins/js/utils/get-workspace-packages-from-graph'; +export { + getWorkspacePackagesFromGraph, + resolveWorkspaceDependencyTarget, +} from './plugins/js/utils/get-workspace-packages-from-graph'; +export { parseDependencySpecifier } from './plugins/js/utils/dependency-specifiers'; +export type { ParsedDependencySpecifier } from './plugins/js/utils/dependency-specifiers'; +export type { ProjectPackageDependencies } from './config/workspace-json-project-json'; export { registerTsConfigPaths } from './plugins/js/utils/register'; export { getGlobPatternsFromPackageManagerWorkspaces } from './plugins/package-json'; export { diff --git a/packages/nx/src/plugins/js/lock-file/npm-parser.spec.ts b/packages/nx/src/plugins/js/lock-file/npm-parser.spec.ts index 1734607eb83..21a274e0a1a 100644 --- a/packages/nx/src/plugins/js/lock-file/npm-parser.spec.ts +++ b/packages/nx/src/plugins/js/lock-file/npm-parser.spec.ts @@ -1513,6 +1513,7 @@ describe('NPM lock file utility', () => { projectName: string; packageName: string; root: string; + version?: string; }>, workspaceDeps: Record, externalNodes: ProjectGraph['externalNodes'], @@ -1520,13 +1521,23 @@ describe('NPM lock file utility', () => { ): ProjectGraph { const nodes: ProjectGraph['nodes'] = {}; const dependencies: ProjectGraph['dependencies'] = {}; - for (const { projectName, packageName, root } of workspaceProjects) { + for (const { + projectName, + packageName, + root, + version, + } of workspaceProjects) { nodes[projectName] = { name: projectName, type: 'lib', data: { root, - metadata: { js: { packageName } }, + metadata: { + js: { + packageName, + ...(version ? { packageVersion: version } : {}), + }, + }, }, } as any; dependencies[projectName] = [ @@ -1699,6 +1710,405 @@ describe('NPM lock file utility', () => { expect(result.packages).toHaveProperty('workspace_modules/@myorg/lib-a'); expect(result.packages).toHaveProperty('workspace_modules/@myorg/lib-b'); }); + + it('should link a workspace alias entry at the root to the target module', () => { + const lockFile = { + name: 'test-app', + version: '1.0.0', + lockfileVersion: 3, + packages: { + '': { + name: 'test-app', + version: '1.0.0', + dependencies: { 'custom-lib': 'npm:@myorg/lib-a@*' }, + }, + 'libs/lib-a': { + name: '@myorg/lib-a', + version: '0.0.1', + }, + 'node_modules/custom-lib': { + resolved: 'libs/lib-a', + link: true, + }, + }, + }; + + const packageJson = { + name: 'test-app', + version: '1.0.0', + dependencies: { 'custom-lib': 'workspace:@myorg/lib-a@*' }, + }; + + const graph = makeGraph( + [ + { + projectName: '@myorg/lib-a', + packageName: '@myorg/lib-a', + root: 'libs/lib-a', + }, + ], + {}, + {} + ); + + const prunedGraph = pruneProjectGraph(graph, packageJson); + const result = JSON.parse( + stringifyNpmLockfile(prunedGraph, JSON.stringify(lockFile), packageJson) + ); + + expect(result.packages['node_modules/custom-lib']).toEqual({ + version: 'file:./workspace_modules/@myorg/lib-a', + resolved: 'workspace_modules/@myorg/lib-a', + link: true, + }); + expect(result.packages).toHaveProperty('workspace_modules/@myorg/lib-a'); + expect(result.packages[''].dependencies['custom-lib']).toBe( + 'workspace_modules/@myorg/lib-a' + ); + // Preserve the input specifier for the executor's post-stringification + // alias rewrite. + expect(packageJson.dependencies['custom-lib']).toBe( + 'workspace:@myorg/lib-a@*' + ); + }); + + it('should nest a transitive canonical link when a root alias claims its name', () => { + const lockFile = { + name: 'test-app', + version: '1.0.0', + lockfileVersion: 3, + packages: { + '': { + name: 'test-app', + version: '1.0.0', + dependencies: { 'lib-a': 'workspace:lib-b@*' }, + }, + 'libs/lib-a': { + name: 'lib-a', + version: '0.0.1', + }, + 'libs/lib-b': { + name: 'lib-b', + version: '0.0.1', + dependencies: { 'lib-a': 'workspace:*' }, + }, + 'node_modules/lib-a': { + resolved: 'libs/lib-b', + link: true, + }, + }, + }; + + const packageJson = { + name: 'test-app', + version: '1.0.0', + dependencies: { 'lib-a': 'workspace:lib-b@*' }, + }; + + const graph = makeGraph( + [ + { projectName: 'lib-a', packageName: 'lib-a', root: 'libs/lib-a' }, + { projectName: 'lib-b', packageName: 'lib-b', root: 'libs/lib-b' }, + ], + { 'lib-b': ['lib-a'] }, + {} + ); + + const prunedGraph = pruneProjectGraph(graph, packageJson); + const result = JSON.parse( + stringifyNpmLockfile(prunedGraph, JSON.stringify(lockFile), packageJson) + ); + + expect(result.packages['node_modules/lib-a']).toEqual({ + version: 'file:./workspace_modules/lib-b', + resolved: 'workspace_modules/lib-b', + link: true, + }); + expect( + result.packages['workspace_modules/lib-b/node_modules/lib-a'] + ).toEqual({ + version: 'file:./workspace_modules/lib-a', + resolved: 'workspace_modules/lib-a', + link: true, + }); + expect(result.packages).toHaveProperty('workspace_modules/lib-a'); + expect(result.packages).toHaveProperty('workspace_modules/lib-b'); + }); + + it('should link an npm alias entry targeting a workspace package at the root', () => { + const lockFile = { + name: 'test-app', + version: '1.0.0', + lockfileVersion: 3, + packages: { + '': { + name: 'test-app', + version: '1.0.0', + dependencies: { 'custom-lib': 'npm:@myorg/lib-a@1.0.0' }, + }, + 'libs/lib-a': { + name: '@myorg/lib-a', + version: '1.0.0', + }, + 'node_modules/custom-lib': { + resolved: 'libs/lib-a', + link: true, + }, + }, + }; + + const packageJson = { + name: 'test-app', + version: '1.0.0', + dependencies: { 'custom-lib': 'npm:@myorg/lib-a@1.0.0' }, + }; + + const graph = makeGraph( + [ + { + projectName: '@myorg/lib-a', + packageName: '@myorg/lib-a', + root: 'libs/lib-a', + version: '1.0.0', + }, + ], + {}, + {} + ); + + const prunedGraph = pruneProjectGraph(graph, packageJson); + const result = JSON.parse( + stringifyNpmLockfile(prunedGraph, JSON.stringify(lockFile), packageJson) + ); + + expect(result.packages['node_modules/custom-lib']).toEqual({ + version: 'file:./workspace_modules/@myorg/lib-a', + resolved: 'workspace_modules/@myorg/lib-a', + link: true, + }); + expect(result.packages).toHaveProperty('workspace_modules/@myorg/lib-a'); + }); + + it('should include entries for aliased transitive workspace deps', () => { + const lockFile = { + name: 'test-app', + version: '1.0.0', + lockfileVersion: 3, + packages: { + '': { + name: 'test-app', + version: '1.0.0', + dependencies: { '@myorg/lib-a': 'file:libs/lib-a' }, + }, + 'libs/lib-a': { + name: '@myorg/lib-a', + version: '0.0.1', + dependencies: { 'custom-inner': 'npm:@myorg/lib-b@1.0.0' }, + }, + 'node_modules/@myorg/lib-a': { + resolved: 'libs/lib-a', + link: true, + }, + 'libs/lib-b': { + name: '@myorg/lib-b', + version: '1.0.0', + }, + 'node_modules/custom-inner': { + resolved: 'libs/lib-b', + link: true, + }, + }, + }; + + const packageJson = { + name: 'test-app', + version: '1.0.0', + dependencies: { '@myorg/lib-a': 'file:libs/lib-a' }, + }; + + const graph = makeGraph( + [ + { + projectName: '@myorg/lib-a', + packageName: '@myorg/lib-a', + root: 'libs/lib-a', + }, + { + projectName: '@myorg/lib-b', + packageName: '@myorg/lib-b', + root: 'libs/lib-b', + version: '1.0.0', + }, + ], + { '@myorg/lib-a': ['@myorg/lib-b'] }, + {} + ); + + const prunedGraph = pruneProjectGraph(graph, packageJson); + const result = JSON.parse( + stringifyNpmLockfile(prunedGraph, JSON.stringify(lockFile), packageJson) + ); + + expect(result.packages).toHaveProperty('workspace_modules/@myorg/lib-a'); + expect(result.packages).toHaveProperty('workspace_modules/@myorg/lib-b'); + expect(result.packages['node_modules/custom-inner']).toEqual({ + version: 'file:./workspace_modules/@myorg/lib-b', + resolved: 'workspace_modules/@myorg/lib-b', + link: true, + }); + }); + + it('should emit name-keyed v1 dependency nodes for workspace deps and aliases', () => { + const lockFile = { + name: 'test-app', + version: '1.0.0', + lockfileVersion: 1, + dependencies: { + '@myorg/lib-a': { + version: 'file:libs/lib-a', + }, + }, + }; + + const packageJson = { + name: 'test-app', + version: '1.0.0', + dependencies: { + '@myorg/lib-a': 'workspace:*', + 'custom-lib': 'workspace:@myorg/lib-b@*', + }, + }; + + const graph = makeGraph( + [ + { + projectName: '@myorg/lib-a', + packageName: '@myorg/lib-a', + root: 'libs/lib-a', + }, + { + projectName: '@myorg/lib-b', + packageName: '@myorg/lib-b', + root: 'libs/lib-b', + }, + ], + {}, + {} + ); + + const prunedGraph = pruneProjectGraph(graph, packageJson); + const result = JSON.parse( + stringifyNpmLockfile(prunedGraph, JSON.stringify(lockFile), packageJson) + ); + + expect(result.packages).toBeUndefined(); + expect(result.dependencies['@myorg/lib-a']).toEqual({ + version: 'file:./workspace_modules/@myorg/lib-a', + }); + expect(result.dependencies['custom-lib']).toEqual({ + version: 'file:./workspace_modules/@myorg/lib-b', + }); + expect( + Object.keys(result.dependencies).filter( + (k) => k.includes('node_modules') || k.startsWith('workspace_modules') + ) + ).toEqual([]); + }); + + it('should nest a colliding canonical dependency inside its consumer for v1 lockfiles', () => { + const lockFile = { + name: 'test-app', + version: '1.0.0', + lockfileVersion: 1, + dependencies: { + 'lib-a': { + version: 'file:libs/lib-b', + }, + 'lib-b': { + version: 'file:libs/lib-b', + dependencies: { + 'lib-a': { version: 'file:libs/lib-a' }, + }, + }, + }, + }; + + const packageJson = { + name: 'test-app', + version: '1.0.0', + dependencies: { 'lib-a': 'workspace:lib-b@*' }, + }; + + const graph = makeGraph( + [ + { projectName: 'lib-a', packageName: 'lib-a', root: 'libs/lib-a' }, + { projectName: 'lib-b', packageName: 'lib-b', root: 'libs/lib-b' }, + ], + { 'lib-b': ['lib-a'] }, + {} + ); + + const prunedGraph = pruneProjectGraph(graph, packageJson); + const result = JSON.parse( + stringifyNpmLockfile(prunedGraph, JSON.stringify(lockFile), packageJson) + ); + + expect(result.dependencies['lib-a']).toEqual({ + version: 'file:./workspace_modules/lib-b', + dependencies: { + 'lib-a': { version: 'file:./workspace_modules/lib-a' }, + }, + }); + }); + + it('should walk transitive deps of a workspace package referenced only through an alias in a v1 lock file', () => { + // npm v1 keys this node by alias, leaving no canonical snapshot for + // transitive traversal. + const lockFile = { + name: 'test-app', + version: '1.0.0', + lockfileVersion: 1, + dependencies: { + custom: { version: 'file:libs/lib-b' }, + }, + }; + + const packageJson = { + name: 'test-app', + version: '1.0.0', + dependencies: { custom: 'workspace:lib-b@*' }, + }; + + const graph = makeGraph( + [ + { projectName: 'lib-a', packageName: 'lib-a', root: 'libs/lib-a' }, + { projectName: 'lib-b', packageName: 'lib-b', root: 'libs/lib-b' }, + ], + { 'lib-b': ['lib-a'] }, + {} + ); + // Supply lib-b's manifest edge because alias-keyed npm v1 lock data omits it. + (graph.nodes['lib-b'].data.metadata.js as any).packageDependencies = { + dependencies: { + 'lib-a': { + rawSpecifier: 'workspace:*', + requestedPackageName: 'lib-a', + }, + }, + }; + + const prunedGraph = pruneProjectGraph(graph, packageJson); + const result = JSON.parse( + stringifyNpmLockfile(prunedGraph, JSON.stringify(lockFile), packageJson) + ); + + expect(result.dependencies['custom']).toEqual({ + version: 'file:./workspace_modules/lib-b', + }); + expect(result.dependencies['lib-a']).toEqual({ + version: 'file:./workspace_modules/lib-a', + }); + }); }); }); diff --git a/packages/nx/src/plugins/js/lock-file/npm-parser.ts b/packages/nx/src/plugins/js/lock-file/npm-parser.ts index bcb243f4f20..cf396251334 100644 --- a/packages/nx/src/plugins/js/lock-file/npm-parser.ts +++ b/packages/nx/src/plugins/js/lock-file/npm-parser.ts @@ -15,7 +15,10 @@ import { } from '../../../config/project-graph'; import { hashArray } from '../../../hasher/file-hasher'; import { CreateDependenciesContext } from '../../../project-graph/plugins'; -import { getWorkspacePackagesFromGraph } from '../utils/get-workspace-packages-from-graph'; +import { + getWorkspacePackagesFromGraph, + resolveWorkspaceDependencyTarget, +} from '../utils/get-workspace-packages-from-graph'; /** * NPM @@ -479,12 +482,16 @@ export function stringifyNpmLockfile( output.overrides = packageJson.overrides; } if (lockfileVersion > 1) { - const packages = mapV3Snapshots(mappedPackages, packageJson); - output.packages = { ...packages, ...workspaceModules }; + const packages = mapV3Snapshots( + mappedPackages, + packageJson, + workspaceModulesFromGraph + ); + output.packages = { ...packages, ...workspaceModules.v3 }; } if (lockfileVersion < 3) { const dependencies = mapV1Snapshots(mappedPackages); - output.dependencies = { ...dependencies, ...workspaceModules }; + output.dependencies = { ...dependencies, ...workspaceModules.v1 }; } return JSON.stringify(output, null, 2); @@ -500,53 +507,153 @@ function mapWorkspaceModules( packageJson: NormalizedPackageJson, rootLockFile: NpmLockFile, workspaceModules: Map -) { - const output: Record = {}; +): { + v3: Record; + v1: Record; +} { + const v3: Record = {}; + // npm v1 dependencies are name-keyed and nested; never use filesystem + // paths as keys. + const v1: Record = {}; + const v1NodesByTarget = new Map(); const snapshotsByName = new Map(); - for (const snapshot of Object.values( - rootLockFile.packages || rootLockFile.dependencies || {} + // npm v1 aliases are key-indexed, so retain raw-key lookup alongside + // canonical-name lookup. + const snapshotsByKey = new Map(); + for (const [key, snapshot] of Object.entries( + rootLockFile.packages ?? rootLockFile.dependencies ?? {} )) { - if (snapshot.name) snapshotsByName.set(snapshot.name, snapshot); + // npm v1 identifies snapshots by object key; npm v3 supplies snapshot.name. + const name = snapshot.name ?? (rootLockFile.packages ? undefined : key); + if (name) snapshotsByName.set(name, snapshot); + if (!rootLockFile.packages) snapshotsByKey.set(key, snapshot); } // Walk transitive workspace deps so every workspace package // copy-workspace-modules writes to disk has matching lockfile entries. // Without this, `npm ci` errors with "Missing: from lock file". - const queue: string[] = Object.keys(packageJson.dependencies ?? {}); + // Carry consumer identity so canonical links nest when a root alias already + // owns the same path. + const queue: Array< + [ + key: string, + specifier: string | undefined, + consumer: string | null, + sourceSnapshot: (NpmDependencyV3 & NpmDependencyV1) | undefined, + ] + > = Object.entries(packageJson.dependencies ?? {}).map(([key, specifier]) => [ + key, + specifier, + null, + undefined, + ]); const visited = new Set(); + const links = new Map(); while (queue.length > 0) { - const pkgName = queue.shift()!; - if (visited.has(pkgName) || !workspaceModules.has(pkgName)) continue; + const [key, specifier, consumer, sourceSnapshot] = queue.shift()!; + const pkgName = resolveWorkspaceDependencyTarget( + key, + specifier, + workspaceModules + ); + if (!pkgName) continue; + + const moduleDir = `workspace_modules/${pkgName}`; + const rootLinkPath = `node_modules/${key}`; + const nested = + consumer !== null && + links.has(rootLinkPath) && + links.get(rootLinkPath) !== moduleDir; + const linkPath = nested + ? `workspace_modules/${consumer}/node_modules/${key}` + : rootLinkPath; + if (links.get(linkPath) !== moduleDir) { + links.set(linkPath, moduleDir); + v3[linkPath] = { + version: `file:./${moduleDir}`, + resolved: moduleDir, + link: true, + }; + + const v1Node: NpmDependencyV1 = { version: `file:./${moduleDir}` }; + if (nested) { + const parent = v1NodesByTarget.get(consumer); + if (parent) { + parent.dependencies ??= {}; + parent.dependencies[key] ??= v1Node; + } + } else { + v1[key] ??= v1Node; + } + if (!v1NodesByTarget.has(pkgName)) { + v1NodesByTarget.set(pkgName, v1Node); + } + } + + if (visited.has(pkgName)) continue; visited.add(pkgName); - const snapshot = snapshotsByName.get(pkgName); + // Fall back to the reference snapshot because npm v1 alias nodes lack a + // canonical-name entry. + const snapshot = + snapshotsByName.get(pkgName) ?? sourceSnapshot ?? snapshotsByKey.get(key); - output[`node_modules/${pkgName}`] = { - version: `file:./workspace_modules/${pkgName}`, - resolved: `workspace_modules/${pkgName}`, - link: true, - }; - output[`workspace_modules/${pkgName}`] = { + v3[moduleDir] = { name: pkgName, version: `0.0.1`, - dependencies: snapshot?.dependencies, + dependencies: snapshot?.dependencies as Record, }; for (const depType of WORKSPACE_DEP_TYPES) { const deps = snapshot?.[depType]; if (!deps) continue; - for (const depName of Object.keys(deps)) queue.push(depName); + for (const [depName, depSpecifier] of Object.entries(deps)) { + const isV1Node = typeof depSpecifier === 'object'; + queue.push([ + depName, + isV1Node + ? (depSpecifier as NpmDependencyV1)?.version + : (depSpecifier as string), + pkgName, + isV1Node + ? (depSpecifier as NpmDependencyV3 & NpmDependencyV1) + : snapshotsByKey.get(depName), + ]); + } + } + + // npm v1 alias snapshots can omit canonical workspace edges; graph + // descriptors restore manifest dependencies. + const packageDependencies = + workspaceModules.get(pkgName)?.data.metadata?.js?.packageDependencies; + if (packageDependencies) { + for (const depType of WORKSPACE_DEP_TYPES) { + const collection = packageDependencies[depType]; + if (!collection) continue; + for (const [depKey, descriptor] of Object.entries(collection)) { + queue.push([ + depKey, + descriptor.rawSpecifier, + pkgName, + snapshotsByKey.get(depKey), + ]); + } + } } } - return output; + return { v3, v1 }; } function mapV3Snapshots( mappedPackages: MappedPackage[], - packageJson: NormalizedPackageJson + packageJson: NormalizedPackageJson, + workspaceModules: Map ): Record { const output: Record = {}; - const mappedPackageJson = mapPackageJsonWithWorkspaceModules(packageJson); + const mappedPackageJson = mapPackageJsonWithWorkspaceModules( + packageJson, + workspaceModules + ); output[''] = mappedPackageJson; mappedPackages.forEach((p) => { @@ -557,16 +664,31 @@ function mapV3Snapshots( } function mapPackageJsonWithWorkspaceModules( - packageJson: NormalizedPackageJson + packageJson: NormalizedPackageJson, + workspaceModules: Map ) { - for (const [pkgName, pkgVersion] of Object.entries( - packageJson.dependencies ?? {} - )) { - if (pkgVersion.startsWith('workspace:') || pkgVersion.startsWith('file:')) { - packageJson.dependencies[pkgName] = `workspace_modules/${pkgName}`; + if (!packageJson.dependencies) { + return packageJson; + } + // Copy dependencies: the prune-lockfile executor still needs originals to + // rewrite aliases after the lockfile is generated. + const dependencies = { ...packageJson.dependencies }; + for (const [pkgName, pkgVersion] of Object.entries(dependencies)) { + const target = resolveWorkspaceDependencyTarget( + pkgName, + pkgVersion, + workspaceModules + ); + if (target !== null && target !== pkgName) { + dependencies[pkgName] = `workspace_modules/${target}`; + } else if ( + pkgVersion.startsWith('workspace:') || + pkgVersion.startsWith('file:') + ) { + dependencies[pkgName] = `workspace_modules/${pkgName}`; } } - return packageJson; + return { ...packageJson, dependencies }; } function mapV1Snapshots( diff --git a/packages/nx/src/plugins/js/lock-file/pnpm-parser.spec.ts b/packages/nx/src/plugins/js/lock-file/pnpm-parser.spec.ts index 796884a029c..2269ed794f1 100644 --- a/packages/nx/src/plugins/js/lock-file/pnpm-parser.spec.ts +++ b/packages/nx/src/plugins/js/lock-file/pnpm-parser.spec.ts @@ -3022,6 +3022,7 @@ snapshots: projectName: string; packageName: string; root: string; + version?: string; }>, workspaceDeps: Record, externalNodes: Record, @@ -3029,13 +3030,23 @@ snapshots: ): ProjectGraph { const nodes: ProjectGraph['nodes'] = {}; const dependencies: ProjectGraph['dependencies'] = {}; - for (const { projectName, packageName, root } of workspaceProjects) { + for (const { + projectName, + packageName, + root, + version, + } of workspaceProjects) { nodes[projectName] = { name: projectName, type: 'lib', data: { root, - metadata: { js: { packageName } }, + metadata: { + js: { + packageName, + ...(version ? { packageVersion: version } : {}), + }, + }, }, } as any; dependencies[projectName] = [ @@ -4899,6 +4910,242 @@ snapshots: 'vendor-real-name@file:local_path_modules/libs/vendor:' ); }); + + it('should map a workspace alias entry at the root to the target workspace module', () => { + const lockFile = `lockfileVersion: '9.0' + +importers: + + .: + dependencies: + custom-lib: + specifier: workspace:@myorg/lib-a@* + version: link:libs/lib-a + + libs/lib-a: + dependencies: + lodash: + specifier: ^4.17.21 + version: 4.17.21 + +packages: + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + +snapshots: + + lodash@4.17.21: {}`; + + const packageJson = { + name: 'test-app', + version: '1.0.0', + dependencies: { 'custom-lib': 'workspace:@myorg/lib-a@*' }, + }; + + const graph = makeGraph( + [ + { + projectName: '@myorg/lib-a', + packageName: '@myorg/lib-a', + root: 'libs/lib-a', + }, + ], + {}, + { + 'npm:lodash': { + type: 'npm', + name: 'npm:lodash', + data: { + version: '4.17.21', + packageName: 'lodash', + hash: 'sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==', + }, + }, + }, + { + '@myorg/lib-a': ['npm:lodash'], + } + ); + + const prunedGraph = pruneProjectGraph(graph, packageJson); + const result = stringifyPnpmLockfile( + prunedGraph, + lockFile, + packageJson, + '/virtual' + ); + + expect(result).toMatch( + /custom-lib:\s+specifier: file:\.\/workspace_modules\/@myorg\/lib-a\s+version: '@myorg\/lib-a@file:workspace_modules\/@myorg\/lib-a'/ + ); + expect(result).toContain( + `'@myorg/lib-a@file:workspace_modules/@myorg/lib-a':` + ); + expect(result).toContain(`lodash@4.17.21:`); + }); + + it('should map an npm alias entry targeting a workspace package at the root', () => { + const lockFile = `lockfileVersion: '9.0' + +importers: + + .: + dependencies: + custom-lib: + specifier: npm:@myorg/lib-a@1.0.0 + version: link:libs/lib-a + + libs/lib-a: + dependencies: + lodash: + specifier: ^4.17.21 + version: 4.17.21 + +packages: + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + +snapshots: + + lodash@4.17.21: {}`; + + const packageJson = { + name: 'test-app', + version: '1.0.0', + dependencies: { 'custom-lib': 'npm:@myorg/lib-a@1.0.0' }, + }; + + const graph = makeGraph( + [ + { + projectName: '@myorg/lib-a', + packageName: '@myorg/lib-a', + root: 'libs/lib-a', + version: '1.0.0', + }, + ], + {}, + { + 'npm:lodash': { + type: 'npm', + name: 'npm:lodash', + data: { + version: '4.17.21', + packageName: 'lodash', + hash: 'sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==', + }, + }, + }, + { + '@myorg/lib-a': ['npm:lodash'], + } + ); + + const prunedGraph = pruneProjectGraph(graph, packageJson); + const result = stringifyPnpmLockfile( + prunedGraph, + lockFile, + packageJson, + '/virtual' + ); + + expect(result).toMatch( + /custom-lib:\s+specifier: file:\.\/workspace_modules\/@myorg\/lib-a\s+version: '@myorg\/lib-a@file:workspace_modules\/@myorg\/lib-a'/ + ); + expect(result).toContain( + `'@myorg/lib-a@file:workspace_modules/@myorg/lib-a':` + ); + expect(result).toContain(`lodash@4.17.21:`); + }); + + it('should rewrite aliased transitive workspace dependencies in importer blocks', () => { + const lockFile = `lockfileVersion: '9.0' + +importers: + + .: + dependencies: + '@myorg/lib-a': + specifier: workspace:* + version: link:libs/lib-a + + libs/lib-a: + dependencies: + custom-inner: + specifier: workspace:@myorg/lib-b@* + version: link:../lib-b + + libs/lib-b: + dependencies: + lodash: + specifier: ^4.17.21 + version: 4.17.21 + +packages: + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + +snapshots: + + lodash@4.17.21: {}`; + + const packageJson = { + name: 'test-app', + version: '1.0.0', + dependencies: { '@myorg/lib-a': 'workspace:*' }, + }; + + const graph = makeGraph( + [ + { + projectName: '@myorg/lib-a', + packageName: '@myorg/lib-a', + root: 'libs/lib-a', + }, + { + projectName: '@myorg/lib-b', + packageName: '@myorg/lib-b', + root: 'libs/lib-b', + }, + ], + { + '@myorg/lib-a': ['@myorg/lib-b'], + }, + { + 'npm:lodash': { + type: 'npm', + name: 'npm:lodash', + data: { + version: '4.17.21', + packageName: 'lodash', + hash: 'sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==', + }, + }, + }, + { + '@myorg/lib-b': ['npm:lodash'], + } + ); + + const prunedGraph = pruneProjectGraph(graph, packageJson); + const result = stringifyPnpmLockfile( + prunedGraph, + lockFile, + packageJson, + '/virtual' + ); + + expect(result).toContain( + `'@myorg/lib-b@file:workspace_modules/@myorg/lib-b':` + ); + expect(result).toContain( + `custom-inner: '@myorg/lib-b@file:workspace_modules/@myorg/lib-b'` + ); + expect(result).toContain(`lodash@4.17.21:`); + }); }); describe('missing .modules.yaml', () => { diff --git a/packages/nx/src/plugins/js/lock-file/pnpm-parser.ts b/packages/nx/src/plugins/js/lock-file/pnpm-parser.ts index c4b1aface5d..a9804e83b5b 100644 --- a/packages/nx/src/plugins/js/lock-file/pnpm-parser.ts +++ b/packages/nx/src/plugins/js/lock-file/pnpm-parser.ts @@ -46,7 +46,10 @@ import { isAbsolute, join, posix, relative, sep } from 'path'; import { workspaceRoot } from '../../../utils/workspace-root'; import { existsSync, readFileSync, statSync } from 'node:fs'; import { logger } from '../../../utils/logger'; -import { getWorkspacePackagesFromGraph } from '../utils/get-workspace-packages-from-graph'; +import { + getWorkspacePackagesFromGraph, + resolveWorkspaceDependencyTarget, +} from '../utils/get-workspace-packages-from-graph'; import { satisfies, validRange } from 'semver'; // The dep types walked when pulling a copied module's own workspace deps into @@ -690,7 +693,7 @@ export function stringifyPnpmLockfile( // pruned lockfile in sync; a missing edge installs cleanly but the module // fails at require time with MODULE_NOT_FOUND. Cached per module root. type ManifestPeers = { - workspaceSiblings: string[]; + workspaceSiblings: Array<[name: string, target: string]>; localPathPeers: Array<[name: string, spec: string]>; }; const manifestPeersCache = new Map(); @@ -707,8 +710,13 @@ export function stringifyPnpmLockfile( for (const [name, spec] of Object.entries( manifest.peerDependencies ?? {} )) { - if (workspaceModules.has(name)) { - peers.workspaceSiblings.push(name); + const target = resolveWorkspaceDependencyTarget( + name, + typeof spec === 'string' ? spec : undefined, + workspaceModules + ); + if (target) { + peers.workspaceSiblings.push([name, target]); } else if (typeof spec === 'string' && isLocalPathSpecifier(spec)) { peers.localPathPeers.push([name, spec]); } @@ -747,10 +755,15 @@ export function stringifyPnpmLockfile( // errors with ERR_PNPM_OUTDATED_LOCKFILE on transitive workspace chains. const allRequiredImporters: Record = { ...requiredImporters }; const queue = Object.keys(requiredImporters); - const enqueueWorkspaceModule = (depName: string) => { - if (workspaceModules.has(depName) && !(depName in allRequiredImporters)) { - allRequiredImporters[depName] = workspaceModules.get(depName)!.data.root; - queue.push(depName); + const enqueueWorkspaceModule = (depName: string, specifier?: string) => { + const target = resolveWorkspaceDependencyTarget( + depName, + specifier, + workspaceModules + ); + if (target && !(target in allRequiredImporters)) { + allRequiredImporters[target] = workspaceModules.get(target)!.data.root; + queue.push(target); } }; while (queue.length > 0) { @@ -762,14 +775,15 @@ export function stringifyPnpmLockfile( const deps = importer[depType]; if (!deps) continue; for (const depName of Object.keys(deps)) { - enqueueWorkspaceModule(depName); + enqueueWorkspaceModule(depName, importer.specifiers?.[depName]); } } } // Peers pnpm did not auto-install are absent from the importer above; pull // them from the manifest so their directory package is emitted too. - for (const depName of getManifestPeers(importerPath).workspaceSiblings) { - enqueueWorkspaceModule(depName); + for (const [, peerTarget] of getManifestPeers(importerPath) + .workspaceSiblings) { + enqueueWorkspaceModule(peerTarget); } } @@ -814,8 +828,18 @@ export function stringifyPnpmLockfile( for (const [depName, ref] of Object.entries(deps)) { // Sibling workspace modules resolve to their own directory package; npm // deps (resolved peers included) keep the ref from the source importer. - if (workspaceModules.has(depName)) { - resolved[depName] = `file:workspace_modules/${depName}`; + const target = resolveWorkspaceDependencyTarget( + depName, + baseImporter.specifiers?.[depName], + workspaceModules + ); + if (target) { + // pnpm alias refs must include the target package name before the + // file: reference. + resolved[depName] = + target === depName + ? `file:workspace_modules/${target}` + : `${target}@file:workspace_modules/${target}`; } else if (ref.startsWith('link:')) { // pnpm reads a snapshot link: ref relative to the lockfile dir, so // rebase the importer-relative ref onto the deploy root, relocated to @@ -877,8 +901,11 @@ export function stringifyPnpmLockfile( continue; } snapshot.dependencies ??= {}; - for (const depName of workspaceSiblings) { - snapshot.dependencies[depName] ??= `file:workspace_modules/${depName}`; + for (const [depName, target] of workspaceSiblings) { + snapshot.dependencies[depName] ??= + target === depName + ? `file:workspace_modules/${target}` + : `${target}@file:workspace_modules/${target}`; } for (const [depName, spec] of localPathPeers) { if (snapshot.dependencies[depName]) { @@ -1260,7 +1287,12 @@ function mapRootSnapshot( version = resolved; } - if (workspaceModules.has(packageName)) { + const workspaceTarget = resolveWorkspaceDependencyTarget( + packageName, + version, + workspaceModules + ); + if (workspaceTarget !== null) { // The app may declare the module under dependencies, // optionalDependencies, devDependencies, or peerDependencies. Route // the lockfile entry into the matching section; peerDependencies @@ -1274,39 +1306,49 @@ function mapRootSnapshot( ? 'devDependencies' : 'dependencies'; let importerKeyForPackage: string | undefined; - for (const [importerPath, importerSnapshot] of Object.entries( - rootImporters - )) { - const workspaceDep = - (importerSnapshot.dependencies && - importerSnapshot.dependencies[packageName]) || - (importerSnapshot.optionalDependencies && - importerSnapshot.optionalDependencies[packageName]) || - (importerSnapshot.devDependencies && - importerSnapshot.devDependencies[packageName]); - if (workspaceDep) { - importerKeyForPackage = join( - importerPath, - workspaceDep.replace('link:', '') - ); - break; + if (workspaceTarget !== packageName) { + // Alias-only targets lack a canonical importer entry; use their + // graph root. + importerKeyForPackage = + workspaceModules.get(workspaceTarget)!.data.root; + } else { + for (const [importerPath, importerSnapshot] of Object.entries( + rootImporters + )) { + const workspaceDep = + (importerSnapshot.dependencies && + importerSnapshot.dependencies[packageName]) || + (importerSnapshot.optionalDependencies && + importerSnapshot.optionalDependencies[packageName]) || + (importerSnapshot.devDependencies && + importerSnapshot.devDependencies[packageName]); + if (workspaceDep) { + importerKeyForPackage = join( + importerPath, + workspaceDep.replace('link:', '') + ); + break; + } } + // pnpm records no importer entry for a workspace peer when + // autoInstallPeers is off, so fall back to the module's own root. The + // pruned manifest still moves the peer into dependencies, so the root + // importer must reference its directory package either way. + importerKeyForPackage ??= + workspaceModules.get(packageName)?.data.root; } - // pnpm records no importer entry for a workspace peer when - // autoInstallPeers is off, so fall back to the module's own root. The - // pruned manifest still moves the peer into dependencies, so the root - // importer must reference its directory package either way. - importerKeyForPackage ??= - workspaceModules.get(packageName)?.data.root; if (importerKeyForPackage) { - importers[packageName] = importerKeyForPackage; - // Specifier matches the app manifest's file: ref; the version is - // the directory package key's ref (no leading `./`). + importers[workspaceTarget] = importerKeyForPackage; + // The specifier uses the manifest's file:./ path. The version uses + // a bare file: path, prefixed with the target package name for + // aliases. snapshot.specifiers[packageName] = - `file:./workspace_modules/${packageName}`; + `file:./workspace_modules/${workspaceTarget}`; snapshot[targetSection] = snapshot[targetSection] || {}; snapshot[targetSection][packageName] = - `file:workspace_modules/${packageName}`; + workspaceTarget === packageName + ? `file:workspace_modules/${workspaceTarget}` + : `${workspaceTarget}@file:workspace_modules/${workspaceTarget}`; } } else { let node = diff --git a/packages/nx/src/plugins/js/lock-file/project-graph-pruning.spec.ts b/packages/nx/src/plugins/js/lock-file/project-graph-pruning.spec.ts index b8075798830..f56114bc1d1 100644 --- a/packages/nx/src/plugins/js/lock-file/project-graph-pruning.spec.ts +++ b/packages/nx/src/plugins/js/lock-file/project-graph-pruning.spec.ts @@ -630,6 +630,7 @@ describe('project-graph-pruning', () => { metadata: { js: { packageName: 'workspace-lib', + packageVersion: '1.0.0', }, }, }, @@ -748,6 +749,127 @@ describe('project-graph-pruning', () => { expect(prunedGraph.nodes['workspace-lib']).toBeDefined(); }); + it('should keep a workspace alias entry and traverse the requested target', () => { + const prunedPackageJson: PackageJson = { + name: 'test', + version: '1.0.0', + dependencies: { + 'any-alias': 'workspace:workspace-lib@*', + }, + }; + + const prunedGraph = pruneProjectGraph(graph, prunedPackageJson); + + expect(prunedGraph.nodes['workspace-lib']).toBeDefined(); + expect(prunedGraph.externalNodes?.['npm:lodash']).toBeDefined(); + }); + + it('should not let an external node named like the alias key shadow the workspace target', () => { + graph.externalNodes['npm:any-alias'] = { + type: 'npm', + name: 'npm:any-alias', + data: { + packageName: 'any-alias', + version: '9.9.9', + }, + }; + graph.dependencies['npm:any-alias'] = []; + const prunedPackageJson: PackageJson = { + name: 'test', + version: '1.0.0', + dependencies: { + 'any-alias': 'workspace:workspace-lib@*', + }, + }; + + const prunedGraph = pruneProjectGraph(graph, prunedPackageJson); + + expect(prunedGraph.externalNodes?.['npm:any-alias']).toBeUndefined(); + expect(prunedGraph.externalNodes?.['npm:lodash']).toBeDefined(); + }); + + it('should keep an npm alias entry targeting a workspace package and traverse the target', () => { + const prunedPackageJson: PackageJson = { + name: 'test', + version: '1.0.0', + dependencies: { + 'any-alias': 'npm:workspace-lib@1.0.0', + }, + }; + + const prunedGraph = pruneProjectGraph(graph, prunedPackageJson); + + expect(prunedGraph.nodes['workspace-lib']).toBeDefined(); + expect(prunedGraph.externalNodes?.['npm:lodash']).toBeDefined(); + }); + + it('should keep a range-less npm alias entry targeting a workspace package', () => { + const prunedPackageJson: PackageJson = { + name: 'test', + version: '1.0.0', + dependencies: { + 'any-alias': 'npm:workspace-lib', + }, + }; + + const prunedGraph = pruneProjectGraph(graph, prunedPackageJson); + + expect(prunedGraph.nodes['workspace-lib']).toBeDefined(); + expect(prunedGraph.externalNodes?.['npm:lodash']).toBeDefined(); + }); + + it('should not let an external node named like the npm alias key shadow the workspace target', () => { + graph.externalNodes['npm:any-alias'] = { + type: 'npm', + name: 'npm:any-alias', + data: { + packageName: 'any-alias', + version: '9.9.9', + }, + }; + graph.dependencies['npm:any-alias'] = []; + const prunedPackageJson: PackageJson = { + name: 'test', + version: '1.0.0', + dependencies: { + 'any-alias': 'npm:workspace-lib@1.0.0', + }, + }; + + const prunedGraph = pruneProjectGraph(graph, prunedPackageJson); + + expect(prunedGraph.externalNodes?.['npm:any-alias']).toBeUndefined(); + expect(prunedGraph.externalNodes?.['npm:lodash']).toBeDefined(); + }); + + it('should fail when an npm alias range does not match the workspace package version and the alias is not in the lock file', () => { + const prunedPackageJson: PackageJson = { + name: 'test', + version: '1.0.0', + dependencies: { + 'any-alias': 'npm:workspace-lib@^9.0.0', + }, + }; + + expect(() => pruneProjectGraph(graph, prunedPackageJson)).toThrow( + 'The following package was not found in the root lock file: any-alias@npm:workspace-lib@^9.0.0' + ); + }); + + it('should fail when a workspace entry does not match any workspace package', () => { + const prunedPackageJson: PackageJson = { + name: 'test', + version: '1.0.0', + dependencies: { + 'any-alias': 'workspace:ghost@*', + }, + }; + + expect(() => pruneProjectGraph(graph, prunedPackageJson)).toThrow( + '"any-alias": "workspace:ghost@*" does not match any workspace package' + ); + }); + it('should handle devDependencies', () => { const prunedPackageJson: PackageJson = { name: 'test', diff --git a/packages/nx/src/plugins/js/lock-file/project-graph-pruning.ts b/packages/nx/src/plugins/js/lock-file/project-graph-pruning.ts index 03016a29ef0..32483b58e20 100644 --- a/packages/nx/src/plugins/js/lock-file/project-graph-pruning.ts +++ b/packages/nx/src/plugins/js/lock-file/project-graph-pruning.ts @@ -10,7 +10,11 @@ import { getCatalogManager } from '../../../utils/catalog'; import { PackageJson } from '../../../utils/package-json'; import { PackageManager } from '../../../utils/package-manager'; import { workspaceRoot } from '../../../utils/workspace-root'; -import { getWorkspacePackagesFromGraph } from '../utils/get-workspace-packages-from-graph'; +import { parseDependencySpecifier } from '../utils/dependency-specifiers'; +import { + getWorkspacePackagesFromGraph, + resolveWorkspaceDependencyTarget, +} from '../utils/get-workspace-packages-from-graph'; import { normalizeLocalPathSpec, uncontainLocalPathSpec, @@ -108,6 +112,33 @@ function normalizeDependencies( } } + // workspace: entries bypass lockfile lookup; keep them only when their + // requested local package exists. + const parsed = parseDependencySpecifier(resolvedVersionRange); + if (parsed.protocol === 'workspace') { + if (workspacePackages.has(parsed.requestedPackageName ?? packageName)) { + combinedDependencies[packageName] = resolvedVersionRange; + return; + } + throw new Error( + `Pruned lock file creation failed. "${packageName}": "${resolvedVersionRange}" does not match any workspace package.` + ); + } + + // Keep npm aliases that resolve to local workspace versions; registry + // aliases still use lockfile lookup. + if ( + parsed.protocol === 'npm' && + resolveWorkspaceDependencyTarget( + packageName, + resolvedVersionRange, + workspacePackages + ) !== null + ) { + combinedDependencies[packageName] = resolvedVersionRange; + return; + } + if (graph.externalNodes[`npm:${packageName}@${resolvedVersionRange}`]) { combinedDependencies[packageName] = resolvedVersionRange; return; @@ -253,6 +284,31 @@ export function addNodesAndDependencies( builder: ProjectGraphBuilder ) { Object.entries(packageJsonDeps).forEach(([name, version]) => { + // Resolve workspace: entries before external nodes so a same-named + // registry package cannot shadow the local target. + const parsed = parseDependencySpecifier(version); + if (parsed.protocol === 'workspace') { + const workspaceNode = workspacePackages.get( + parsed.requestedPackageName ?? name + ); + if (workspaceNode) { + traverseWorkspaceNode(graph, builder, workspaceNode); + } + return; + } + + if (parsed.protocol === 'npm') { + const target = resolveWorkspaceDependencyTarget( + name, + version, + workspacePackages + ); + if (target !== null) { + traverseWorkspaceNode(graph, builder, workspacePackages.get(target)); + return; + } + } + const node = graph.externalNodes[`npm:${name}@${version}`] || graph.externalNodes[`npm:${name}`]; diff --git a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts index a4a1a90e07f..cbcd08469d9 100644 --- a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts +++ b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts @@ -1294,6 +1294,110 @@ describe('TargetProjectLocator', () => { expect(result).toBeFalsy(); }); + + describe('aliases and version specifiers', () => { + const projects: Record = { + pkg1: { + name: 'pkg1', + type: 'lib' as const, + data: { + root: 'packages/pkg1', + metadata: { + js: { + packageName: '@org/pkg1', + packageVersion: '1.2.3', + isInPackageManagerWorkspaces: true, + }, + }, + }, + }, + pkg2: { + name: 'pkg2', + type: 'lib' as const, + data: { + root: 'packages/pkg2', + metadata: { + js: { + packageName: '@org/pkg2', + packageVersion: '2.0.0', + isInPackageManagerWorkspaces: true, + }, + }, + }, + }, + 'lib-b': { + name: 'lib-b', + type: 'lib' as const, + data: { + root: 'packages/lib-b', + metadata: { + js: { + packageName: 'lib-b', + packageVersion: '3.0.0', + isInPackageManagerWorkspaces: true, + }, + }, + }, + }, + }; + + it.each` + dep | specifier | expected + ${'@org/pkg1'} | ${'*'} | ${'pkg1'} + ${'@org/pkg1'} | ${'^1.0.0'} | ${'pkg1'} + ${'@org/pkg1'} | ${'^9.0.0'} | ${null} + ${'@org/pkg1'} | ${'workspace:*'} | ${'pkg1'} + ${'@org/pkg1'} | ${'workspace:^9.0.0'} | ${'pkg1'} + ${'alias'} | ${'workspace:@org/pkg1@*'} | ${'pkg1'} + ${'alias'} | ${'workspace:@org/pkg1@^9.0.0'} | ${'pkg1'} + ${'alias'} | ${'workspace:lib-b@*'} | ${'lib-b'} + ${'@org/pkg2'} | ${'workspace:@org/pkg1@*'} | ${'pkg1'} + ${'alias'} | ${'workspace:@org/ghost@*'} | ${null} + ${'alias'} | ${'npm:@org/pkg1@^1.0.0'} | ${'pkg1'} + ${'alias'} | ${'npm:@org/pkg1@^9.0.0'} | ${null} + ${'alias'} | ${'npm:@org/ghost@*'} | ${null} + `( + 'should resolve dep=$dep specifier=$specifier to $expected', + ({ dep, specifier, expected }) => { + const targetProjectLocator = new TargetProjectLocator( + projects, + {}, + new Map() + ); + + const result = targetProjectLocator.findDependencyInWorkspaceProjects( + 'packages/app/package.json', + dep, + specifier + ); + + expect(result).toEqual(expected); + } + ); + + it('should resolve a file: dependency by path against the source manifest directory', () => { + const targetProjectLocator = new TargetProjectLocator( + projects, + {}, + new Map() + ); + + expect( + targetProjectLocator.findDependencyInWorkspaceProjects( + 'packages/app/package.json', + '@org/pkg1', + 'file:../pkg1' + ) + ).toEqual('pkg1'); + expect( + targetProjectLocator.findDependencyInWorkspaceProjects( + 'packages/app/package.json', + '@org/pkg1', + 'file:../elsewhere' + ) + ).toBeNull(); + }); + }); }); describe('findImportInWorkspaceProjects', () => { diff --git a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts index 64bde5b93cb..7e4534cf53f 100644 --- a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts +++ b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts @@ -1,6 +1,6 @@ import { isBuiltin } from 'node:module'; import { dirname, join, posix, relative, isAbsolute } from 'node:path'; -import { clean, satisfies } from 'semver'; +import { clean } from 'semver'; import type { ProjectGraphExternalNode, ProjectGraphProjectNode, @@ -14,6 +14,7 @@ import { getPackageNameFromImportPath } from '../../../../utils/get-package-name import type { PackageJson } from '../../../../utils/package-json'; import { normalizePath } from '../../../../utils/path'; import { workspaceRoot } from '../../../../utils/workspace-root'; +import { matchDependencyToWorkspacePackage } from '../../utils/dependency-specifiers'; import { getWorkspacePackagesMetadata, matchImportToWildcardEntryPointsToProjectMap, @@ -360,48 +361,35 @@ export class TargetProjectLocator { ): string | null { this.packagesMetadata ??= getWorkspacePackagesMetadata(this.nodes); - const maybeDep = this.packagesMetadata.packageToProjectMap[dep]; - - const maybeDepMetadata = maybeDep?.data.metadata.js; - - if (!maybeDepMetadata) { - return null; - } - - const workspaceRegex = /^workspace:/; - const hasWorkspaceProtocol = workspaceRegex.test(packageVersion); - const normalizedRange = packageVersion.replace(workspaceRegex, ''); - - /** - * Regex is needed to test for workspace: protocol because following options are all valid: - * - workspace:* - * - workspace:^ - * - workspace:~ - * - workspace:foo@* - */ - if (hasWorkspaceProtocol || normalizedRange === '*') { - return maybeDep?.name; + const match = matchDependencyToWorkspacePackage( + dep, + packageVersion, + (packageName) => { + const metadata = + this.packagesMetadata.packageToProjectMap[packageName]?.data.metadata + ?.js; + return metadata ? (metadata.packageVersion ?? null) : undefined; + } + ); + if (match) { + return this.packagesMetadata.packageToProjectMap[ + match.requestedPackageName + ].name; } - if (normalizedRange.startsWith('file:')) { - const targetPath = maybeDep?.data.root; - - const normalizedPath = normalizedRange.replace('file:', ''); + // file: dependencies resolve by path against the source manifest directory + if (packageVersion.startsWith('file:')) { + const maybeDep = this.packagesMetadata.packageToProjectMap[dep]; + if (!maybeDep?.data.metadata?.js) { + return null; + } + const normalizedPath = packageVersion.replace('file:', ''); const resolvedPath = posix.join(dirname(packageJsonPath), normalizedPath); - - if (targetPath === resolvedPath) { - return maybeDep?.name; + if (maybeDep.data.root === resolvedPath) { + return maybeDep.name; } } - if ( - satisfies(maybeDepMetadata.packageVersion, normalizedRange, { - includePrerelease: true, - }) - ) { - return maybeDep?.name; - } - return null; } diff --git a/packages/nx/src/plugins/js/utils/dependency-specifiers.spec.ts b/packages/nx/src/plugins/js/utils/dependency-specifiers.spec.ts new file mode 100644 index 00000000000..cc9dfc7d7c8 --- /dev/null +++ b/packages/nx/src/plugins/js/utils/dependency-specifiers.spec.ts @@ -0,0 +1,178 @@ +import { + getWorkspacePackageDependencies, + matchDependencyToWorkspacePackage, + parseDependencySpecifier, +} from './dependency-specifiers'; + +describe('parseDependencySpecifier', () => { + it.each` + rawSpecifier | protocol | requestedPackageName | range + ${'1.2.3'} | ${'plain'} | ${null} | ${'1.2.3'} + ${'^1.2.3'} | ${'plain'} | ${null} | ${'^1.2.3'} + ${'~1.2.3'} | ${'plain'} | ${null} | ${'~1.2.3'} + ${'*'} | ${'plain'} | ${null} | ${'*'} + ${'>=1.0.0 <2.0.0'} | ${'plain'} | ${null} | ${'>=1.0.0 <2.0.0'} + ${'1.0.0-beta.1'} | ${'plain'} | ${null} | ${'1.0.0-beta.1'} + ${'latest'} | ${'plain'} | ${null} | ${'latest'} + ${''} | ${'plain'} | ${null} | ${''} + ${'workspace:*'} | ${'workspace'} | ${null} | ${'*'} + ${'workspace:^'} | ${'workspace'} | ${null} | ${'^'} + ${'workspace:~'} | ${'workspace'} | ${null} | ${'~'} + ${'workspace:1.2.3'} | ${'workspace'} | ${null} | ${'1.2.3'} + ${'workspace:^1.2.3'} | ${'workspace'} | ${null} | ${'^1.2.3'} + ${'workspace:foo@*'} | ${'workspace'} | ${'foo'} | ${'*'} + ${'workspace:foo@^1.2.3'} | ${'workspace'} | ${'foo'} | ${'^1.2.3'} + ${'workspace:@scope/foo@*'} | ${'workspace'} | ${'@scope/foo'} | ${'*'} + ${'workspace:@scope/foo@~2.0.0'} | ${'workspace'} | ${'@scope/foo'} | ${'~2.0.0'} + ${'workspace:@scope/foo'} | ${'workspace'} | ${'@scope/foo'} | ${null} + ${'workspace:@scope/foo@'} | ${'workspace'} | ${'@scope/foo'} | ${''} + ${'workspace:foo@'} | ${'workspace'} | ${'foo'} | ${''} + ${'workspace:foo'} | ${'workspace'} | ${null} | ${'foo'} + ${'npm:foo'} | ${'npm'} | ${'foo'} | ${null} + ${'npm:foo@1.2.3'} | ${'npm'} | ${'foo'} | ${'1.2.3'} + ${'npm:foo@^1.2.3'} | ${'npm'} | ${'foo'} | ${'^1.2.3'} + ${'npm:@scope/foo'} | ${'npm'} | ${'@scope/foo'} | ${null} + ${'npm:@scope/foo@~2.0.0'} | ${'npm'} | ${'@scope/foo'} | ${'~2.0.0'} + ${'npm:@scope/foo@1.0.0-rc.1'} | ${'npm'} | ${'@scope/foo'} | ${'1.0.0-rc.1'} + ${'npm:foo@'} | ${'npm'} | ${'foo'} | ${''} + ${' npm:foo@1.0.0'} | ${'plain'} | ${null} | ${' npm:foo@1.0.0'} + ${'file:../foo'} | ${'file'} | ${null} | ${null} + ${'file:./libs/foo'} | ${'file'} | ${null} | ${null} + ${'catalog:'} | ${'other'} | ${null} | ${null} + ${'catalog:react'} | ${'other'} | ${null} | ${null} + ${'link:../foo'} | ${'other'} | ${null} | ${null} + ${'portal:../foo'} | ${'other'} | ${null} | ${null} + ${'patch:foo@1.2.3#patch.diff'} | ${'other'} | ${null} | ${null} + ${'git+ssh://git@github.com/u/r.git'} | ${'other'} | ${null} | ${null} + ${'github:user/repo'} | ${'other'} | ${null} | ${null} + ${'https://example.com/foo.tgz'} | ${'other'} | ${null} | ${null} + `( + 'should parse "$rawSpecifier" as $protocol / $requestedPackageName / $range', + ({ rawSpecifier, protocol, requestedPackageName, range }) => { + expect(parseDependencySpecifier(rawSpecifier)).toEqual({ + protocol, + requestedPackageName, + range, + }); + } + ); +}); + +describe('matchDependencyToWorkspacePackage', () => { + const versions: Record = { + foo: '1.2.3', + '@scope/foo': '2.0.0', + 'no-version': null, + }; + const getPackageVersion = (name: string) => versions[name]; + + it.each` + dependencyKey | rawSpecifier | expected + ${'foo'} | ${'*'} | ${'foo'} + ${'foo'} | ${'1.2.3'} | ${'foo'} + ${'foo'} | ${'^1.0.0'} | ${'foo'} + ${'foo'} | ${'^2.0.0'} | ${null} + ${'missing'} | ${'*'} | ${null} + ${'no-version'} | ${'*'} | ${'no-version'} + ${'no-version'} | ${'^1.0.0'} | ${null} + ${'foo'} | ${'workspace:*'} | ${'foo'} + ${'foo'} | ${'workspace:^'} | ${'foo'} + ${'foo'} | ${'workspace:^9.0.0'} | ${'foo'} + ${'missing'} | ${'workspace:*'} | ${null} + ${'anything'} | ${'workspace:foo@*'} | ${'foo'} + ${'anything'} | ${'workspace:foo@^9.0.0'} | ${'foo'} + ${'anything'} | ${'workspace:foo@'} | ${'foo'} + ${'anything'} | ${'workspace:@scope/foo@~2.0.0'} | ${'@scope/foo'} + ${'anything'} | ${'workspace:@scope/foo@'} | ${'@scope/foo'} + ${'anything'} | ${'workspace:@scope/foo'} | ${null} + ${'anything'} | ${'workspace:ghost@*'} | ${null} + ${'anything'} | ${'npm:foo'} | ${'foo'} + ${'anything'} | ${'npm:foo@*'} | ${'foo'} + ${'anything'} | ${'npm:foo@1.2.3'} | ${'foo'} + ${'anything'} | ${'npm:foo@^1.0.0'} | ${'foo'} + ${'anything'} | ${'npm:foo@^9.0.0'} | ${null} + ${'anything'} | ${'npm:@scope/foo@^2.0.0'} | ${'@scope/foo'} + ${'anything'} | ${'npm:ghost@*'} | ${null} + ${'anything'} | ${'npm:no-version@^1.0.0'} | ${null} + ${'anything'} | ${'npm:no-version'} | ${'no-version'} + ${'foo'} | ${'file:../foo'} | ${null} + ${'foo'} | ${'catalog:'} | ${null} + ${'foo'} | ${'1.0.0-beta.1 || ^1.2.0'} | ${'foo'} + `( + 'should resolve key=$dependencyKey specifier=$rawSpecifier to $expected', + ({ dependencyKey, rawSpecifier, expected }) => { + const result = matchDependencyToWorkspacePackage( + dependencyKey, + rawSpecifier, + getPackageVersion + ); + if (expected === null) { + expect(result).toBeNull(); + } else { + expect(result).toEqual({ requestedPackageName: expected }); + } + } + ); +}); + +describe('getWorkspacePackageDependencies', () => { + const versions: Record = { + 'lib-a': '1.0.0', + 'lib-b': '2.0.0', + }; + const getPackageVersion = (name: string) => versions[name]; + + it('should collect matching entries across all dependency collections', () => { + expect( + getWorkspacePackageDependencies( + { + name: 'app', + version: '1.0.0', + dependencies: { 'lib-a': '^1.0.0' }, + devDependencies: { 'alias-b': 'workspace:lib-b@*' }, + optionalDependencies: { 'lib-b': 'workspace:*' }, + peerDependencies: { 'lib-a': 'npm:lib-b@^2.0.0' }, + }, + getPackageVersion + ) + ).toEqual({ + dependencies: { + 'lib-a': { rawSpecifier: '^1.0.0', requestedPackageName: 'lib-a' }, + }, + devDependencies: { + 'alias-b': { + rawSpecifier: 'workspace:lib-b@*', + requestedPackageName: 'lib-b', + }, + }, + optionalDependencies: { + 'lib-b': { rawSpecifier: 'workspace:*', requestedPackageName: 'lib-b' }, + }, + peerDependencies: { + 'lib-a': { + rawSpecifier: 'npm:lib-b@^2.0.0', + requestedPackageName: 'lib-b', + }, + }, + }); + }); + + it('should skip self references, non-string specifiers, and non-matching entries', () => { + expect( + getWorkspacePackageDependencies( + { + name: 'lib-a', + version: '1.0.0', + dependencies: { + 'lib-a': '^1.0.0', + 'self-alias': 'workspace:lib-a@*', + 'not-a-string': 42 as unknown as string, + external: '^5.0.0', + 'lib-b': 'file:../lib-b', + }, + }, + getPackageVersion + ) + ).toBeUndefined(); + }); +}); diff --git a/packages/nx/src/plugins/js/utils/dependency-specifiers.ts b/packages/nx/src/plugins/js/utils/dependency-specifiers.ts new file mode 100644 index 00000000000..d720482ec74 --- /dev/null +++ b/packages/nx/src/plugins/js/utils/dependency-specifiers.ts @@ -0,0 +1,180 @@ +import { satisfies } from 'semver'; +import type { + PackageDependencyCollection, + ProjectPackageDependencies, +} from '../../../config/workspace-json-project-json'; +import type { PackageJson } from '../../../utils/package-json'; + +export interface ParsedDependencySpecifier { + protocol: 'plain' | 'workspace' | 'npm' | 'file' | 'other'; + requestedPackageName: string | null; + range: string | null; +} + +// Match protocol prefixes; plain semver ranges contain no colon. +const PROTOCOL_REGEX = /^[a-z][a-z0-9+.-]*:/i; + +/** + * Parses plain, workspace, npm-alias, file, and other specifiers. Supports + * scoped aliases and npm aliases without ranges; returns the embedded + * package name and range when present. + */ +export function parseDependencySpecifier( + rawSpecifier: string +): ParsedDependencySpecifier { + if (rawSpecifier.startsWith('workspace:')) { + const rest = rawSpecifier.slice('workspace:'.length); + const { name, range } = splitNameAndRange(rest); + if (name !== null) { + return { protocol: 'workspace', requestedPackageName: name, range }; + } + // A bare unscoped name is indistinguishable from a range, so + // workspace:foo is not parsed as an alias. + return { protocol: 'workspace', requestedPackageName: null, range: rest }; + } + + if (rawSpecifier.startsWith('npm:')) { + const rest = rawSpecifier.slice('npm:'.length); + const { name, range } = splitNameAndRange(rest); + if (name !== null) { + return { protocol: 'npm', requestedPackageName: name, range }; + } + return { protocol: 'npm', requestedPackageName: rest, range: null }; + } + + if (rawSpecifier.startsWith('file:')) { + return { protocol: 'file', requestedPackageName: null, range: null }; + } + + if (PROTOCOL_REGEX.test(rawSpecifier)) { + return { protocol: 'other', requestedPackageName: null, range: null }; + } + + return { protocol: 'plain', requestedPackageName: null, range: rawSpecifier }; +} + +function splitNameAndRange(value: string): { + name: string | null; + range: string | null; +} { + const atIndex = value.indexOf('@', value.startsWith('@') ? 1 : 0); + if (atIndex > 0) { + return { name: value.slice(0, atIndex), range: value.slice(atIndex + 1) }; + } + if (value.startsWith('@')) { + return { name: value, range: null }; + } + return { name: null, range: null }; +} + +/** + * Matches a dependency to a workspace package: + * - a target-bearing `workspace:` alias requires the `@` separator (an + * empty range is valid) and matches an existing target regardless of range; + * - a bare `workspace:` specifier matches an existing package under the + * dependency key, also regardless of range; + * - an `npm:` alias matches when rangeless, wildcard, or version-satisfying; + * - a plain entry matches the dependency key when wildcard or + * version-satisfying; + * - `file:` and other protocols do not match here. + * + * `getPackageVersion` returns the workspace package's version, `null` when the + * package exists without a version, or `undefined` when no workspace package + * has that name; wildcards match unversioned packages. + */ +export function matchDependencyToWorkspacePackage( + dependencyKey: string, + rawSpecifier: string, + getPackageVersion: (packageName: string) => string | null | undefined +): { requestedPackageName: string } | null { + const parsed = parseDependencySpecifier(rawSpecifier); + + switch (parsed.protocol) { + case 'workspace': { + if (parsed.requestedPackageName !== null && parsed.range === null) { + return null; + } + const requestedPackageName = parsed.requestedPackageName ?? dependencyKey; + return getPackageVersion(requestedPackageName) !== undefined + ? { requestedPackageName } + : null; + } + case 'npm': { + const version = getPackageVersion(parsed.requestedPackageName); + if (version === undefined) { + return null; + } + if ( + parsed.range === null || + parsed.range === '*' || + (version !== null && + satisfies(version, parsed.range, { includePrerelease: true })) + ) { + return { requestedPackageName: parsed.requestedPackageName }; + } + return null; + } + case 'plain': { + const version = getPackageVersion(dependencyKey); + if (version === undefined) { + return null; + } + if ( + parsed.range === '*' || + (version !== null && + satisfies(version, parsed.range, { includePrerelease: true })) + ) { + return { requestedPackageName: dependencyKey }; + } + return null; + } + case 'file': + case 'other': + return null; + } +} + +const DEPENDENCY_COLLECTIONS: PackageDependencyCollection[] = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', +]; + +/** + * Collects non-self manifest entries resolving to workspace packages, + * grouped by dependency collection and key. Omits file: entries and + * returns undefined when empty. + */ +export function getWorkspacePackageDependencies( + packageJson: PackageJson, + getPackageVersion: (packageName: string) => string | null | undefined +): ProjectPackageDependencies | undefined { + let result: ProjectPackageDependencies | undefined; + for (const collection of DEPENDENCY_COLLECTIONS) { + const dependencies = packageJson[collection]; + if (!dependencies) { + continue; + } + for (const [key, rawSpecifier] of Object.entries(dependencies)) { + if (typeof rawSpecifier !== 'string') { + continue; + } + const match = matchDependencyToWorkspacePackage( + key, + rawSpecifier, + getPackageVersion + ); + if (!match || match.requestedPackageName === packageJson.name) { + continue; + } + result ??= {}; + result[collection] ??= {}; + result[collection][key] = { + rawSpecifier, + requestedPackageName: match.requestedPackageName, + }; + } + } + return result; +} diff --git a/packages/nx/src/plugins/js/utils/get-workspace-packages-from-graph.spec.ts b/packages/nx/src/plugins/js/utils/get-workspace-packages-from-graph.spec.ts new file mode 100644 index 00000000000..f7ad9f291e4 --- /dev/null +++ b/packages/nx/src/plugins/js/utils/get-workspace-packages-from-graph.spec.ts @@ -0,0 +1,125 @@ +import type { ProjectGraphProjectNode } from '../../../config/project-graph'; +import { resolveWorkspaceDependencyTarget } from './get-workspace-packages-from-graph'; + +describe('resolveWorkspaceDependencyTarget', () => { + function makeWorkspacePackages( + packages: Array<{ packageName: string; version?: string }> + ): Map { + const map = new Map(); + for (const { packageName, version } of packages) { + map.set(packageName, { + name: packageName, + type: 'lib', + data: { + root: `libs/${packageName}`, + metadata: { + js: { + packageName, + ...(version ? { packageVersion: version } : {}), + }, + }, + }, + } as ProjectGraphProjectNode); + } + return map; + } + + it('should resolve a plain entry keyed by a workspace package name', () => { + const packages = makeWorkspacePackages([ + { packageName: 'lib-a', version: '1.0.0' }, + ]); + + expect(resolveWorkspaceDependencyTarget('lib-a', '^1.0.0', packages)).toBe( + 'lib-a' + ); + expect( + resolveWorkspaceDependencyTarget('lib-a', 'workspace:*', packages) + ).toBe('lib-a'); + expect(resolveWorkspaceDependencyTarget('lib-a', undefined, packages)).toBe( + 'lib-a' + ); + }); + + it('should resolve a workspace alias entry to the requested target', () => { + const packages = makeWorkspacePackages([ + { packageName: 'lib-a', version: '1.0.0' }, + ]); + + expect( + resolveWorkspaceDependencyTarget( + 'custom-lib', + 'workspace:lib-a@*', + packages + ) + ).toBe('lib-a'); + }); + + it('should prefer the aliased target over a workspace package matching the key', () => { + const packages = makeWorkspacePackages([ + { packageName: 'lib-a', version: '1.0.0' }, + { packageName: 'lib-b', version: '1.0.0' }, + ]); + + expect( + resolveWorkspaceDependencyTarget('lib-a', 'workspace:lib-b@*', packages) + ).toBe('lib-b'); + }); + + it('should resolve an npm alias entry when the range matches the workspace package version', () => { + const packages = makeWorkspacePackages([ + { packageName: 'lib-a', version: '1.0.0' }, + ]); + + expect( + resolveWorkspaceDependencyTarget( + 'custom-lib', + 'npm:lib-a@1.0.0', + packages + ) + ).toBe('lib-a'); + expect( + resolveWorkspaceDependencyTarget('custom-lib', 'npm:lib-a', packages) + ).toBe('lib-a'); + }); + + it('should not resolve an npm alias entry when the range does not match the workspace package version', () => { + const packages = makeWorkspacePackages([ + { packageName: 'lib-a', version: '1.0.0' }, + ]); + + expect( + resolveWorkspaceDependencyTarget( + 'custom-lib', + 'npm:lib-a@^9.0.0', + packages + ) + ).toBeNull(); + }); + + it('should not fall back to the key for an alias entry targeting a non-workspace package', () => { + const packages = makeWorkspacePackages([ + { packageName: 'custom-lib', version: '1.0.0' }, + ]); + + expect( + resolveWorkspaceDependencyTarget( + 'custom-lib', + 'npm:lodash@^4.17.21', + packages + ) + ).toBeNull(); + }); + + it('should return null for entries that do not reference a workspace package', () => { + const packages = makeWorkspacePackages([ + { packageName: 'lib-a', version: '1.0.0' }, + ]); + + expect( + resolveWorkspaceDependencyTarget('lodash', '^4.17.21', packages) + ).toBeNull(); + expect( + resolveWorkspaceDependencyTarget('lodash', undefined, packages) + ).toBeNull(); + }); +}); diff --git a/packages/nx/src/plugins/js/utils/get-workspace-packages-from-graph.ts b/packages/nx/src/plugins/js/utils/get-workspace-packages-from-graph.ts index 0105d122272..dc1a43e7a8a 100644 --- a/packages/nx/src/plugins/js/utils/get-workspace-packages-from-graph.ts +++ b/packages/nx/src/plugins/js/utils/get-workspace-packages-from-graph.ts @@ -2,6 +2,10 @@ import { type ProjectGraph, ProjectGraphProjectNode, } from '../../../config/project-graph'; +import { + matchDependencyToWorkspacePackage, + parseDependencySpecifier, +} from './dependency-specifiers'; export function getWorkspacePackagesFromGraph(graph: ProjectGraph) { const workspacePackages: Map = new Map(); @@ -13,3 +17,36 @@ export function getWorkspacePackagesFromGraph(graph: ProjectGraph) { } return workspacePackages; } + +function getWorkspacePackageVersion( + workspacePackages: Map, + packageName: string +): string | null | undefined { + const node = workspacePackages.get(packageName); + if (!node) { + return undefined; + } + return node.data.metadata?.js?.packageVersion ?? null; +} + +/** + * Resolves a dependency key and specifier to its workspace target package + * name, or null. Alias targets override the key. + */ +export function resolveWorkspaceDependencyTarget( + dependencyKey: string, + specifier: string | undefined, + workspacePackages: Map +): string | null { + if (typeof specifier === 'string') { + const parsed = parseDependencySpecifier(specifier); + if (parsed.requestedPackageName !== null) { + return ( + matchDependencyToWorkspacePackage(dependencyKey, specifier, (name) => + getWorkspacePackageVersion(workspacePackages, name) + )?.requestedPackageName ?? null + ); + } + } + return workspacePackages.has(dependencyKey) ? dependencyKey : null; +} diff --git a/packages/nx/src/plugins/package-json/create-nodes.spec.ts b/packages/nx/src/plugins/package-json/create-nodes.spec.ts index 9ccd0ba9801..20b81ce6d86 100644 --- a/packages/nx/src/plugins/package-json/create-nodes.spec.ts +++ b/packages/nx/src/plugins/package-json/create-nodes.spec.ts @@ -3,8 +3,10 @@ import '../../internal-testing-utils/mock-fs'; import { join } from 'node:path'; import { vol } from 'memfs'; import { createNodeFromPackageJson, createNodes } from './create-nodes'; +import { hashObject } from '../../hasher/file-hasher'; import { workspaceDataDirectory } from '../../utils/cache-directory'; import { PluginCache } from '../../utils/plugin-cache-utils'; +import { nxVersion } from '../../utils/versions'; const packageJsonCachePath = join(workspaceDataDirectory, 'package-json.hash'); @@ -1046,4 +1048,438 @@ describe('nx package.json workspaces plugin', () => { ] `); }); + + describe('workspace alias validation', () => { + it('should aggregate per-file errors for invalid workspace aliases and keep valid results', async () => { + vol.fromJSON( + { + 'package.json': JSON.stringify({ + name: 'root', + workspaces: ['packages/*'], + }), + 'packages/app/package.json': JSON.stringify({ + name: 'app', + version: '1.0.0', + dependencies: { 'alias-name': 'workspace:@acme/ghost@*' }, + }), + 'packages/lib-b/package.json': JSON.stringify({ + name: 'lib-b', + version: '2.0.0', + }), + }, + '/root' + ); + + let error: any; + try { + await createNodes[1]( + ['packages/app/package.json', 'packages/lib-b/package.json'], + undefined, + context + ); + } catch (e) { + error = e; + } + + expect(error.name).toEqual('AggregateCreateNodesError'); + expect(error.errors).toHaveLength(1); + const [file, innerError] = error.errors[0]; + expect(file).toEqual('packages/app/package.json'); + expect(innerError.message).toContain( + 'Invalid workspace dependency alias "alias-name": "workspace:@acme/ghost@*".' + ); + expect( + error.partialResults.some(([f]) => f === 'packages/lib-b/package.json') + ).toBe(true); + }); + + it('should validate the root manifest even when it is not an Nx project', async () => { + vol.fromJSON( + { + 'package.json': JSON.stringify({ + name: 'root', + workspaces: ['packages/*'], + dependencies: { 'alias-name': 'workspace:ghost@*' }, + }), + 'packages/lib-b/package.json': JSON.stringify({ + name: 'lib-b', + version: '2.0.0', + }), + }, + '/root' + ); + + await expect( + createNodes[1]( + ['package.json', 'packages/lib-b/package.json'], + undefined, + context + ) + ).rejects.toMatchObject({ + name: 'AggregateCreateNodesError', + errors: [ + [ + 'package.json', + expect.objectContaining({ + message: expect.stringContaining( + 'Invalid workspace dependency alias' + ), + }), + ], + ], + }); + }); + + it('should not let a same-named package outside the package-manager workspaces satisfy an alias', async () => { + vol.fromJSON( + { + 'package.json': JSON.stringify({ + name: 'root', + workspaces: ['packages/*'], + }), + 'packages/app/package.json': JSON.stringify({ + name: 'app', + version: '1.0.0', + dependencies: { 'alias-name': 'workspace:fixture-lib@*' }, + }), + 'e2e/fixture/project.json': JSON.stringify({ name: 'fixture-lib' }), + 'e2e/fixture/package.json': JSON.stringify({ + name: 'fixture-lib', + version: '1.0.0', + }), + }, + '/root' + ); + + await expect( + createNodes[1]( + [ + 'packages/app/package.json', + 'e2e/fixture/project.json', + 'e2e/fixture/package.json', + ], + undefined, + context + ) + ).rejects.toMatchObject({ name: 'AggregateCreateNodesError' }); + }); + + it('should not validate manifests outside the package-manager workspaces', async () => { + vol.fromJSON( + { + 'package.json': JSON.stringify({ + name: 'root', + workspaces: ['packages/*'], + }), + 'e2e/fixture/project.json': JSON.stringify({ name: 'fixture-lib' }), + 'e2e/fixture/package.json': JSON.stringify({ + name: 'fixture-lib', + version: '1.0.0', + dependencies: { 'alias-name': 'workspace:ghost@*' }, + }), + }, + '/root' + ); + + const results = await createNodes[1]( + ['e2e/fixture/project.json', 'e2e/fixture/package.json'], + undefined, + context + ); + expect(results.some(([f]) => f === 'e2e/fixture/package.json')).toBe( + true + ); + }); + + it('should not report a missing target when a workspace manifest fails to parse', async () => { + vol.fromJSON( + { + 'package.json': JSON.stringify({ + name: 'root', + workspaces: ['packages/*'], + }), + 'packages/app/package.json': JSON.stringify({ + name: 'app', + version: '1.0.0', + dependencies: { 'alias-name': 'workspace:lib-b@*' }, + }), + 'packages/lib-b/package.json': '{ invalid json', + }, + '/root' + ); + + let error: any; + try { + await createNodes[1]( + ['packages/app/package.json', 'packages/lib-b/package.json'], + undefined, + context + ); + } catch (e) { + error = e; + } + + expect(error.name).toEqual('AggregateCreateNodesError'); + expect(error.errors).toHaveLength(1); + expect(error.errors[0][0]).toEqual('packages/lib-b/package.json'); + expect(error.errors[0][1].message).not.toContain( + 'Invalid workspace dependency alias' + ); + }); + + it('should attribute a malformed root manifest to its file and keep valid results', async () => { + vol.fromJSON( + { + 'package.json': '{ invalid json', + 'packages/lib-b/package.json': JSON.stringify({ + name: 'lib-b', + version: '2.0.0', + }), + 'packages/lib-b/project.json': JSON.stringify({ name: 'lib-b' }), + }, + '/root' + ); + + let error: any; + try { + await createNodes[1]( + [ + 'package.json', + 'packages/lib-b/package.json', + 'packages/lib-b/project.json', + ], + undefined, + context + ); + } catch (e) { + error = e; + } + + expect(error.name).toEqual('AggregateCreateNodesError'); + expect(error.errors).toHaveLength(1); + expect(error.errors[0][0]).toEqual('package.json'); + expect( + error.partialResults.some(([f]) => f === 'packages/lib-b/package.json') + ).toBe(true); + }); + + it('should accept aliases naming any workspace package, including the root package', async () => { + vol.fromJSON( + { + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + workspaces: ['packages/*'], + }), + 'packages/app/package.json': JSON.stringify({ + name: 'app', + version: '1.0.0', + dependencies: { + 'alias-root': 'workspace:root@*', + 'alias-b': 'workspace:lib-b@^2.0.0', + }, + }), + 'packages/lib-b/package.json': JSON.stringify({ + name: 'lib-b', + version: '2.0.0', + }), + }, + '/root' + ); + + const results = await createNodes[1]( + [ + 'package.json', + 'packages/app/package.json', + 'packages/lib-b/package.json', + ], + undefined, + context + ); + expect(results.some(([f]) => f === 'packages/app/package.json')).toBe( + true + ); + }); + }); + + describe('workspace package dependency descriptors', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const getProject = ( + results: Awaited>, + file: string + ) => { + const entry = results.find(([f]) => f === file); + return Object.values(entry[1].projects)[0]; + }; + + it('should collect dependencies resolving to workspace packages, including aliases', async () => { + vol.fromJSON( + { + 'package.json': JSON.stringify({ + name: 'root', + workspaces: ['packages/*'], + }), + 'packages/app/package.json': JSON.stringify({ + name: 'app', + version: '1.0.0', + dependencies: { + 'lib-a': '^1.0.0', + 'alias-b': 'workspace:lib-b@*', + 'alias-c': 'npm:@scope/lib-c@^2.0.0', + 'external-pkg': '^5.0.0', + 'mismatched-alias': 'npm:lib-b@^9.0.0', + }, + devDependencies: { + 'lib-b': 'workspace:*', + }, + }), + 'packages/lib-a/package.json': JSON.stringify({ + name: 'lib-a', + version: '1.2.3', + }), + 'packages/lib-b/package.json': JSON.stringify({ + name: 'lib-b', + version: '2.0.0', + }), + 'packages/lib-c/package.json': JSON.stringify({ + name: '@scope/lib-c', + version: '2.5.0', + }), + }, + '/root' + ); + + const results = await createNodes[1]( + [ + 'packages/app/package.json', + 'packages/lib-a/package.json', + 'packages/lib-b/package.json', + 'packages/lib-c/package.json', + ], + undefined, + context + ); + + const app = getProject(results, 'packages/app/package.json'); + expect(app.metadata.js.packageDependencies).toEqual({ + dependencies: { + 'lib-a': { + rawSpecifier: '^1.0.0', + requestedPackageName: 'lib-a', + }, + 'alias-b': { + rawSpecifier: 'workspace:lib-b@*', + requestedPackageName: 'lib-b', + }, + 'alias-c': { + rawSpecifier: 'npm:@scope/lib-c@^2.0.0', + requestedPackageName: '@scope/lib-c', + }, + }, + devDependencies: { + 'lib-b': { + rawSpecifier: 'workspace:*', + requestedPackageName: 'lib-b', + }, + }, + }); + + const libA = getProject(results, 'packages/lib-a/package.json'); + expect(libA.metadata.js.packageDependencies).toBeUndefined(); + }); + + it('should recompute descriptors on cache hits and never store them in the cache', async () => { + // Use plain and npm entries; workspace: would fail validation after + // the target rename. + const appJson = { + name: 'app', + version: '1.0.0', + dependencies: { + 'lib-b': '^2.0.0', + 'alias-b': 'npm:lib-b@^2.0.0', + }, + }; + const files = { + 'package.json': JSON.stringify({ + name: 'root', + workspaces: ['packages/*'], + }), + 'packages/app/package.json': JSON.stringify(appJson), + 'packages/lib-b/package.json': JSON.stringify({ + name: 'lib-b', + version: '2.0.0', + }), + }; + vol.fromJSON(files, '/root'); + + // Seed a cache hit without targets or dependency descriptors. + const hash = hashObject({ + ...appJson, + root: 'packages/app', + isInPackageManagerWorkspaces: true, + nxVersion, + }); + const cache = new PluginCache(packageJsonCachePath, {}); + cache.set(hash, { + root: 'packages/app', + name: 'app', + metadata: { + description: undefined, + targetGroups: {}, + js: { + packageName: 'app', + packageVersion: '1.0.0', + packageExports: undefined, + packageMain: undefined, + isInPackageManagerWorkspaces: true, + }, + }, + }); + // the plugin loads the cache from this path on every run + cache.writeToDisk(); + + const configFiles = [ + 'packages/app/package.json', + 'packages/lib-b/package.json', + ]; + const firstRun = await createNodes[1](configFiles, undefined, context); + const firstApp = getProject(firstRun, 'packages/app/package.json'); + expect(firstApp.targets).toBeUndefined(); + expect(firstApp.metadata.js.packageDependencies).toEqual({ + dependencies: { + 'lib-b': { + rawSpecifier: '^2.0.0', + requestedPackageName: 'lib-b', + }, + 'alias-b': { + rawSpecifier: 'npm:lib-b@^2.0.0', + requestedPackageName: 'lib-b', + }, + }, + }); + expect( + (new PluginCache(packageJsonCachePath).get(hash) as any).metadata.js + .packageDependencies + ).toBeUndefined(); + + vol.fromJSON( + { + ...files, + 'packages/lib-b/package.json': JSON.stringify({ + name: 'lib-b-renamed', + version: '2.0.0', + }), + }, + '/root' + ); + + const secondRun = await createNodes[1](configFiles, undefined, context); + const secondApp = getProject(secondRun, 'packages/app/package.json'); + expect(secondApp.targets).toBeUndefined(); + expect(secondApp.metadata.js.packageDependencies).toBeUndefined(); + }); + }); }); diff --git a/packages/nx/src/plugins/package-json/create-nodes.ts b/packages/nx/src/plugins/package-json/create-nodes.ts index d5fde1575f3..c6ec6c22327 100644 --- a/packages/nx/src/plugins/package-json/create-nodes.ts +++ b/packages/nx/src/plugins/package-json/create-nodes.ts @@ -23,6 +23,8 @@ import { import { joinPathFragments } from '../../utils/path'; import { nxVersion } from '../../utils/versions'; import { createNodesFromFiles, CreateNodes } from '../../project-graph/plugins'; +import { getWorkspacePackageDependencies } from '../js/utils/dependency-specifiers'; +import { findInvalidWorkspaceAliases } from './validate-workspace-aliases'; import { basename } from 'path'; import { hashObject } from '../../hasher/file-hasher'; import { @@ -66,8 +68,65 @@ export const createNodes: CreateNodes = [ context.workspaceRoot ); + // The root manifest participates in the package manager's install even + // when it is not an Nx project, so alias validation checks it too. + const isWorkspaceManifest = (packageJsonPath: string) => + packageJsonPath === 'package.json' || + isInPackageJsonWorkspaces(packageJsonPath); + + const { packageJsonContents, getWorkspacePackageVersion } = + preloadWorkspacePackages( + packageJsons, + (packageJsonPath) => + isWorkspaceManifest(packageJsonPath) || + isNextToProjectJson(packageJsonPath), + readJson + ); + + // Skip batch alias validation after any workspace manifest parse + // failure; incomplete names could cause false missing-target errors. + const workspacePackageNames = new Set(); + let workspacePackageNamesComplete = true; + for (const packageJsonPath of packageJsons) { + if (!isWorkspaceManifest(packageJsonPath)) { + continue; + } + const json = packageJsonContents.get(packageJsonPath); + if (!json) { + workspacePackageNamesComplete = false; + } else if (json.name) { + workspacePackageNames.add(json.name); + } + } + return createNodesFromFiles( (packageJsonPath, options, context) => { + const json = packageJsonContents.get(packageJsonPath); + + // Re-read failed workspace manifests here so createNodesFromFiles + // attributes parse errors to their files; a failed root manifest + // would otherwise be swallowed by the workspaces-pattern fallback and + // skipped as a non-project. + if (!json && isWorkspaceManifest(packageJsonPath)) { + readJson(packageJsonPath); + } + + // Validated before the non-project skip below so a root manifest + // without an "nx" property is still checked. + if ( + json && + workspacePackageNamesComplete && + isWorkspaceManifest(packageJsonPath) + ) { + const aliasIssues = findInvalidWorkspaceAliases( + json, + workspacePackageNames + ); + if (aliasIssues.length > 0) { + throw new Error(aliasIssues.join('\n\n')); + } + } + const isInPackageManagerWorkspaces = isInPackageJsonWorkspaces(packageJsonPath); if ( @@ -78,12 +137,17 @@ export const createNodes: CreateNodes = [ return null; } - return createNodeFromPackageJson( - packageJsonPath, - context.workspaceRoot, - cache, - isInPackageManagerWorkspaces, - packageManagerCommand + return attachPackageDependencies( + createNodeFromPackageJson( + packageJsonPath, + context.workspaceRoot, + cache, + isInPackageManagerWorkspaces, + packageManagerCommand, + json + ), + json, + getWorkspacePackageVersion ); }, packageJsons, @@ -93,6 +157,77 @@ export const createNodes: CreateNodes = [ }, ]; +/** + * Preloads eligible manifests and package versions; leaves read failures for + * per-file processing. + */ +export function preloadWorkspacePackages( + packageJsonPaths: string[], + isEligible: (packageJsonPath: string) => boolean, + readJson: (packageJsonPath: string) => PackageJson +): { + packageJsonContents: Map; + getWorkspacePackageVersion: ( + packageName: string + ) => string | null | undefined; +} { + const packageJsonContents = new Map(); + const workspacePackageVersions = new Map(); + for (const packageJsonPath of packageJsonPaths) { + if (!isEligible(packageJsonPath)) { + continue; + } + try { + const json: PackageJson = readJson(packageJsonPath); + packageJsonContents.set(packageJsonPath, json); + if (json.name) { + workspacePackageVersions.set(json.name, json.version ?? null); + } + } catch {} + } + return { + packageJsonContents, + getWorkspacePackageVersion: (packageName) => + workspacePackageVersions.get(packageName), + }; +} + +/** + * Attaches workspace dependency metadata to a project clone so cross-manifest + * data never enters the per-file cache. + */ +export function attachPackageDependencies( + result: ReturnType, + json: PackageJson | undefined, + getWorkspacePackageVersion: (packageName: string) => string | null | undefined +): ReturnType { + if (!json) { + return result; + } + const packageDependencies = getWorkspacePackageDependencies( + json, + getWorkspacePackageVersion + ); + if (!packageDependencies) { + return result; + } + const [root, project] = Object.entries(result.projects)[0]; + if (!project.metadata?.js) { + return result; + } + return { + projects: { + [root]: { + ...project, + metadata: { + ...project.metadata, + js: { ...project.metadata.js, packageDependencies }, + }, + }, + }, + }; +} + function splitConfigFiles(configFiles: readonly string[]): { packageJsons: string[]; projectJsonRoots: Set; @@ -184,9 +319,11 @@ export function createNodeFromPackageJson( workspaceRoot: string, cache: PackageJsonConfigurationCache, isInPackageManagerWorkspaces: boolean, - packageManagerCommand: PackageManagerCommands + packageManagerCommand: PackageManagerCommands, + preloadedPackageJson?: PackageJson ) { - const json: PackageJson = readJsonFile(join(workspaceRoot, pkgJsonPath)); + const json: PackageJson = + preloadedPackageJson ?? readJsonFile(join(workspaceRoot, pkgJsonPath)); const projectRoot = dirname(pkgJsonPath); diff --git a/packages/nx/src/plugins/package-json/validate-workspace-aliases.spec.ts b/packages/nx/src/plugins/package-json/validate-workspace-aliases.spec.ts new file mode 100644 index 00000000000..6cb1c3de149 --- /dev/null +++ b/packages/nx/src/plugins/package-json/validate-workspace-aliases.spec.ts @@ -0,0 +1,127 @@ +import { + findClosePackageNames, + findInvalidWorkspaceAliases, +} from './validate-workspace-aliases'; + +describe('findInvalidWorkspaceAliases', () => { + const workspacePackageNames = new Set([ + '@acme/missing-lib', + '@acme/other-lib', + 'lib-a', + ]); + + it('should return no issues for valid specifiers', () => { + expect( + findInvalidWorkspaceAliases( + { + name: 'app', + version: '1.0.0', + dependencies: { + 'lib-a': '^1.0.0', + 'alias-a': 'workspace:lib-a@*', + 'alias-b': 'workspace:@acme/missing-lib@^1.0.0', + external: '^5.0.0', + 'registry-alias': 'npm:@acme/ghost@*', + 'bare-workspace': 'workspace:*', + 'not-a-string': 42 as unknown as string, + }, + }, + workspacePackageNames + ) + ).toEqual([]); + }); + + it('should report a target-bearing alias whose package is not a workspace package, with a suggestion and repair hint', () => { + const issues = findInvalidWorkspaceAliases( + { + name: 'app', + version: '1.0.0', + dependencies: { + 'alias-name': 'workspace:@acme/mssing-lib@*', + }, + }, + workspacePackageNames + ); + + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchInlineSnapshot(` + "Invalid workspace dependency alias "alias-name": "workspace:@acme/mssing-lib@*". + The requested package "@acme/mssing-lib" was not found among this repository's package-manager workspaces. + Did you mean "@acme/missing-lib"? + Fix the package name or add "@acme/mssing-lib" to the package-manager workspace configuration. If "alias-name" should resolve from the registry instead, replace "workspace:@acme/mssing-lib@*" with "npm:@acme/mssing-lib@*"." + `); + }); + + it('should omit the suggestion line when no close candidate exists', () => { + const issues = findInvalidWorkspaceAliases( + { + name: 'app', + version: '1.0.0', + devDependencies: { + 'alias-name': 'workspace:completely-unrelated@*', + }, + }, + workspacePackageNames + ); + + expect(issues).toHaveLength(1); + expect(issues[0]).not.toContain('Did you mean'); + }); + + it('should report each invalid alias across collections', () => { + const issues = findInvalidWorkspaceAliases( + { + name: 'app', + version: '1.0.0', + dependencies: { a: 'workspace:ghost-a@*' }, + optionalDependencies: { b: 'workspace:ghost-b@^1.0.0' }, + peerDependencies: { c: 'workspace:lib-a@*' }, + }, + workspacePackageNames + ); + + expect(issues).toHaveLength(2); + expect(issues[0]).toContain('"a": "workspace:ghost-a@*"'); + expect(issues[1]).toContain('"b": "workspace:ghost-b@^1.0.0"'); + }); + + it('should not report the malformed no-separator scoped form', () => { + // A missing @ separator is malformed alias syntax, not a + // missing-target alias. + expect( + findInvalidWorkspaceAliases( + { + name: 'app', + version: '1.0.0', + dependencies: { 'alias-name': 'workspace:@acme/ghost' }, + }, + workspacePackageNames + ) + ).toEqual([]); + }); +}); + +describe('findClosePackageNames', () => { + it('should return close candidates sorted by distance, scope preference, then lexically', () => { + expect( + findClosePackageNames( + '@acme/utils', + new Set(['@acme/utils2', '@other/utils', '@acme/util']) + ) + ).toEqual(['@acme/util', '@acme/utils2']); + // distance tie: the same-scope candidate ranks first + expect( + findClosePackageNames( + '@acme/utils', + new Set(['@bcme/utils', '@acme/utuls']) + ) + ).toEqual(['@acme/utuls', '@bcme/utils']); + }); + + it('should cap results at three and honor the distance threshold', () => { + const candidates = new Set(['aaa1', 'aaa2', 'aaa3', 'aaa4', 'zzzzz']); + const result = findClosePackageNames('aaa0', candidates); + expect(result).toHaveLength(3); + expect(result).not.toContain('zzzzz'); + }); +}); diff --git a/packages/nx/src/plugins/package-json/validate-workspace-aliases.ts b/packages/nx/src/plugins/package-json/validate-workspace-aliases.ts new file mode 100644 index 00000000000..fbe0c405726 --- /dev/null +++ b/packages/nx/src/plugins/package-json/validate-workspace-aliases.ts @@ -0,0 +1,146 @@ +import type { PackageJson } from '../../utils/package-json'; +import { parseDependencySpecifier } from '../js/utils/dependency-specifiers'; + +const DEPENDENCY_COLLECTIONS = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', +] as const; + +/** + * Reports `workspace:@` aliases whose target is absent from the + * package-manager workspace names. Failing during graph construction + * surfaces the broken manifest early with a repair hint. + * + * `workspacePackageNames` must contain only packages the package manager + * treats as part of the install (the root package and packages matched by the + * workspace globs), so that out-of-workspaces fixtures never satisfy a name. + */ +export function findInvalidWorkspaceAliases( + packageJson: PackageJson, + workspacePackageNames: Set +): string[] { + const issues: string[] = []; + for (const collection of DEPENDENCY_COLLECTIONS) { + const dependencies = packageJson[collection]; + if (!dependencies) { + continue; + } + for (const [key, rawSpecifier] of Object.entries(dependencies)) { + if (typeof rawSpecifier !== 'string') { + continue; + } + const parsed = parseDependencySpecifier(rawSpecifier); + if ( + parsed.protocol !== 'workspace' || + parsed.requestedPackageName === null || + parsed.range === null || + workspacePackageNames.has(parsed.requestedPackageName) + ) { + continue; + } + issues.push( + buildInvalidWorkspaceAliasMessage( + key, + rawSpecifier, + parsed.requestedPackageName, + workspacePackageNames + ) + ); + } + } + return issues; +} + +function buildInvalidWorkspaceAliasMessage( + dependencyKey: string, + rawSpecifier: string, + requestedPackageName: string, + workspacePackageNames: Set +): string { + const lines = [ + `Invalid workspace dependency alias "${dependencyKey}": "${rawSpecifier}".`, + `The requested package "${requestedPackageName}" was not found among this repository's package-manager workspaces.`, + ]; + const suggestions = findClosePackageNames( + requestedPackageName, + workspacePackageNames + ); + if (suggestions.length === 1) { + lines.push(`Did you mean "${suggestions[0]}"?`); + } else if (suggestions.length > 1) { + const quoted = suggestions.map((s) => `"${s}"`); + lines.push( + `Did you mean one of ${quoted.slice(0, -1).join(', ')}, or ${quoted.at( + -1 + )}?` + ); + } + // Convert workspace: to npm: so registry fallback retains the alias target. + lines.push( + `Fix the package name or add "${requestedPackageName}" to the package-manager workspace configuration. If "${dependencyKey}" should resolve from the registry instead, replace "${rawSpecifier}" with "${rawSpecifier.replace( + /^workspace:/, + 'npm:' + )}".` + ); + return lines.join('\n'); +} + +/** + * Returns up to three workspace package names close to the given name, sorted + * by edit distance, same-scope preference, then lexically. Only names within + * a conservative distance threshold are returned, so a large monorepo does + * not get flooded with unrelated candidates. + */ +export function findClosePackageNames( + name: string, + candidates: Set +): string[] { + const threshold = Math.max(2, Math.floor(name.length * 0.2)); + const scope = name.startsWith('@') ? name.split('/')[0] : null; + const close: Array<{ + candidate: string; + distance: number; + sameScope: boolean; + }> = []; + for (const candidate of candidates) { + const distance = levenshtein(name, candidate, threshold); + if (distance > threshold) { + continue; + } + close.push({ + candidate, + distance, + sameScope: scope !== null && candidate.startsWith(`${scope}/`), + }); + } + return close + .sort( + (a, b) => + a.distance - b.distance || + Number(b.sameScope) - Number(a.sameScope) || + (a.candidate < b.candidate ? -1 : 1) + ) + .slice(0, 3) + .map((c) => c.candidate); +} + +function levenshtein(a: string, b: string, threshold: number): number { + if (Math.abs(a.length - b.length) > threshold) { + return threshold + 1; + } + let previous = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + const current = [i]; + for (let j = 1; j <= b.length; j++) { + current[j] = Math.min( + previous[j] + 1, + current[j - 1] + 1, + previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1) + ); + } + previous = current; + } + return previous[b.length]; +} diff --git a/packages/nx/src/project-graph/project-graph.spec.ts b/packages/nx/src/project-graph/project-graph.spec.ts index 971971f0dec..5fbad987af8 100644 --- a/packages/nx/src/project-graph/project-graph.spec.ts +++ b/packages/nx/src/project-graph/project-graph.spec.ts @@ -4,7 +4,12 @@ // `buildProjectGraphAndSourceMapsWithoutDaemon` implementation, so opt out. vi.unmock('./project-graph'); -import { buildProjectGraphAndSourceMapsWithoutDaemon } from './project-graph'; +import { + buildProjectGraphAndSourceMapsWithoutDaemon, + handleProjectGraphError, +} from './project-graph'; +import { AggregateCreateNodesError, ProjectGraphError } from './error-types'; +import { output } from '../utils/output'; import * as plugins from './plugins/get-plugins'; vi.mock('../utils/workspace-context', () => { @@ -107,3 +112,82 @@ describe('buildProjectGraphAndSourceMapsWithoutDaemon', () => { }); }); }); + +describe('handleProjectGraphError', () => { + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.NX_VERBOSE_LOGGING; + }); + + it('should print the nested error messages without --verbose', () => { + const outputErrorSpy = vi + .spyOn(output, 'error') + .mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('exit'); + }) as any); + + const aggregateError = new AggregateCreateNodesError( + [ + [ + 'packages/app/package.json', + new Error( + 'Invalid workspace dependency alias "alias-name": "workspace:@acme/missing-lib@*".' + ), + ], + ], + [] + ); + const projectGraphError = new ProjectGraphError( + [aggregateError], + { nodes: {}, dependencies: {} } as any, + {} + ); + + expect(() => + handleProjectGraphError({ exitOnError: true }, projectGraphError) + ).toThrow('exit'); + + expect(outputErrorSpy).toHaveBeenCalledWith({ + title: projectGraphError.message, + bodyLines: [aggregateError.message], + }); + expect(outputErrorSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + bodyLines: expect.arrayContaining([ + expect.stringContaining('Pass --verbose'), + ]), + }) + ); + }); + + it('should print the stacks with --verbose', () => { + process.env.NX_VERBOSE_LOGGING = 'true'; + const outputErrorSpy = vi + .spyOn(output, 'error') + .mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('exit'); + }) as any); + + const projectGraphError = new ProjectGraphError( + [ + new AggregateCreateNodesError( + [['package.json', new Error('boom')]], + [] + ), + ], + { nodes: {}, dependencies: {} } as any, + {} + ); + + expect(() => + handleProjectGraphError({ exitOnError: true }, projectGraphError) + ).toThrow('exit'); + + expect(outputErrorSpy).toHaveBeenCalledWith({ + title: projectGraphError.message, + bodyLines: [projectGraphError.stack], + }); + }); +}); diff --git a/packages/nx/src/project-graph/project-graph.ts b/packages/nx/src/project-graph/project-graph.ts index 2d50f6c4af1..4bd9b15a826 100644 --- a/packages/nx/src/project-graph/project-graph.ts +++ b/packages/nx/src/project-graph/project-graph.ts @@ -206,9 +206,11 @@ export function handleProjectGraphError(opts: { exitOnError: boolean }, e) { if (e instanceof ProjectGraphError) { let title = e.message; + // Mirrors handleErrors' ProjectGraphError branch (utils/handle-errors.ts): + // non-verbose output shows the nested messages, not the stack. const bodyLines = isVerbose ? [e.stack] - : ['Pass --verbose to see the stacktraces.']; + : e.getErrors().map((error) => error.message); output.error({ title, diff --git a/packages/nx/src/utils/package-json.ts b/packages/nx/src/utils/package-json.ts index a54c029d52f..e95ebad63b9 100644 --- a/packages/nx/src/utils/package-json.ts +++ b/packages/nx/src/utils/package-json.ts @@ -9,6 +9,7 @@ import { NxJsonConfiguration } from '../config/nx-json'; import { ProjectConfiguration, ProjectMetadata, + ProjectPackageDependencies, TargetConfiguration, } from '../config/workspace-json-project-json'; import type { Tree } from '../generators/tree'; @@ -209,6 +210,7 @@ export type PackageJsonProjectMetadata = { packageExports: PackageJson['exports']; packageMain: PackageJson['main']; isInPackageManagerWorkspaces: boolean; + packageDependencies?: ProjectPackageDependencies; }; };