diff --git a/docs/generated/devkit/ProjectGraphProjectNode.md b/docs/generated/devkit/ProjectGraphProjectNode.md index a641649d8010b..12a785f2aa5be 100644 --- a/docs/generated/devkit/ProjectGraphProjectNode.md +++ b/docs/generated/devkit/ProjectGraphProjectNode.md @@ -8,7 +8,7 @@ A node describing a project in a workspace - [data](../../devkit/documents/ProjectGraphProjectNode#data): ProjectConfiguration & Object - [name](../../devkit/documents/ProjectGraphProjectNode#name): string -- [type](../../devkit/documents/ProjectGraphProjectNode#type): "lib" | "app" | "e2e" +- [type](../../devkit/documents/ProjectGraphProjectNode#type): "app" | "e2e" | "lib" ## Properties @@ -28,4 +28,4 @@ Additional metadata about a project ### type -• **type**: `"lib"` \| `"app"` \| `"e2e"` +• **type**: `"app"` \| `"e2e"` \| `"lib"` diff --git a/e2e/nx/src/graph-ts-solution.test.ts b/e2e/nx/src/graph-ts-solution.test.ts new file mode 100644 index 0000000000000..cb0e95c67dc61 --- /dev/null +++ b/e2e/nx/src/graph-ts-solution.test.ts @@ -0,0 +1,343 @@ +import { + checkFilesExist, + cleanupProject, + createFile, + getPackageManagerCommand, + getSelectedPackageManager, + newProject, + readJson, + runCLI, + runCommand, + updateFile, + updateJson, +} from '@nx/e2e/utils'; +import { basename } from 'node:path'; + +describe('Graph - TS solution setup', () => { + beforeAll(() => { + newProject({ + packages: ['@nx/js'], + preset: 'ts', + }); + }); + + afterAll(() => { + cleanupProject(); + }); + + it('should detect dependencies from local packages included in the package manager workspaces', () => { + const pm = getSelectedPackageManager(); + const pmc = getPackageManagerCommand({ packageManager: pm }); + + createPackage('pkg-parent', { sourceFilePaths: ['index.ts'] }); + + // invalid definition with no entry fields in package.json + createPackage('pkg1'); + // only `main` + createPackage('pkg2', { + packageJsonEntryFields: { main: './dist/src/index.js' }, + }); + // invalid empty exports, no code is accessible + createPackage('pkg3', { + packageJsonEntryFields: { + main: './dist/src/index.js', + types: './dist/src/index.d.ts', + exports: {}, + }, + }); + // '.' entry point + createPackage('pkg4', { + packageJsonEntryFields: { + exports: { + '.': { + types: './dist/src/index.d.ts', + default: './dist/src/index.js', + }, + }, + }, + }); + // conditional exports + createPackage('pkg5', { + packageJsonEntryFields: { + exports: { + types: './dist/src/index.d.ts', + default: './dist/src/index.js', + }, + }, + }); + // exports set to a string + createPackage('pkg6', { + packageJsonEntryFields: { + exports: './dist/src/index.js', + }, + }); + // '.' entry point set to source (non buildable library) + createPackage('pkg7', { + packageJsonEntryFields: { + exports: { + '.': './src/index.ts', + }, + }, + }); + // matches a path alias that resolves correctly + createPackage('pkg8', { + packageJsonEntryFields: { + exports: { + '.': { + types: './dist/src/index.d.ts', + default: './dist/src/index.js', + }, + }, + }, + }); + // matches a path alias that doesn't resolve correctly, should still be + // picked up by the package manager workspaces fallback resolution + createPackage('pkg9', { + packageJsonEntryFields: { + exports: { + '.': { + types: './src/index.ts', + default: './src/index.ts', + }, + }, + }, + }); + // only named exports, no '.' entry point + createPackage('pkg10', { + packageJsonEntryFields: { + exports: { + './feature1': { + types: './dist/src/index.d.ts', + default: './dist/src/index.js', + }, + }, + }, + }); + // wildcard exports + createPackage('pkg11', { + sourceFilePaths: ['src/utils/util1.ts'], + packageJsonEntryFields: { + exports: { + './utils/*': { + types: './dist/src/utils/*.d.ts', + default: './dist/src/utils/*.js', + }, + }, + }, + }); + // restricted exports, should not be picked up as a dependency + createPackage('pkg12', { + packageJsonEntryFields: { + exports: { './feature1': null }, + }, + }); + // valid package that will be imported as @proj/pkg14 due to a TS path alias + createPackage('pkg13', { + packageJsonEntryFields: { + exports: { + '.': { + types: './src/index.ts', + default: './src/index.ts', + }, + }, + }, + }); + // valid package that we'll be foreshadowed by a TS path alias pointing to + // pkg13, so should not be picked up as a dependency + createPackage('pkg14', { + packageJsonEntryFields: { + exports: { + '.': { + types: './src/index.ts', + default: './src/index.ts', + }, + }, + }, + }); + // project outside of the package manager workspaces + createPackage('lib1', { root: 'libs/lib1' }); + + if (pm === 'pnpm') { + // for pnpm we need to add the local packages as dependencies to each consumer package.json + // we keep out the ones we want to validate won't be picked up as dependencies, otherwise + // they would be included because the package.json depends on them + updateJson('packages/pkg-parent/package.json', (json) => { + json.dependencies ??= {}; + json.dependencies['@proj/pkg2'] = 'workspace:*'; + json.dependencies['@proj/pkg4'] = 'workspace:*'; + json.dependencies['@proj/pkg5'] = 'workspace:*'; + json.dependencies['@proj/pkg6'] = 'workspace:*'; + json.dependencies['@proj/pkg7'] = 'workspace:*'; + json.dependencies['@proj/pkg8'] = 'workspace:*'; + json.dependencies['@proj/pkg9'] = 'workspace:*'; + json.dependencies['@proj/pkg10'] = 'workspace:*'; + json.dependencies['@proj/pkg11'] = 'workspace:*'; + json.dependencies['@proj/pkg13'] = 'workspace:*'; + return json; + }); + } + + runCommand(pmc.install); + + updateJson('tsconfig.base.json', (json) => { + json.compilerOptions.baseUrl = '.'; + json.compilerOptions.paths = { + '@proj/pkg8': ['packages/pkg8/src/index.ts'], + '@proj/pkg9': ['dist/packages/pkg9'], + '@proj/pkg14': ['packages/pkg13/src/index.ts'], + }; + return json; + }); + // add TS project references to all packages, including the invalid ones + // so they are all built ahead of pkg-parent and we can assert the test + // correctly sets them up as invalid imports + updateJson('packages/pkg-parent/tsconfig.json', (json) => { + json.references = [ + { path: '../pkg1' }, + { path: '../pkg2' }, + { path: '../pkg3' }, + { path: '../pkg4' }, + { path: '../pkg5' }, + { path: '../pkg6' }, + { path: '../pkg7' }, + { path: '../pkg8' }, + { path: '../pkg9' }, + { path: '../pkg10' }, + { path: '../pkg11' }, + { path: '../pkg12' }, + { path: '../pkg13' }, + { path: '../pkg14' }, + ]; + return json; + }); + updateFile( + 'packages/pkg-parent/index.ts', + () => ` + import { pkg1 } from '@proj/pkg1'; + import { pkg2 } from '@proj/pkg2'; + import { pkg3 } from '@proj/pkg3'; + import { pkg4 } from '@proj/pkg4'; + import { pkg5 } from '@proj/pkg5'; + import { pkg6 } from '@proj/pkg6'; + import { pkg7 } from '@proj/pkg7'; + import { pkg8 } from '@proj/pkg8'; + import { pkg9 } from '@proj/pkg9'; + import { pkg10 } from '@proj/pkg10/feature1'; + import { util1 } from '@proj/pkg11/utils/util1'; + import { pkg12 } from '@proj/pkg12/feature1'; + import { pkg13 } from '@proj/pkg14'; + // this is an invalid import that doesn't match any TS path alias and + // it's not included in the package manager workspaces, it should not + // be picked up as a dependency + import { lib1 } from '@proj/lib1'; + + // use the correct imports, leave out the invalid ones so it's easier to remove them later + export const pkgParent = pkg2 + pkg4 + pkg5 + pkg6 + pkg7 + pkg8 + pkg9 + pkg10 + util1 + pkg13; + ` + ); + + runCLI(`graph --file graph.json`); + + const { graph } = readJson('graph.json'); + // pkg1, pkg3, pkg12, pkg14, and lib1 are not detected as dependencies + expect( + graph.dependencies['@proj/pkg-parent'].map((d) => d.target) + ).toStrictEqual([ + '@proj/pkg2', + '@proj/pkg4', + '@proj/pkg5', + '@proj/pkg6', + '@proj/pkg7', + '@proj/pkg8', + '@proj/pkg9', + '@proj/pkg10', + '@proj/pkg11', + '@proj/pkg13', + ]); + + // assert build fails due to the invalid imports + const output = runCommand(`${pmc.exec} tsc -b packages/pkg-parent`); + expect(output).toContain( + `error TS2307: Cannot find module '@proj/pkg1' or its corresponding type declarations.` + ); + expect(output).toContain( + `error TS2307: Cannot find module '@proj/pkg3' or its corresponding type declarations.` + ); + expect(output).toContain( + `error TS2307: Cannot find module '@proj/pkg12/feature1' or its corresponding type declarations.` + ); + expect(output).toContain( + `error TS2307: Cannot find module '@proj/lib1' or its corresponding type declarations.` + ); + + // remove the invalid imports + updateFile('packages/pkg-parent/index.ts', (content) => + content + .replace(`import { pkg1 } from '@proj/pkg1';`, '') + .replace(`import { pkg3 } from '@proj/pkg3';`, '') + .replace(`import { pkg12 } from '@proj/pkg12/feature1';`, '') + .replace(`import { lib1 } from '@proj/lib1';`, '') + ); + + // assert build succeeds, tsc outputs nothing when successful + expect(runCommand(`${pmc.exec} tsc -b packages/pkg-parent`)).toBe(''); + checkFilesExist( + 'packages/pkg-parent/dist/index.js', + 'packages/pkg-parent/dist/index.d.ts' + ); + }); + + function createPackage( + name: string, + options?: { + root?: string; + sourceFilePaths?: string[]; + packageJsonEntryFields?: { + main?: string; + types?: string; + exports?: string | Record; + }; + } + ): void { + const root = options?.root ?? `packages/${name}`; + + createFile( + `${root}/package.json`, + JSON.stringify( + { + name: `@proj/${name}`, + version: '1.0.0', + ...options?.packageJsonEntryFields, + }, + null, + 2 + ) + ); + createFile( + `${root}/tsconfig.json`, + JSON.stringify( + { + extends: '../../tsconfig.base.json', + compilerOptions: { + outDir: './dist', + emitDeclarationOnly: false, + }, + include: ['**/*.ts'], + }, + null, + 2 + ) + ); + + const sourceFilePaths = options?.sourceFilePaths ?? ['src/index.ts']; + for (const sourceFilePath of sourceFilePaths) { + const fileName = basename(sourceFilePath, '.ts'); + createFile( + `${root}/${sourceFilePath}`, + `export const ${ + fileName !== 'index' ? fileName : name + } = '${name} - ${fileName}';` + ); + } + } +}); diff --git a/e2e/utils/command-utils.ts b/e2e/utils/command-utils.ts index 4c6907291a3dc..58f2646cd2d8d 100644 --- a/e2e/utils/command-utils.ts +++ b/e2e/utils/command-utils.ts @@ -5,6 +5,7 @@ import { ensureCypressInstallation, ensurePlaywrightBrowsersInstallation, getNpmMajorVersion, + getPnpmVersion, getPublishedVersion, getStrippedEnvironmentVariables, getYarnMajorVersion, @@ -17,6 +18,7 @@ import * as isCI from 'is-ci'; import { fileExists, readJson, updateJson } from './file-utils'; import { logError, stripConsoleColors } from './log-utils'; import { existsSync } from 'fs-extra'; +import { gte } from 'semver'; export interface RunCmdOpts { silenceError?: boolean; @@ -111,9 +113,11 @@ export function getPackageManagerCommand({ addDev: string; list: string; runLerna: string; + exec: string; } { const npmMajorVersion = getNpmMajorVersion(); const yarnMajorVersion = getYarnMajorVersion(path); + const pnpmVersion = getPnpmVersion(); const publishedVersion = getPublishedVersion(); const isYarnWorkspace = fileExists(join(path, 'package.json')) ? readJson('package.json').workspaces @@ -135,6 +139,7 @@ export function getPackageManagerCommand({ addDev: `npm install --legacy-peer-deps -D`, list: 'npm ls --depth 10', runLerna: `npx lerna`, + exec: 'npx', }, yarn: { createWorkspace: `npx ${ @@ -156,6 +161,7 @@ export function getPackageManagerCommand({ yarnMajorVersion && +yarnMajorVersion >= 2 ? 'yarn lerna' : `yarn --silent lerna`, + exec: 'yarn', }, // Pnpm 3.5+ adds nx to pnpm: { @@ -170,6 +176,7 @@ export function getPackageManagerCommand({ addDev: isPnpmWorkspace ? 'pnpm add -Dw' : 'pnpm add -D', list: 'pnpm ls --depth 10', runLerna: `pnpm exec lerna`, + exec: pnpmVersion && gte(pnpmVersion, '6.13.0') ? 'pnpm exec' : 'pnpx', }, bun: { createWorkspace: `bunx create-nx-workspace@${publishedVersion}`, @@ -183,6 +190,7 @@ export function getPackageManagerCommand({ addDev: 'bun install -D', list: 'bun pm ls', runLerna: `bunx lerna`, + exec: 'bun', }, }[packageManager.trim() as PackageManager]; } diff --git a/e2e/utils/get-env-info.ts b/e2e/utils/get-env-info.ts index a8938352937a0..c2d757276dbc7 100644 --- a/e2e/utils/get-env-info.ts +++ b/e2e/utils/get-env-info.ts @@ -99,6 +99,17 @@ export function getYarnMajorVersion(path: string): string | undefined { } } +export function getPnpmVersion(): string | undefined { + try { + const pnpmVersion = execSync(`pnpm -v`, { + encoding: 'utf-8', + }).trim(); + return pnpmVersion; + } catch { + return undefined; + } +} + export function getLatestLernaVersion(): string { const lernaVersion = execSync(`npm view lerna version`, { encoding: 'utf-8', diff --git a/packages/nx/plugins/package-json.ts b/packages/nx/plugins/package-json.ts index 3ea2d5a85e627..53a032fe0ca13 100644 --- a/packages/nx/plugins/package-json.ts +++ b/packages/nx/plugins/package-json.ts @@ -1,6 +1,9 @@ import { createNodesFromFiles, NxPluginV2 } from '../src/project-graph/plugins'; import { workspaceRoot } from '../src/utils/workspace-root'; -import { createNodeFromPackageJson } from '../src/plugins/package-json'; +import { + buildPackageJsonWorkspacesMatcher, + createNodeFromPackageJson, +} from '../src/plugins/package-json'; import { workspaceDataDirectory } from '../src/utils/cache-directory'; import { join } from 'path'; import { ProjectConfiguration } from '../src/config/workspace-json-project-json'; @@ -31,8 +34,19 @@ const plugin: NxPluginV2 = { (configFiles, options, context) => { const cache = readPackageJsonConfigurationCache(); + const isInPackageJsonWorkspaces = buildPackageJsonWorkspacesMatcher( + context.workspaceRoot, + (f) => readJsonFile(join(context.workspaceRoot, f)) + ); + const result = createNodesFromFiles( - (f) => createNodeFromPackageJson(f, workspaceRoot, cache), + (packageJsonPath) => + createNodeFromPackageJson( + packageJsonPath, + workspaceRoot, + cache, + isInPackageJsonWorkspaces(packageJsonPath) + ), configFiles, options, context diff --git a/packages/nx/src/config/schema-utils.ts b/packages/nx/src/config/schema-utils.ts index 82692713e63fa..d46f9471a7980 100644 --- a/packages/nx/src/config/schema-utils.ts +++ b/packages/nx/src/config/schema-utils.ts @@ -1,7 +1,7 @@ import { existsSync } from 'fs'; import { extname, join } from 'path'; import { resolve as resolveExports } from 'resolve.exports'; -import { getPackageEntryPointsToProjectMap } from '../plugins/js/utils/packages'; +import { getWorkspacePackagesMetadata } from '../plugins/js/utils/packages'; import { registerPluginTSTranspiler } from '../project-graph/plugins'; import { normalizePath } from '../utils/path'; import type { ProjectConfiguration } from './workspace-json-project-json'; @@ -119,16 +119,16 @@ export function resolveSchema( }); } -let packageEntryPointsToProjectMap: Record; +let packageToProjectMap: Record; function tryResolveFromSource( path: string, directory: string, packageName: string, projects: Record ): string | null { - packageEntryPointsToProjectMap ??= - getPackageEntryPointsToProjectMap(projects); - const localProject = packageEntryPointsToProjectMap[packageName]; + packageToProjectMap ??= + getWorkspacePackagesMetadata(projects).packageToProjectMap; + const localProject = packageToProjectMap[packageName]; if (!localProject) { // it doesn't match any of the package names from the local projects return null; diff --git a/packages/nx/src/config/to-project-name.spec.ts b/packages/nx/src/config/to-project-name.spec.ts index 2a2a14d8261b4..0278f363b6288 100644 --- a/packages/nx/src/config/to-project-name.spec.ts +++ b/packages/nx/src/config/to-project-name.spec.ts @@ -58,6 +58,7 @@ describe('Workspaces', () => { "metadata": { "description": "my-package description", "js": { + "isInPackageManagerWorkspaces": true, "packageName": "my-package", }, "targetGroups": {}, diff --git a/packages/nx/src/config/workspace-json-project-json.ts b/packages/nx/src/config/workspace-json-project-json.ts index f6d42ca32c6c7..78c56cf71a814 100644 --- a/packages/nx/src/config/workspace-json-project-json.ts +++ b/packages/nx/src/config/workspace-json-project-json.ts @@ -140,6 +140,8 @@ export interface ProjectMetadata { js?: { packageName: string; packageExports?: PackageJson['exports']; + packageMain?: string; + isInPackageManagerWorkspaces?: boolean; }; } diff --git a/packages/nx/src/plugins/js/project-graph/build-dependencies/explicit-package-json-dependencies.spec.ts b/packages/nx/src/plugins/js/project-graph/build-dependencies/explicit-package-json-dependencies.spec.ts index d9a57dd5f14f6..ce0b37048f039 100644 --- a/packages/nx/src/plugins/js/project-graph/build-dependencies/explicit-package-json-dependencies.spec.ts +++ b/packages/nx/src/plugins/js/project-graph/build-dependencies/explicit-package-json-dependencies.spec.ts @@ -74,7 +74,11 @@ describe('explicit package json dependencies', () => { data: { root: 'libs/proj', metadata: { - js: { packageName: 'proj', packageExports: undefined }, + js: { + packageName: 'proj', + packageExports: undefined, + isInPackageManagerWorkspaces: true, + }, }, }, }, @@ -84,7 +88,11 @@ describe('explicit package json dependencies', () => { data: { root: 'libs/proj2', metadata: { - js: { packageName: 'proj2', packageExports: undefined }, + js: { + packageName: 'proj2', + packageExports: undefined, + isInPackageManagerWorkspaces: true, + }, }, }, }, @@ -94,7 +102,11 @@ describe('explicit package json dependencies', () => { data: { root: 'libs/proj4', metadata: { - js: { packageName: 'proj3', packageExports: undefined }, + js: { + packageName: 'proj3', + packageExports: undefined, + isInPackageManagerWorkspaces: true, + }, }, }, }, 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 a740e3c3263c7..b2b8d987a6591 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 @@ -221,6 +221,8 @@ describe('TargetProjectLocator', () => { js: { packageName: '@proj/child-pm-workspaces', packageExports: undefined, + isInPackageManagerWorkspaces: true, + packageMain: 'index.ts', }, }, }, @@ -1011,6 +1013,197 @@ describe('TargetProjectLocator', () => { expect(result).toEqual('npm:foo@0.0.1'); }); }); + + describe('findDependencyInWorkspaceProjects', () => { + it.each` + exports + ${undefined} + ${'dist/index.js'} + ${{}} + ${{ '.': 'dist/index.js' }} + ${{ './subpath': './dist/subpath.js' }} + ${{ import: './dist/index.js', default: './dist/index.js' }} + `( + 'should find "@org/pkg1" package as "pkg1" project when exports="$exports"', + ({ exports }) => { + let projects: Record = { + pkg1: { + name: 'pkg1', + type: 'lib' as const, + data: { + root: 'pkg1', + metadata: { + js: { + packageName: '@org/pkg1', + packageExports: exports, + isInPackageManagerWorkspaces: true, + }, + }, + }, + }, + }; + + const targetProjectLocator = new TargetProjectLocator( + projects, + {}, + new Map() + ); + const result = + targetProjectLocator.findDependencyInWorkspaceProjects('@org/pkg1'); + + expect(result).toEqual('pkg1'); + } + ); + + it('should not match "@org/pkg2" when there is no workspace project with that package name', () => { + let projects: Record = { + pkg1: { + name: 'pkg1', + type: 'lib' as const, + data: { + root: 'pkg1', + metadata: { + js: { + packageName: '@org/pkg1', + isInPackageManagerWorkspaces: true, + }, + }, + }, + }, + }; + + const targetProjectLocator = new TargetProjectLocator( + projects, + {}, + new Map() + ); + const result = + targetProjectLocator.findDependencyInWorkspaceProjects('@org/pkg2'); + + expect(result).toBeFalsy(); + }); + }); + + describe('findImportInWorkspaceProjects', () => { + it.each` + exports | importPath + ${'dist/index.js'} | ${'@org/pkg1'} + ${{ '.': 'dist/index.js' }} | ${'@org/pkg1'} + ${{ './subpath': './dist/subpath.js' }} | ${'@org/pkg1/subpath'} + ${{ './*': './dist/*.js' }} | ${'@org/pkg1/subpath'} + ${{ import: './dist/index.js', default: './dist/index.js' }} | ${'@org/pkg1'} + `( + 'should find "$importPath" as "pkg1" project when exports="$exports"', + ({ exports, importPath }) => { + let projects: Record = { + pkg1: { + name: 'pkg1', + type: 'lib' as const, + data: { + root: 'pkg1', + metadata: { + js: { + packageName: '@org/pkg1', + packageExports: exports, + isInPackageManagerWorkspaces: true, + }, + }, + }, + }, + }; + + const targetProjectLocator = new TargetProjectLocator( + projects, + {}, + new Map() + ); + const result = + targetProjectLocator.findImportInWorkspaceProjects(importPath); + + expect(result).toEqual('pkg1'); + } + ); + + it.each` + exports | importPath + ${'dist/index.js'} | ${'@org/pkg1'} + ${{ '.': 'dist/index.js' }} | ${'@org/pkg1'} + ${{ './subpath': './dist/subpath.js' }} | ${'@org/pkg1/subpath'} + ${{ './*': './dist/*.js' }} | ${'@org/pkg1/subpath'} + ${{ import: './dist/index.js', default: './dist/index.js' }} | ${'@org/pkg1'} + `( + 'should not find "$importPath" as "pkg1" project when exports="$exports" and isInPackageManagerWorkspaces is false', + ({ exports, importPath }) => { + let projects: Record = { + pkg1: { + name: 'pkg1', + type: 'lib' as const, + data: { + root: 'pkg1', + metadata: { + js: { + packageName: '@org/pkg1', + packageExports: exports, + isInPackageManagerWorkspaces: false, + }, + }, + }, + }, + }; + + const targetProjectLocator = new TargetProjectLocator( + projects, + {}, + new Map() + ); + const result = + targetProjectLocator.findImportInWorkspaceProjects(importPath); + + expect(result).toBeFalsy(); + } + ); + + it.each` + exports | importPath + ${undefined} | ${'@org/pkg1'} + ${{}} | ${'@org/pkg1'} + ${{ '.': 'dist/index.js' }} | ${'@org/pkg1/subpath'} + ${{ './subpath': './dist/subpath.js' }} | ${'@org/pkg1/subpath/extra-path'} + ${{ './*': './dist/*.js' }} | ${'@org/pkg1/subpath/extra-path'} + ${{ './feature': null }} | ${'@org/pkg1/feature'} + ${{ import: './dist/index.js', default: './dist/index.js' }} | ${'@org/pkg1/subpath'} + `( + 'should not match "$importPath" when exports="$exports"', + ({ exports, importPath }) => { + let projects: Record = { + pkg1: { + name: 'pkg1', + type: 'lib' as const, + data: { + root: 'pkg1', + metadata: { + js: { + packageName: '@org/pkg1', + packageExports: exports, + isInPackageManagerWorkspaces: true, + }, + }, + }, + }, + }; + + const targetProjectLocator = new TargetProjectLocator( + projects, + {}, + new Map() + ); + const result = + targetProjectLocator.findImportInWorkspaceProjects(importPath); + + expect(result).toBeFalsy(); + } + ); + }); }); describe('isBuiltinModuleImport()', () => { 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 e4752cd89852c..d97f3714bf381 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 @@ -13,7 +13,10 @@ import { isRelativePath, readJsonFile } from '../../../../utils/fileutils'; import { getPackageNameFromImportPath } from '../../../../utils/get-package-name-from-import-path'; import type { PackageJson } from '../../../../utils/package-json'; import { workspaceRoot } from '../../../../utils/workspace-root'; -import { getPackageEntryPointsToProjectMap } from '../../utils/packages'; +import { + getWorkspacePackagesMetadata, + matchImportToWildcardEntryPointsToProjectMap, +} from '../../utils/packages'; import { resolveRelativeToDir } from '../../utils/resolve-relative-to-dir'; import { getRootTsConfigFileName, @@ -45,10 +48,11 @@ export class TargetProjectLocator { private tsConfig = this.getRootTsConfig(); private paths = this.tsConfig.config?.compilerOptions?.paths; private typescriptResolutionCache = new Map(); - private packageEntryPointsToProjectMap: Record< - string, - ProjectGraphProjectNode - >; + private packagesMetadata: { + entryPointsToProjectMap: Record; + wildcardEntryPointsToProjectMap: Record; + packageToProjectMap: Record; + }; constructor( private readonly nodes: Record, @@ -142,7 +146,7 @@ export class TargetProjectLocator { // fall back to see if it's a locally linked workspace project where the // output might not exist yet - const localProject = this.findDependencyInWorkspaceProjects(importExpr); + const localProject = this.findImportInWorkspaceProjects(importExpr); if (localProject) { return localProject; } @@ -254,12 +258,25 @@ export class TargetProjectLocator { return undefined; } - findDependencyInWorkspaceProjects(dep: string): string | null { - this.packageEntryPointsToProjectMap ??= getPackageEntryPointsToProjectMap( - this.nodes + findImportInWorkspaceProjects(importPath: string): string | null { + this.packagesMetadata ??= getWorkspacePackagesMetadata(this.nodes); + + if (this.packagesMetadata.entryPointsToProjectMap[importPath]) { + return this.packagesMetadata.entryPointsToProjectMap[importPath].name; + } + + const project = matchImportToWildcardEntryPointsToProjectMap( + this.packagesMetadata.wildcardEntryPointsToProjectMap, + importPath ); - return this.packageEntryPointsToProjectMap[dep]?.name ?? null; + return project?.name; + } + + findDependencyInWorkspaceProjects(dep: string): string | null { + this.packagesMetadata ??= getWorkspacePackagesMetadata(this.nodes); + + return this.packagesMetadata.packageToProjectMap[dep]?.name; } private resolveImportWithTypescript( diff --git a/packages/nx/src/plugins/js/utils/packages.ts b/packages/nx/src/plugins/js/utils/packages.ts index 0625deedec688..349f38b8bd276 100644 --- a/packages/nx/src/plugins/js/utils/packages.ts +++ b/packages/nx/src/plugins/js/utils/packages.ts @@ -1,11 +1,20 @@ +import { minimatch } from 'minimatch'; import { join } from 'node:path/posix'; import type { ProjectGraphProjectNode } from '../../../config/project-graph'; import type { ProjectConfiguration } from '../../../config/workspace-json-project-json'; -export function getPackageEntryPointsToProjectMap< +export function getWorkspacePackagesMetadata< T extends ProjectGraphProjectNode | ProjectConfiguration ->(projects: Record): Record { - const result: Record = {}; +>( + projects: Record +): { + entryPointsToProjectMap: Record; + wildcardEntryPointsToProjectMap: Record; + packageToProjectMap: Record; +} { + const entryPointsToProjectMap: Record = {}; + const wildcardEntryPointsToProjectMap: Record = {}; + const packageToProjectMap: Record = {}; for (const project of Object.values(projects)) { const metadata = 'data' in project ? project.data.metadata : project.metadata; @@ -14,17 +23,75 @@ export function getPackageEntryPointsToProjectMap< continue; } - const { packageName, packageExports } = metadata.js; - if (!packageExports || typeof packageExports === 'string') { - // no `exports` or it points to a file, which would be the equivalent of - // an '.' export, in which case the package name is the entry point - result[packageName] = project; - } else { - for (const entryPoint of Object.keys(packageExports)) { - result[join(packageName, entryPoint)] = project; + const { + packageName, + packageExports, + packageMain, + isInPackageManagerWorkspaces, + } = metadata.js; + packageToProjectMap[packageName] = project; + + if (!isInPackageManagerWorkspaces) { + // it is not included in the package manager workspaces config, so we + // skip it since the exports information wouldn't be used by the Node.js + // resolution + continue; + } + + if (packageExports) { + if (typeof packageExports === 'string') { + // it points to a file, which would be the equivalent of an '.' export, + // in which case the package name is the entry point + entryPointsToProjectMap[packageName] = project; + } else { + for (const entryPoint of Object.keys(packageExports)) { + if (packageExports[entryPoint] === null) { + // if the entry point is restricted, we skip it + continue; + } + + if (entryPoint.startsWith('.')) { + // it is a relative subpath export + if (entryPoint.includes('*')) { + wildcardEntryPointsToProjectMap[join(packageName, entryPoint)] = + project; + } else { + entryPointsToProjectMap[join(packageName, entryPoint)] = project; + } + } else { + // it's a conditional export, so we use the package name as the entry point + // https://nodejs.org/api/packages.html#conditional-exports + entryPointsToProjectMap[packageName] = project; + } + } } + } else if (packageMain) { + // if there is no exports, but there is a main, the package name is the + // entry point + entryPointsToProjectMap[packageName] = project; } } - return result; + return { + entryPointsToProjectMap, + wildcardEntryPointsToProjectMap, + packageToProjectMap, + }; +} + +export function matchImportToWildcardEntryPointsToProjectMap< + T extends ProjectGraphProjectNode | ProjectConfiguration +>( + wildcardEntryPointsToProjectMap: Record, + importPath: string +): T | null { + if (!Object.keys(wildcardEntryPointsToProjectMap).length) { + return null; + } + + const matchingPair = Object.entries(wildcardEntryPointsToProjectMap).find( + ([key]) => minimatch(importPath, key) + ); + + return matchingPair?.[1]; } 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 eafa255ec13a3..d4edb6781110f 100644 --- a/packages/nx/src/plugins/package-json/create-nodes.spec.ts +++ b/packages/nx/src/plugins/package-json/create-nodes.spec.ts @@ -48,7 +48,7 @@ describe('nx package.json workspaces plugin', () => { '/root' ); - expect(createNodeFromPackageJson('package.json', '/root', {})) + expect(createNodeFromPackageJson('package.json', '/root', {}, false)) .toMatchInlineSnapshot(` { "projects": { @@ -56,7 +56,9 @@ describe('nx package.json workspaces plugin', () => { "metadata": { "description": undefined, "js": { + "isInPackageManagerWorkspaces": false, "packageExports": undefined, + "packageMain": undefined, "packageName": "root", }, "targetGroups": { @@ -95,7 +97,12 @@ describe('nx package.json workspaces plugin', () => { } `); expect( - createNodeFromPackageJson('packages/lib-a/package.json', '/root', {}) + createNodeFromPackageJson( + 'packages/lib-a/package.json', + '/root', + {}, + false + ) ).toMatchInlineSnapshot(` { "projects": { @@ -103,7 +110,9 @@ describe('nx package.json workspaces plugin', () => { "metadata": { "description": "lib-a description", "js": { + "isInPackageManagerWorkspaces": false, "packageExports": undefined, + "packageMain": undefined, "packageName": "lib-a", }, "targetGroups": { @@ -142,7 +151,12 @@ describe('nx package.json workspaces plugin', () => { } `); expect( - createNodeFromPackageJson('packages/lib-b/package.json', '/root', {}) + createNodeFromPackageJson( + 'packages/lib-b/package.json', + '/root', + {}, + false + ) ).toMatchInlineSnapshot(` { "projects": { @@ -157,7 +171,9 @@ describe('nx package.json workspaces plugin', () => { "metadata": { "description": "lib-b description", "js": { + "isInPackageManagerWorkspaces": false, "packageExports": undefined, + "packageMain": undefined, "packageName": "lib-b", }, "targetGroups": { @@ -265,7 +281,9 @@ describe('nx package.json workspaces plugin', () => { "metadata": { "description": undefined, "js": { + "isInPackageManagerWorkspaces": true, "packageExports": undefined, + "packageMain": undefined, "packageName": "vite", }, "targetGroups": {}, @@ -367,7 +385,9 @@ describe('nx package.json workspaces plugin', () => { "metadata": { "description": undefined, "js": { + "isInPackageManagerWorkspaces": true, "packageExports": undefined, + "packageMain": undefined, "packageName": "vite", }, "targetGroups": {}, @@ -465,7 +485,9 @@ describe('nx package.json workspaces plugin', () => { "metadata": { "description": undefined, "js": { + "isInPackageManagerWorkspaces": true, "packageExports": undefined, + "packageMain": undefined, "packageName": "vite", }, "targetGroups": {}, @@ -547,7 +569,9 @@ describe('nx package.json workspaces plugin', () => { "metadata": { "description": undefined, "js": { + "isInPackageManagerWorkspaces": true, "packageExports": undefined, + "packageMain": undefined, "packageName": "root", }, "targetGroups": { @@ -629,7 +653,9 @@ describe('nx package.json workspaces plugin', () => { "metadata": { "description": undefined, "js": { + "isInPackageManagerWorkspaces": true, "packageExports": undefined, + "packageMain": undefined, "packageName": "root", }, "targetGroups": { @@ -718,7 +744,9 @@ describe('nx package.json workspaces plugin', () => { "metadata": { "description": undefined, "js": { + "isInPackageManagerWorkspaces": true, "packageExports": undefined, + "packageMain": undefined, "packageName": "root", }, "targetGroups": {}, @@ -769,13 +797,17 @@ describe('nx package.json workspaces plugin', () => { ); expect( - createNodeFromPackageJson('apps/myapp/package.json', '/root', {}) + createNodeFromPackageJson('apps/myapp/package.json', '/root', {}, false) .projects['apps/myapp'].projectType ).toEqual('application'); expect( - createNodeFromPackageJson('packages/mylib/package.json', '/root', {}) - .projects['packages/mylib'].projectType + createNodeFromPackageJson( + 'packages/mylib/package.json', + '/root', + {}, + false + ).projects['packages/mylib'].projectType ).toEqual('library'); }); @@ -797,8 +829,9 @@ describe('nx package.json workspaces plugin', () => { ); expect( - createNodeFromPackageJson('package.json', '/root', {}).projects['.'] - .projectType + createNodeFromPackageJson('package.json', '/root', {}, false).projects[ + '.' + ].projectType ).toEqual('library'); }); @@ -823,19 +856,26 @@ describe('nx package.json workspaces plugin', () => { ); expect( - createNodeFromPackageJson('packages/mylib/package.json', '/root', {}) - .projects['packages/mylib'].projectType + createNodeFromPackageJson( + 'packages/mylib/package.json', + '/root', + {}, + false + ).projects['packages/mylib'].projectType ).toEqual('library'); expect( - createNodeFromPackageJson('example/package.json', '/root', {}).projects[ - 'example' - ].projectType + createNodeFromPackageJson('example/package.json', '/root', {}, false) + .projects['example'].projectType ).toBeUndefined(); }); - it('should store package name and exports in the project metadata', () => { + it('should store js package metadata', async () => { vol.fromJSON( { + 'package.json': JSON.stringify({ + name: 'repo', + workspaces: ['packages/*'], + }), 'packages/lib-a/package.json': JSON.stringify({ name: 'lib-a', description: 'lib-a description', @@ -845,59 +885,139 @@ describe('nx package.json workspaces plugin', () => { '.': './dist/index.js', }, }), + // not in package manager workspaces + 'libs/lib-b/package.json': JSON.stringify({ + name: 'lib-b', + description: 'lib-b description', + scripts: { test: 'jest' }, + exports: { + './package.json': './package.json', + '.': './dist/index.js', + }, + }), + // project.json so it's identified as a project + 'libs/lib-b/project.json': '{}', }, '/root' ); expect( - createNodeFromPackageJson('packages/lib-a/package.json', '/root', {}) + await createNodesV2[1]( + [ + 'package.json', + 'packages/lib-a/package.json', + 'libs/lib-b/package.json', + 'libs/lib-b/project.json', + ], + + undefined, + context + ) ).toMatchInlineSnapshot(` - { - "projects": { - "packages/lib-a": { - "metadata": { - "description": "lib-a description", - "js": { - "packageExports": { - ".": "./dist/index.js", - "./package.json": "./package.json", + [ + [ + "packages/lib-a/package.json", + { + "projects": { + "packages/lib-a": { + "metadata": { + "description": "lib-a description", + "js": { + "isInPackageManagerWorkspaces": true, + "packageExports": { + ".": "./dist/index.js", + "./package.json": "./package.json", + }, + "packageMain": undefined, + "packageName": "lib-a", + }, + "targetGroups": { + "NPM Scripts": [ + "test", + ], + }, }, - "packageName": "lib-a", - }, - "targetGroups": { - "NPM Scripts": [ - "test", + "name": "lib-a", + "root": "packages/lib-a", + "sourceRoot": "packages/lib-a", + "tags": [ + "npm:public", ], + "targets": { + "nx-release-publish": { + "dependsOn": [ + "^nx-release-publish", + ], + "executor": "@nx/js:release-publish", + "options": {}, + }, + "test": { + "executor": "nx:run-script", + "metadata": { + "runCommand": "npm run test", + "scriptContent": "jest", + }, + "options": { + "script": "test", + }, + }, + }, }, }, - "name": "lib-a", - "root": "packages/lib-a", - "sourceRoot": "packages/lib-a", - "tags": [ - "npm:public", - ], - "targets": { - "nx-release-publish": { - "dependsOn": [ - "^nx-release-publish", - ], - "executor": "@nx/js:release-publish", - "options": {}, - }, - "test": { - "executor": "nx:run-script", + }, + ], + [ + "libs/lib-b/package.json", + { + "projects": { + "libs/lib-b": { "metadata": { - "runCommand": "npm run test", - "scriptContent": "jest", + "description": "lib-b description", + "js": { + "isInPackageManagerWorkspaces": false, + "packageExports": { + ".": "./dist/index.js", + "./package.json": "./package.json", + }, + "packageMain": undefined, + "packageName": "lib-b", + }, + "targetGroups": { + "NPM Scripts": [ + "test", + ], + }, }, - "options": { - "script": "test", + "name": "lib-b", + "root": "libs/lib-b", + "sourceRoot": "libs/lib-b", + "tags": [ + "npm:public", + ], + "targets": { + "nx-release-publish": { + "dependsOn": [ + "^nx-release-publish", + ], + "executor": "@nx/js:release-publish", + "options": {}, + }, + "test": { + "executor": "nx:run-script", + "metadata": { + "runCommand": "npm run test", + "scriptContent": "jest", + }, + "options": { + "script": "test", + }, + }, }, }, }, }, - }, - } + ], + ] `); }); }); diff --git a/packages/nx/src/plugins/package-json/create-nodes.ts b/packages/nx/src/plugins/package-json/create-nodes.ts index 66c9128026013..2fd168dc2e814 100644 --- a/packages/nx/src/plugins/package-json/create-nodes.ts +++ b/packages/nx/src/plugins/package-json/create-nodes.ts @@ -50,8 +50,10 @@ export const createNodesV2: CreateNodesV2 = [ return createNodesFromFiles( (packageJsonPath, options, context) => { + const isInPackageManagerWorkspaces = + isInPackageJsonWorkspaces(packageJsonPath); if ( - !isInPackageJsonWorkspaces(packageJsonPath) && + !isInPackageManagerWorkspaces && !isNextToProjectJson(packageJsonPath) ) { // Skip if package.json is not part of the package.json workspaces and not next to a project.json. @@ -61,7 +63,8 @@ export const createNodesV2: CreateNodesV2 = [ return createNodeFromPackageJson( packageJsonPath, context.workspaceRoot, - cache + cache, + isInPackageManagerWorkspaces ); }, packageJsons, @@ -91,7 +94,7 @@ function splitConfigFiles(configFiles: readonly string[]): { export function buildPackageJsonWorkspacesMatcher( workspaceRoot: string, - readJson: (string) => any + readJson: (path: string) => any ) { const patterns = getGlobPatternsFromPackageManagerWorkspaces( workspaceRoot, @@ -129,7 +132,8 @@ export function buildPackageJsonWorkspacesMatcher( export function createNodeFromPackageJson( pkgJsonPath: string, workspaceRoot: string, - cache: PackageJsonConfigurationCache + cache: PackageJsonConfigurationCache, + isInPackageManagerWorkspaces: boolean ) { const json: PackageJson = readJsonFile(join(workspaceRoot, pkgJsonPath)); @@ -138,12 +142,7 @@ export function createNodeFromPackageJson( const hash = hashObject({ ...json, root: projectRoot, - /** - * Increment this number to force processing the package.json again. Do it - * when the implementation of this plugin is changed and results in different - * results for the same package.json contents. - */ - bust: 1, + isInPackageManagerWorkspaces, }); const cached = cache[hash]; @@ -159,7 +158,8 @@ export function createNodeFromPackageJson( json, workspaceRoot, pkgJsonPath, - readNxJson(workspaceRoot) + readNxJson(workspaceRoot), + isInPackageManagerWorkspaces ); cache[hash] = project; @@ -174,7 +174,8 @@ export function buildProjectConfigurationFromPackageJson( packageJson: PackageJson, workspaceRoot: string, packageJsonPath: string, - nxJson: NxJsonConfiguration + nxJson: NxJsonConfiguration, + isInPackageManagerWorkspaces: boolean ): ProjectConfiguration & { name: string } { const normalizedPath = packageJsonPath.split('\\').join('/'); const projectRoot = dirname(normalizedPath); @@ -213,7 +214,10 @@ export function buildProjectConfigurationFromPackageJson( ...packageJson.nx, targets: readTargetsFromPackageJson(packageJson, nxJson), tags: getTagsFromPackageJson(packageJson), - metadata: getMetadataFromPackageJson(packageJson), + metadata: getMetadataFromPackageJson( + packageJson, + isInPackageManagerWorkspaces + ), }; if ( diff --git a/packages/nx/src/project-graph/file-utils.ts b/packages/nx/src/project-graph/file-utils.ts index d7eeb682b9c9a..e57da642e399e 100644 --- a/packages/nx/src/project-graph/file-utils.ts +++ b/packages/nx/src/project-graph/file-utils.ts @@ -22,6 +22,7 @@ import { readProjectConfigurationsFromRootMap, } from './utils/project-configuration-utils'; import { + buildPackageJsonWorkspacesMatcher, buildProjectConfigurationFromPackageJson, getGlobPatternsFromPackageManagerWorkspaces, } from '../plugins/package-json'; @@ -200,6 +201,11 @@ function getProjectsSync( ]; const projectFiles = globWithWorkspaceContextSync(root, patterns); + const isInPackageJsonWorkspaces = buildPackageJsonWorkspacesMatcher( + root, + (f) => readJsonFile(join(root, f)) + ); + const rootMap: Record = {}; for (const projectFile of projectFiles) { if (basename(projectFile) === 'project.json') { @@ -218,7 +224,8 @@ function getProjectsSync( packageJson, root, projectFile, - nxJson + nxJson, + isInPackageJsonWorkspaces(projectFile) ); if (!rootMap[config.root]) { mergeProjectConfigurationIntoRootMap( diff --git a/packages/nx/src/project-graph/plugins/resolve-plugin.ts b/packages/nx/src/project-graph/plugins/resolve-plugin.ts index 81d9e3d96b389..b4abebdccf16f 100644 --- a/packages/nx/src/project-graph/plugins/resolve-plugin.ts +++ b/packages/nx/src/project-graph/plugins/resolve-plugin.ts @@ -1,7 +1,10 @@ import * as path from 'node:path'; import { existsSync } from 'node:fs'; -import { getPackageEntryPointsToProjectMap } from '../../plugins/js/utils/packages'; +import { + getWorkspacePackagesMetadata, + matchImportToWildcardEntryPointsToProjectMap, +} from '../../plugins/js/utils/packages'; import { readJsonFile } from '../../utils/fileutils'; import { logger } from '../../utils/logger'; import { normalizePath } from '../../utils/path'; @@ -119,6 +122,7 @@ function lookupLocalPlugin( } let packageEntryPointsToProjectMap: Record; +let wildcardEntryPointsToProjectMap: Record; function findNxProjectForImportPath( importPath: string, projects: Record, @@ -146,12 +150,24 @@ function findNxProjectForImportPath( } } - packageEntryPointsToProjectMap ??= - getPackageEntryPointsToProjectMap(projects); + if (!packageEntryPointsToProjectMap && !wildcardEntryPointsToProjectMap) { + ({ + entryPointsToProjectMap: packageEntryPointsToProjectMap, + wildcardEntryPointsToProjectMap, + } = getWorkspacePackagesMetadata(projects)); + } if (packageEntryPointsToProjectMap[importPath]) { return packageEntryPointsToProjectMap[importPath]; } + const project = matchImportToWildcardEntryPointsToProjectMap( + wildcardEntryPointsToProjectMap, + importPath + ); + if (project) { + return project; + } + logger.verbose( 'Unable to find local plugin', possibleTsPaths, diff --git a/packages/nx/src/utils/package-json.ts b/packages/nx/src/utils/package-json.ts index a1c78d4925a7b..6dff53f52f52d 100644 --- a/packages/nx/src/utils/package-json.ts +++ b/packages/nx/src/utils/package-json.ts @@ -154,9 +154,10 @@ export function buildTargetFromScript( let packageManagerCommand: PackageManagerCommands | undefined; export function getMetadataFromPackageJson( - packageJson: PackageJson + packageJson: PackageJson, + isInPackageManagerWorkspaces: boolean ): ProjectMetadata { - const { scripts, nx, description, name, exports } = packageJson; + const { scripts, nx, description, name, exports, main } = packageJson; const includedScripts = nx?.includedScripts || Object.keys(scripts ?? {}); return { targetGroups: { @@ -166,6 +167,8 @@ export function getMetadataFromPackageJson( js: { packageName: name, packageExports: exports, + packageMain: main, + isInPackageManagerWorkspaces, }, }; }