From d1c4fe2f5130306743854f2909101a49ec1f4bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Thu, 5 Dec 2024 18:20:34 +0100 Subject: [PATCH] feat(core): use custom resolution to resolve from source local plugins with artifacts pointing to the outputs --- e2e/plugin/src/nx-plugin-ts-solution.test.ts | 102 +++++++++++++++++ e2e/utils/create-project-utils.ts | 4 +- packages/nx/src/config/schema-utils.ts | 49 +++++++++ .../nx/src/project-graph/plugins/loader.ts | 103 +++++++++++++++--- 4 files changed, 243 insertions(+), 15 deletions(-) create mode 100644 e2e/plugin/src/nx-plugin-ts-solution.test.ts diff --git a/e2e/plugin/src/nx-plugin-ts-solution.test.ts b/e2e/plugin/src/nx-plugin-ts-solution.test.ts new file mode 100644 index 0000000000000..671d528ba38ea --- /dev/null +++ b/e2e/plugin/src/nx-plugin-ts-solution.test.ts @@ -0,0 +1,102 @@ +import type { ProjectConfiguration } from '@nx/devkit'; +import { + checkFilesExist, + cleanupProject, + createFile, + newProject, + runCLI, + uniq, + updateFile, +} from '@nx/e2e/utils'; +import { + ASYNC_GENERATOR_EXECUTOR_CONTENTS, + NX_PLUGIN_V2_CONTENTS, +} from './nx-plugin.fixtures'; + +describe('Nx Plugin (TS solution)', () => { + let workspaceName: string; + + beforeAll(() => { + workspaceName = newProject({ preset: 'ts', packages: ['@nx/plugin'] }); + }); + + afterAll(() => cleanupProject()); + + it('should be able to infer projects and targets', async () => { + const plugin = uniq('plugin'); + runCLI(`generate @nx/plugin:plugin packages/${plugin} --linter=eslint`); + + // Setup project inference + target inference + updateFile(`packages/${plugin}/src/index.ts`, NX_PLUGIN_V2_CONTENTS); + + // Register plugin in nx.json (required for inference) + updateFile(`nx.json`, (nxJson) => { + const nx = JSON.parse(nxJson); + nx.plugins = [ + { + plugin: `@${workspaceName}/${plugin}`, + options: { inferredTags: ['my-tag'] }, + }, + ]; + return JSON.stringify(nx, null, 2); + }); + + // Create project that should be inferred by Nx + const inferredProject = uniq('inferred'); + createFile( + `packages/${inferredProject}/package.json`, + JSON.stringify({ + name: inferredProject, + version: '0.0.1', + }) + ); + createFile(`packages/${inferredProject}/my-project-file`); + + // Attempt to use inferred project w/ Nx + expect(runCLI(`build ${inferredProject}`)).toContain( + 'custom registered target' + ); + const configuration = JSON.parse( + runCLI(`show project ${inferredProject} --json`) + ); + expect(configuration.tags).toContain('my-tag'); + expect(configuration.metadata.technologies).toEqual(['my-plugin']); + }); + + it('should be able to use local generators and executors', async () => { + const plugin = uniq('plugin'); + const generator = uniq('generator'); + const executor = uniq('executor'); + const generatedProject = uniq('project'); + + runCLI(`generate @nx/plugin:plugin packages/${plugin} --linter=eslint`); + + runCLI( + `generate @nx/plugin:generator --name ${generator} --path packages/${plugin}/src/generators/${generator}/generator` + ); + + runCLI( + `generate @nx/plugin:executor --name ${executor} --path packages/${plugin}/src/executors/${executor}/executor` + ); + + updateFile( + `packages/${plugin}/src/executors/${executor}/executor.ts`, + ASYNC_GENERATOR_EXECUTOR_CONTENTS + ); + + runCLI( + `generate @${workspaceName}/${plugin}:${generator} --name ${generatedProject}` + ); + + updateFile(`libs/${generatedProject}/project.json`, (f) => { + const project: ProjectConfiguration = JSON.parse(f); + project.targets['execute'] = { + executor: `@${workspaceName}/${plugin}:${executor}`, + }; + return JSON.stringify(project, null, 2); + }); + + expect(() => checkFilesExist(`libs/${generatedProject}`)).not.toThrow(); + expect(() => runCLI(`execute ${generatedProject}`)).not.toThrow(); + }); +}); diff --git a/e2e/utils/create-project-utils.ts b/e2e/utils/create-project-utils.ts index 05406751870df..2ec50e2a25846 100644 --- a/e2e/utils/create-project-utils.ts +++ b/e2e/utils/create-project-utils.ts @@ -76,10 +76,12 @@ export function newProject({ name = uniq('proj'), packageManager = getSelectedPackageManager(), packages, + preset = 'apps', }: { name?: string; packageManager?: 'npm' | 'yarn' | 'pnpm' | 'bun'; readonly packages?: Array; + preset?: string; } = {}): string { const newProjectStart = performance.mark('new-project:start'); try { @@ -93,7 +95,7 @@ export function newProject({ 'create-nx-workspace:start' ); runCreateWorkspace(projScope, { - preset: 'apps', + preset, packageManager, }); const createNxWorkspaceEnd = performance.mark('create-nx-workspace:end'); diff --git a/packages/nx/src/config/schema-utils.ts b/packages/nx/src/config/schema-utils.ts index 30d7d083130f0..1cbe4164cc420 100644 --- a/packages/nx/src/config/schema-utils.ts +++ b/packages/nx/src/config/schema-utils.ts @@ -1,6 +1,7 @@ import { existsSync } from 'fs'; import { extname, join } from 'path'; import { registerPluginTSTranspiler } from '../project-graph/plugins'; +import { normalizePath } from '../utils/path'; /** * This function is used to get the implementation factory of an executor or generator. @@ -43,6 +44,21 @@ export function resolveImplementation( (x) => implementationModulePath + x ); + if (!directory.includes('node_modules')) { + // It might be a local plugin where the implementation path points to the + // outputs which might not exist or can be stale. We prioritize finding + // the implementation from the source over the outputs. + for (const maybeImplementation of validImplementations) { + const maybeImplementationFromSource = tryResolveFromSource( + maybeImplementation, + directory + ); + if (maybeImplementationFromSource) { + return maybeImplementationFromSource; + } + } + } + for (const maybeImplementation of validImplementations) { const maybeImplementationPath = join(directory, maybeImplementation); if (existsSync(maybeImplementationPath)) { @@ -62,6 +78,16 @@ export function resolveImplementation( } export function resolveSchema(schemaPath: string, directory: string): string { + if (!directory.includes('node_modules')) { + // It might be a local plugin where the schema path points to the outputs + // which might not exist or can be stale. We prioritize finding the schema + // from the source over the outputs. + const schemaPathFromSource = tryResolveFromSource(schemaPath, directory); + if (schemaPathFromSource) { + return schemaPathFromSource; + } + } + const maybeSchemaPath = join(directory, schemaPath); if (existsSync(maybeSchemaPath)) { return maybeSchemaPath; @@ -71,3 +97,26 @@ export function resolveSchema(schemaPath: string, directory: string): string { paths: [directory], }); } + +function tryResolveFromSource(path: string, directory: string): string | null { + const segments = normalizePath(path).replace(/^\.\//, '').split('/'); + for (let i = 1; i < segments.length; i++) { + // We try to find the path relative to the following common directories: + // - the root of the project + // - the src directory + // - the src/lib directory + const possiblePaths = [ + join(directory, ...segments.slice(i)), + join(directory, 'src', ...segments.slice(i)), + join(directory, 'src', 'lib', ...segments.slice(i)), + ]; + + for (const possiblePath of possiblePaths) { + if (existsSync(possiblePath)) { + return possiblePath; + } + } + } + + return null; +} diff --git a/packages/nx/src/project-graph/plugins/loader.ts b/packages/nx/src/project-graph/plugins/loader.ts index 0e90af76855de..2eadeba0407b4 100644 --- a/packages/nx/src/project-graph/plugins/loader.ts +++ b/packages/nx/src/project-graph/plugins/loader.ts @@ -130,32 +130,41 @@ function findNxProjectForImportPath( root = workspaceRoot ): ProjectConfiguration | null { const tsConfigPaths: Record = readTsConfigPaths(root); - const possiblePaths = tsConfigPaths[importPath]?.map((p) => - normalizePath(path.relative(root, path.join(root, p))) - ); - if (possiblePaths?.length) { - const projectRootMappings: ProjectRootMappings = new Map(); + const possibleTsPaths = + tsConfigPaths[importPath]?.map((p) => + normalizePath(path.relative(root, path.join(root, p))) + ) ?? []; + + const projectRootMappings: ProjectRootMappings = new Map(); + if (possibleTsPaths.length) { const projectNameMap = new Map(); for (const projectRoot in projects) { const project = projects[projectRoot]; projectRootMappings.set(project.root, project.name); projectNameMap.set(project.name, project); } - for (const tsConfigPath of possiblePaths) { + for (const tsConfigPath of possibleTsPaths) { const nxProject = findProjectForPath(tsConfigPath, projectRootMappings); if (nxProject) { return projectNameMap.get(nxProject); } } - logger.verbose( - 'Unable to find local plugin', - possiblePaths, - projectRootMappings - ); - throw new Error( - 'Unable to resolve local plugin with import path ' + importPath - ); } + + // try to resolve from the projects' package.json names + const projectName = getNameFromPackageJson(importPath, root, projects); + if (projectName) { + return projects[projectName]; + } + + logger.verbose( + 'Unable to find local plugin', + possibleTsPaths, + projectRootMappings + ); + throw new Error( + 'Unable to resolve local plugin with import path ' + importPath + ); } let tsconfigPaths: Record; @@ -174,6 +183,72 @@ function readTsConfigPaths(root: string = workspaceRoot) { return tsconfigPaths ?? {}; } +let packageJsonMap: Record; +let seenProjects: Set; + +/** + * Locate the project name from the package.json files in the provided projects. + * Progressively build up a map of package names to project names to avoid + * reading the same package.json multiple times and reading unnecessary ones. + */ +function getNameFromPackageJson( + importPath: string, + root: string = workspaceRoot, + projects: Record +): string | null { + packageJsonMap ??= {}; + seenProjects ??= new Set(); + + const resolveFromPackageJson = (projectName: string) => { + try { + const packageJson = readJsonFile( + path.join(root, projects[projectName].root, 'package.json') + ); + packageJsonMap[packageJson.name ?? projectName] = projectName; + + if (packageJsonMap[importPath]) { + // we found the importPath, we progressively build up packageJsonMap + // so we can return early + return projectName; + } + } catch {} + + return null; + }; + + if (packageJsonMap[importPath]) { + if (!!projects[packageJsonMap[importPath]]) { + return packageJsonMap[importPath]; + } else { + // the previously resolved project might have been resolved with the + // project root as the name, so we need to resolve it again to get + // the actual project name + const projectName = Object.keys(projects).find( + (p) => projects[p].root === packageJsonMap[importPath] + ); + const resolvedProject = resolveFromPackageJson(projectName); + if (resolvedProject) { + return resolvedProject; + } + } + } + + for (const projectName of Object.keys(projects)) { + if (seenProjects.has(projectName)) { + // we already parsed this project + continue; + } + seenProjects.add(projectName); + + const resolvedProject = resolveFromPackageJson(projectName); + if (resolvedProject) { + return resolvedProject; + } + } + + return null; +} + function readPluginMainFromProjectConfiguration( plugin: ProjectConfiguration ): string | null {