diff --git a/packages/devkit/CLAUDE.md b/packages/devkit/CLAUDE.md new file mode 100644 index 00000000000..f6a8015fe73 --- /dev/null +++ b/packages/devkit/CLAUDE.md @@ -0,0 +1,62 @@ +# @nx/devkit + +## Architecture + +`@nx/devkit` serves two purposes: it **re-exports** core types and utilities from the `nx` package, and it **defines its own utilities** that are useful for plugin authors but aren't needed by `nx` core itself. + +### Entry Point Structure + +``` +@nx/devkit (index.ts) + ├── re-exports: nx/src/devkit-exports (stable public API) + ├── exports: ./public-api (plugin-author utilities defined in devkit) + │ └── may import from: nx/src/devkit-internals (NOT re-exported to consumers) + │ + ├── @nx/devkit/testing → nx/src/devkit-testing-exports + ├── @nx/devkit/ngcli-adapter → nx/src/adapter/ngcli-adapter + └── @nx/devkit/internal → nx/src/devkit-internals (subset) +``` + +## Version Compatibility Contract + +**This is the most important thing to understand when modifying devkit or its nx entry points.** + +`@nx/devkit` supports `nx` at the current major version **+/- 1 major version**. The `peerDependencies` in `package.json` encode this — e.g. `"nx": ">= 21 <= 23"` means `@nx/devkit@22` works with `nx@21`, `nx@22`, and `nx@23`. + +### What This Means for Changes + +#### `nx/src/devkit-exports.ts` (the public API surface) + +- Everything exported here becomes the public API of `@nx/devkit`. +- **Minimize additions** — the file has a warning: "STOP! Try hard to not add to this API." +- New exports are safe for current consumers but adding then removing them creates breaking changes. + +#### `nx/src/devkit-internals.ts` (semi-private bridge) + +- These are used **internally** by `@nx/devkit`'s own implementation code (e.g. `packages/devkit/src/`). +- They are **NOT** part of `@nx/devkit`'s public API. +- The file warns: "These may not be available in certain versions of Nx, so be sure to check them first." +- **When `@nx/devkit` code imports from `devkit-internals`, it must handle the case where that export doesn't exist** in an older supported `nx` version. Guard with runtime checks or ensure the export has existed since the oldest supported major. + +#### `packages/devkit/public-api.ts` (plugin-author utilities owned by devkit) + +- Utilities implemented in `packages/devkit/src/` that are useful for plugin authors but not needed by `nx` core (e.g. `formatFiles`, `generateFiles`, `parseTargetString`). +- These are **defined here, not re-exported from `nx`** — devkit is the source of truth for this code. +- Code here may import from `nx/src/devkit-internals` — same version-guarding rules apply. + +### Practical Guidelines + +1. **Adding a new export to `devkit-exports.ts`**: This is a public API addition. Keep the surface area small. Once published, removing it is a breaking change. +2. **Adding a new export to `devkit-internals.ts`**: Safe to add, but any `@nx/devkit` code consuming it must account for older `nx` versions where it won't exist. +3. **Removing an export from either file**: Only safe if no published `@nx/devkit` version within the supported range depends on it. +4. **Changing the signature of an existing export**: Must remain compatible across all supported `nx` major versions. + +## Key Files + +| File | Purpose | +| ------------------------------------------- | -------------------------------------------------------------------------- | +| `packages/devkit/index.ts` | Main entry point — re-exports from `nx` + `public-api` | +| `packages/devkit/public-api.ts` | Plugin-author utilities owned by devkit (formatFiles, generateFiles, etc.) | +| `packages/nx/src/devkit-exports.ts` | Stable public API surface exposed through `@nx/devkit` | +| `packages/nx/src/devkit-internals.ts` | Semi-private internals used by devkit's implementation | +| `packages/nx/src/devkit-testing-exports.ts` | Testing utilities for `@nx/devkit/testing` | diff --git a/packages/devkit/src/executors/parse-target-string.ts b/packages/devkit/src/executors/parse-target-string.ts index 4c4984d9995..ac3061d7302 100644 --- a/packages/devkit/src/executors/parse-target-string.ts +++ b/packages/devkit/src/executors/parse-target-string.ts @@ -63,9 +63,15 @@ export function parseTargetString( targetString = `${projectGraphOrCtx.projectName}:${targetString}`; } + const currentProject = + projectGraphOrCtx && 'projectName' in projectGraphOrCtx + ? projectGraphOrCtx.projectName + : undefined; + const [project, target, configuration] = splitTarget( targetString, - projectGraph + projectGraph, + { currentProject } ); if (!project || !target) { diff --git a/packages/nx/src/adapter/ngcli-adapter.ts b/packages/nx/src/adapter/ngcli-adapter.ts index b1367475992..034b2acd30b 100644 --- a/packages/nx/src/adapter/ngcli-adapter.ts +++ b/packages/nx/src/adapter/ngcli-adapter.ts @@ -76,8 +76,8 @@ import { resolveImplementation, resolveSchema, } from '../config/schema-utils'; -import { resolveNxTokensInOptions } from '../project-graph/utils/project-configuration-utils'; import { handleImport } from '../utils/handle-import'; +import { resolveNxTokensInOptions } from '../project-graph/utils/project-configuration/target-merging'; function getProjectGraph(): Promise { try { diff --git a/packages/nx/src/command-line/graph/graph.ts b/packages/nx/src/command-line/graph/graph.ts index 3d77e604ab6..1ad84a69549 100644 --- a/packages/nx/src/command-line/graph/graph.ts +++ b/packages/nx/src/command-line/graph/graph.ts @@ -53,7 +53,7 @@ import { transformProjectGraphForRust } from '../../native/transform-objects'; import { getAffectedGraphNodes } from '../affected/affected'; import { readFileMapCache } from '../../project-graph/nx-deps-cache'; import { filterUsingGlobPatterns } from '../../hasher/task-hasher'; -import { ConfigurationSourceMaps } from '../../project-graph/utils/project-configuration-utils'; +import { ConfigurationSourceMaps } from '../../project-graph/utils/project-configuration/source-maps'; import { findMatchingProjects } from '../../utils/find-matching-projects'; import { createTaskHasher } from '../../hasher/create-task-hasher'; diff --git a/packages/nx/src/command-line/run/run-one.ts b/packages/nx/src/command-line/run/run-one.ts index a1151e0eca4..3ee8fcb1648 100644 --- a/packages/nx/src/command-line/run/run-one.ts +++ b/packages/nx/src/command-line/run/run-one.ts @@ -179,7 +179,8 @@ export function parseRunOneOptions( // run case [project, target, configuration] = splitTarget( parsedArgs[PROJECT_TARGET_CONFIG], - projectGraph + projectGraph, + { currentProject: defaultProjectName } ); // this is to account for "nx npmscript:dev" if (project && !target && defaultProjectName) { diff --git a/packages/nx/src/command-line/show/target.ts b/packages/nx/src/command-line/show/target.ts index 85e69960553..00f40cbad45 100644 --- a/packages/nx/src/command-line/show/target.ts +++ b/packages/nx/src/command-line/show/target.ts @@ -200,7 +200,16 @@ function resolveTargetIdentifier( process.exit(1); } - const [project, target, config] = splitTarget(args.target, graph); + const defaultProjectName = calculateDefaultProjectName( + process.cwd(), + workspaceRoot, + readProjectsConfigurationFromProjectGraph(graph), + nxJson + ); + + const [project, target, config] = splitTarget(args.target, graph, { + currentProject: defaultProjectName, + }); if (project && target) { return { @@ -211,12 +220,7 @@ function resolveTargetIdentifier( } const targetName = project; // splitTarget returns the string as the first element - const projectName = calculateDefaultProjectName( - process.cwd(), - workspaceRoot, - readProjectsConfigurationFromProjectGraph(graph), - nxJson - ); + const projectName = defaultProjectName; if (!projectName) { output.error({ diff --git a/packages/nx/src/daemon/client/client.ts b/packages/nx/src/daemon/client/client.ts index 0a16ef14546..65ec05e2355 100644 --- a/packages/nx/src/daemon/client/client.ts +++ b/packages/nx/src/daemon/client/client.ts @@ -26,7 +26,7 @@ import { PreTasksExecutionContext, } from '../../project-graph/plugins/public-api'; import { preventRecursionInGraphConstruction } from '../../project-graph/project-graph'; -import { ConfigurationSourceMaps } from '../../project-graph/utils/project-configuration-utils'; +import { ConfigurationSourceMaps } from '../../project-graph/utils/project-configuration/source-maps'; import { isJsonMessage } from '../../utils/consume-messages-from-socket'; import { DelayedSpinner } from '../../utils/delayed-spinner'; import { isCI } from '../../utils/is-ci'; diff --git a/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts b/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts index d841a1bc5f2..75b734471fe 100644 --- a/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts +++ b/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts @@ -32,10 +32,8 @@ import { notifyFileChangeListeners } from './file-watching/file-change-events'; import { notifyProjectGraphListenerSockets } from './project-graph-listener-sockets'; import { serverLogger } from '../logger'; import { NxWorkspaceFilesExternals } from '../../native'; -import { - ConfigurationResult, - ConfigurationSourceMaps, -} from '../../project-graph/utils/project-configuration-utils'; +import { ConfigurationResult } from '../../project-graph/utils/project-configuration-utils'; +import { ConfigurationSourceMaps } from '../../project-graph/utils/project-configuration/source-maps'; import type { LoadedNxPlugin } from '../../project-graph/plugins/loaded-nx-plugin'; import { DaemonProjectGraphError, diff --git a/packages/nx/src/daemon/server/project-graph-listener-sockets.ts b/packages/nx/src/daemon/server/project-graph-listener-sockets.ts index 4636055d0ba..ebcda3e25bf 100644 --- a/packages/nx/src/daemon/server/project-graph-listener-sockets.ts +++ b/packages/nx/src/daemon/server/project-graph-listener-sockets.ts @@ -1,6 +1,6 @@ import { Socket } from 'net'; import { ProjectGraph } from '../../config/project-graph'; -import { ConfigurationSourceMaps } from '../../project-graph/utils/project-configuration-utils'; +import { ConfigurationSourceMaps } from '../../project-graph/utils/project-configuration/source-maps'; import { handleResult } from './server'; import { isV8SerializerEnabled } from '../is-v8-serializer-enabled'; diff --git a/packages/nx/src/devkit-internals.ts b/packages/nx/src/devkit-internals.ts index 72e1fc1d1d0..402b7c1963a 100644 --- a/packages/nx/src/devkit-internals.ts +++ b/packages/nx/src/devkit-internals.ts @@ -11,11 +11,9 @@ export { export { readNxJson as readNxJsonFromDisk } from './config/nx-json'; export { calculateDefaultProjectName } from './config/calculate-default-project-name'; export { retrieveProjectConfigurationsWithAngularProjects } from './project-graph/utils/retrieve-workspace-files'; -export { mergeTargetConfigurations } from './project-graph/utils/project-configuration-utils'; -export { - readProjectConfigurationsFromRootMap, - findMatchingConfigFiles, -} from './project-graph/utils/project-configuration-utils'; +export { mergeTargetConfigurations } from './project-graph/utils/project-configuration/target-merging'; +export { readProjectConfigurationsFromRootMap } from './project-graph/utils/project-configuration/project-nodes-manager'; +export { findMatchingConfigFiles } from './project-graph/utils/project-configuration-utils'; export { getIgnoreObjectForTree } from './utils/ignore'; export { splitTarget } from './utils/split-target'; export { combineOptionsForExecutor } from './utils/params'; diff --git a/packages/nx/src/executors/utils/convert-nx-executor.ts b/packages/nx/src/executors/utils/convert-nx-executor.ts index 961dad28776..4263ea93404 100644 --- a/packages/nx/src/executors/utils/convert-nx-executor.ts +++ b/packages/nx/src/executors/utils/convert-nx-executor.ts @@ -6,7 +6,7 @@ import type { Observable } from 'rxjs'; import { readNxJson } from '../../config/nx-json'; import { Executor, ExecutorContext } from '../../config/misc-interfaces'; import { retrieveProjectConfigurations } from '../../project-graph/utils/retrieve-workspace-files'; -import { readProjectConfigurationsFromRootMap } from '../../project-graph/utils/project-configuration-utils'; +import { readProjectConfigurationsFromRootMap } from '../../project-graph/utils/project-configuration/project-nodes-manager'; import { ProjectsConfigurations } from '../../config/workspace-json-project-json'; import { getPlugins } from '../../project-graph/plugins/get-plugins'; diff --git a/packages/nx/src/generators/utils/project-configuration.ts b/packages/nx/src/generators/utils/project-configuration.ts index 5eb9c4fcc13..273257a1cdc 100644 --- a/packages/nx/src/generators/utils/project-configuration.ts +++ b/packages/nx/src/generators/utils/project-configuration.ts @@ -11,7 +11,7 @@ import { import { mergeProjectConfigurationIntoRootMap, readProjectConfigurationsFromRootMap, -} from '../../project-graph/utils/project-configuration-utils'; +} from '../../project-graph/utils/project-configuration/project-nodes-manager'; import { globWithWorkspaceContextSync } from '../../utils/workspace-context'; import { output } from '../../utils/output'; import { PackageJson } from '../../utils/package-json'; diff --git a/packages/nx/src/project-graph/build-project-graph.ts b/packages/nx/src/project-graph/build-project-graph.ts index 54e0339e4a3..80bc305e11f 100644 --- a/packages/nx/src/project-graph/build-project-graph.ts +++ b/packages/nx/src/project-graph/build-project-graph.ts @@ -40,10 +40,8 @@ import { ProcessDependenciesError, WorkspaceValidityError, } from './error-types'; -import { - ConfigurationSourceMaps, - mergeMetadata, -} from './utils/project-configuration-utils'; +import { mergeMetadata } from './utils/project-configuration/target-merging'; +import type { ConfigurationSourceMaps } from './utils/project-configuration/source-maps'; import { DelayedSpinner } from '../utils/delayed-spinner'; import { hashObject } from '../hasher/file-hasher'; diff --git a/packages/nx/src/project-graph/error-types.ts b/packages/nx/src/project-graph/error-types.ts index 9b2ad85f831..fe99b8dd18e 100644 --- a/packages/nx/src/project-graph/error-types.ts +++ b/packages/nx/src/project-graph/error-types.ts @@ -1,7 +1,5 @@ -import { - ConfigurationResult, - ConfigurationSourceMaps, -} from './utils/project-configuration-utils'; +import { ConfigurationResult } from './utils/project-configuration-utils'; +import type { ConfigurationSourceMaps } from './utils/project-configuration/source-maps'; import { ProjectConfiguration } from '../config/workspace-json-project-json'; import { ProjectGraph } from '../config/project-graph'; import { CreateNodesFunctionV2 } from './plugins/public-api'; diff --git a/packages/nx/src/project-graph/nx-deps-cache.ts b/packages/nx/src/project-graph/nx-deps-cache.ts index 6bea9f291e2..62957769ada 100644 --- a/packages/nx/src/project-graph/nx-deps-cache.ts +++ b/packages/nx/src/project-graph/nx-deps-cache.ts @@ -25,7 +25,7 @@ import { ProjectGraphErrorTypes, StaleProjectGraphCacheError, } from './error-types'; -import { ConfigurationSourceMaps } from './utils/project-configuration-utils'; +import { ConfigurationSourceMaps } from './utils/project-configuration/source-maps'; export interface FileMapCache { version: string; diff --git a/packages/nx/src/project-graph/utils/__fixtures__/merge-create-nodes-args.json b/packages/nx/src/project-graph/utils/__fixtures__/merge-create-nodes-args.json new file mode 100644 index 00000000000..2326335c98a --- /dev/null +++ b/packages/nx/src/project-graph/utils/__fixtures__/merge-create-nodes-args.json @@ -0,0 +1,100 @@ +{ + "results": [ + [ + [ + "@acme/gradle", + "apps/my-app/build.gradle", + { + "projects": { + "apps/my-app": { + "name": ":apps:my-app", + "targets": { + "gradle-test": { + "cache": true, + "dependsOn": [ + ":apps:my-app:compileTestJava", + ":apps:my-app:testClasses", + ":apps:my-app:classes", + ":apps:my-app:compileJava", + ":libs:java:lib-a:jar", + ":libs:java:lib-b:jar" + ], + "executor": "@acme/gradle:gradle", + "options": { + "taskName": ":apps:my-app:test" + } + } + } + }, + "libs/java/lib-a": { + "name": ":libs:java:lib-a", + "targets": { + "jar": { + "cache": true, + "executor": "@acme/gradle:gradle", + "options": { + "taskName": ":libs:java:lib-a:jar" + } + } + } + }, + "libs/java/lib-b": { + "name": ":libs:java:lib-b", + "targets": { + "jar": { + "cache": true, + "executor": "@acme/gradle:gradle", + "options": { + "taskName": ":libs:java:lib-b:jar" + } + } + } + } + } + } + ] + ], + [ + [ + "nx/core/project-json", + "apps/my-app/project.json", + { + "projects": { + "apps/my-app": { + "name": "my-app", + "root": "apps/my-app", + "projectType": "application" + } + } + } + ], + [ + "nx/core/project-json", + "libs/java/lib-a/project.json", + { + "projects": { + "libs/java/lib-a": { + "name": "lib-a", + "root": "libs/java/lib-a" + } + } + } + ], + [ + "nx/core/project-json", + "libs/java/lib-b/project.json", + { + "projects": { + "libs/java/lib-b": { + "name": "lib-b", + "root": "libs/java/lib-b" + } + } + } + ] + ] + ], + "nxJsonConfiguration": {}, + "workspaceRoot": "/tmp/test-workspace", + "errors": [] +} diff --git a/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts b/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts index cebdd7e80d2..6688c5a79ac 100644 --- a/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts +++ b/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts @@ -1,2557 +1,766 @@ +import { dirname } from 'path'; +import { isProjectConfigurationsError } from '../error-types'; +import { createNodesFromFiles, NxPluginV2 } from '../plugins'; +import { LoadedNxPlugin } from '../plugins/loaded-nx-plugin'; import { - ProjectConfiguration, - TargetConfiguration, -} from '../../config/workspace-json-project-json'; -import { - ConfigurationSourceMaps, - SourceInformation, createProjectConfigurationsWithPlugins, - isCompatibleTarget, - mergeProjectConfigurationIntoRootMap, - mergeTargetConfigurations, - mergeTargetDefaultWithTargetDefinition, - normalizeTarget, - readProjectConfigurationsFromRootMap, - readTargetDefaultsForTarget, + mergeCreateNodesResults, } from './project-configuration-utils'; -import { createNodesFromFiles, NxPluginV2 } from '../plugins'; -import { LoadedNxPlugin } from '../plugins/loaded-nx-plugin'; -import { dirname } from 'path'; -import { isProjectConfigurationsError } from '../error-types'; -import { workspaceRoot } from '../../utils/workspace-root'; describe('project-configuration-utils', () => { - describe('target merging', () => { - const targetDefaults = { - 'nx:run-commands': { - options: { - key: 'default-value-for-executor', - }, - }, - build: { - options: { - key: 'default-value-for-targetname', - }, - }, - 'e2e-ci--*': { - options: { - key: 'default-value-for-e2e-ci', - }, - }, - 'e2e-ci--file-*': { - options: { - key: 'default-value-for-e2e-ci-file', - }, - }, - }; - - it('should prefer executor key', () => { - expect( - readTargetDefaultsForTarget( - 'other-target', - targetDefaults, - 'nx:run-commands' - ).options['key'] - ).toEqual('default-value-for-executor'); - }); - - it('should fallback to target key', () => { - expect( - readTargetDefaultsForTarget('build', targetDefaults, 'other-executor') - .options['key'] - ).toEqual('default-value-for-targetname'); - }); - - it('should return undefined if not found', () => { - expect( - readTargetDefaultsForTarget( - 'other-target', - targetDefaults, - 'other-executor' - ) - ).toBeNull(); - }); - - it('should return longest matching target', () => { - expect( - // This matches both 'e2e-ci--*' and 'e2e-ci--file-*', we expect the first match to be returned. - readTargetDefaultsForTarget('e2e-ci--file-foo', targetDefaults, null) - .options['key'] - ).toEqual('default-value-for-e2e-ci-file'); - }); - - it('should return longest matching target even if executor is passed', () => { - expect( - // This uses an executor which does not have settings in target defaults - // thus the target name pattern target defaults are used - readTargetDefaultsForTarget( - 'e2e-ci--file-foo', - targetDefaults, - 'other-executor' - ).options['key'] - ).toEqual('default-value-for-e2e-ci-file'); - }); - - it('should not merge top level properties for incompatible targets', () => { - expect( - mergeTargetConfigurations( - { - executor: 'target2', - outputs: ['output1'], - }, - { - executor: 'target', - inputs: ['input1'], - } - ) - ).toEqual({ executor: 'target2', outputs: ['output1'] }); + describe('mergeCreateNodesResults', () => { + it('should substitute gradle-style colon names with project names in dependsOn', () => { + const { + results, + nxJsonConfiguration, + workspaceRoot: root, + errors, + } = require('./__fixtures__/merge-create-nodes-args.json'); + const result = mergeCreateNodesResults( + results, + nxJsonConfiguration, + root, + errors + ); + const projectConfig = result.projectRootMap['apps/my-app']; + const targetConfig = projectConfig['targets']?.['gradle-test']; + const dependsOn = targetConfig?.dependsOn; + expect(dependsOn).toMatchInlineSnapshot(` + [ + "my-app:compileTestJava", + "my-app:testClasses", + "my-app:classes", + "my-app:compileJava", + "lib-a:jar", + "lib-b:jar", + ] + `); }); + }); - describe('options', () => { - it('should merge if executor matches', () => { - expect( - mergeTargetConfigurations( - { - executor: 'target', - options: { - a: 'project-value-a', - }, - }, - { - executor: 'target', - options: { - a: 'default-value-a', - b: 'default-value-b', - }, - } - ).options - ).toEqual({ a: 'project-value-a', b: 'default-value-b' }); - }); - - it('should merge if executor is only provided on the project', () => { - expect( - mergeTargetConfigurations( - { - executor: 'target', - options: { - a: 'project-value', - }, + describe('createProjectConfigurations', () => { + /* A fake plugin that sets `fake-lib` tag to libs. */ + const fakeTagPlugin: NxPluginV2 = { + name: 'fake-tag-plugin', + createNodesV2: [ + 'libs/*/project.json', + (vitestConfigPaths) => + createNodesFromFiles( + (vitestConfigPath) => { + const [_libs, name, _config] = vitestConfigPath.split('/'); + return { + projects: { + [name]: { + name: name, + root: `libs/${name}`, + tags: ['fake-lib'], + }, + }, + }; }, - { - options: { - a: 'default-value', - b: 'default-value', - }, - } - ).options - ).toEqual({ a: 'project-value', b: 'default-value' }); - }); + vitestConfigPaths, + null, + null + ), + ], + }; - it('should merge if executor is only provided in the defaults', () => { - expect( - mergeTargetConfigurations( - { - options: { - a: 'project-value', - }, + const fakeTargetsPlugin: NxPluginV2 = { + name: 'fake-targets-plugin', + createNodesV2: [ + 'libs/*/project.json', + (projectJsonPaths) => + createNodesFromFiles( + (projectJsonPath) => { + const root = dirname(projectJsonPath); + return { + projects: { + [root]: { + root, + targets: { + build: { + executor: 'nx:run-commands', + options: { + command: 'echo {projectName} @ {projectRoot}', + }, + }, + }, + }, + }, + }; }, - { - executor: 'target', - options: { - a: 'default-value', - b: 'default-value', - }, - } - ).options - ).toEqual({ a: 'project-value', b: 'default-value' }); - }); + projectJsonPaths, + null, + null + ), + ], + }; - it('should not merge if executor is different', () => { - expect( - mergeTargetConfigurations( - { - executor: 'other', - options: { - a: 'project-value', - }, + const sameNamePlugin: NxPluginV2 = { + name: 'same-name-plugin', + createNodesV2: [ + 'libs/*/project.json', + (projectJsonPaths) => + createNodesFromFiles( + (projectJsonPath) => { + const root = dirname(projectJsonPath); + return { + projects: { + [root]: { + root, + name: 'same-name', + }, + }, + }; }, - { - executor: 'default-executor', - options: { - b: 'default-value', - }, - } - ).options - ).toEqual({ a: 'project-value' }); - }); - }); + projectJsonPaths, + null, + null + ), + ], + }; - describe('configurations', () => { - const projectConfigurations: TargetConfiguration['configurations'] = { - dev: { - foo: 'project-value-foo', - }, - prod: { - bar: 'project-value-bar', - }, - }; + it('should create nodes for files matching included patterns only', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + [['libs/a/project.json', 'libs/b/project.json']], + [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + }), + ] + ); - const defaultConfigurations: TargetConfiguration['configurations'] = { - dev: { - foo: 'default-value-foo', - other: 'default-value-other', - }, - baz: { - x: 'default-value-x', + expect(projectConfigurations.projects).toEqual({ + 'libs/a': { + name: 'a', + root: 'libs/a', + tags: ['fake-lib'], }, - }; - - const merged: TargetConfiguration['configurations'] = { - dev: { - foo: projectConfigurations.dev.foo, - other: defaultConfigurations.dev.other, + 'libs/b': { + name: 'b', + root: 'libs/b', + tags: ['fake-lib'], }, - prod: { bar: projectConfigurations.prod.bar }, - baz: { x: defaultConfigurations.baz.x }, - }; - - it('should merge configurations if executor matches', () => { - expect( - mergeTargetConfigurations( - { - executor: 'target', - configurations: projectConfigurations, - }, - { - executor: 'target', - configurations: defaultConfigurations, - } - ).configurations - ).toEqual(merged); - }); - - it('should merge if executor is only provided on the project', () => { - expect( - mergeTargetConfigurations( - { - executor: 'target', - configurations: projectConfigurations, - }, - { - configurations: defaultConfigurations, - } - ).configurations - ).toEqual(merged); - }); - - it('should merge if executor is only provided in the defaults', () => { - expect( - mergeTargetConfigurations( - { - configurations: projectConfigurations, - }, - { - executor: 'target', - configurations: defaultConfigurations, - } - ).configurations - ).toEqual(merged); - }); - - it('should not merge if executor doesnt match', () => { - expect( - mergeTargetConfigurations( - { - executor: 'other', - configurations: projectConfigurations, - }, - { - executor: 'target', - configurations: defaultConfigurations, - } - ).configurations - ).toEqual(projectConfigurations); - }); - }); - - describe('defaultConfiguration', () => { - const projectDefaultConfiguration: TargetConfiguration['defaultConfiguration'] = - 'dev'; - const defaultDefaultConfiguration: TargetConfiguration['defaultConfiguration'] = - 'prod'; - - const merged: TargetConfiguration['defaultConfiguration'] = - projectDefaultConfiguration; - - it('should merge defaultConfiguration if executor matches', () => { - expect( - mergeTargetConfigurations( - { - executor: 'target', - defaultConfiguration: projectDefaultConfiguration, - }, - { - executor: 'target', - defaultConfiguration: defaultDefaultConfiguration, - } - ).defaultConfiguration - ).toEqual(merged); - }); - - it('should merge if executor is only provided on the project', () => { - expect( - mergeTargetConfigurations( - { - executor: 'target', - defaultConfiguration: projectDefaultConfiguration, - }, - { - defaultConfiguration: defaultDefaultConfiguration, - } - ).defaultConfiguration - ).toEqual(merged); - }); - - it('should merge if executor is only provided in the defaults', () => { - expect( - mergeTargetConfigurations( - { - defaultConfiguration: projectDefaultConfiguration, - }, - { - executor: 'target', - defaultConfiguration: defaultDefaultConfiguration, - } - ).defaultConfiguration - ).toEqual(merged); - }); - - it('should not merge if executor doesnt match', () => { - expect( - mergeTargetConfigurations( - { - executor: 'other', - defaultConfiguration: projectDefaultConfiguration, - }, - { - executor: 'target', - defaultConfiguration: defaultDefaultConfiguration, - } - ).defaultConfiguration - ).toEqual(projectDefaultConfiguration); }); }); - describe('run-commands', () => { - it('should merge two run-commands targets appropriately', () => { - const merged = mergeTargetConfigurations( - { - outputs: ['{projectRoot}/outputfile.json'], - options: { - command: 'eslint . -o outputfile.json', - }, - }, - { - cache: true, - inputs: [ - 'default', - '{workspaceRoot}/.eslintrc.json', - '{workspaceRoot}/apps/third-app/.eslintrc.json', - '{workspaceRoot}/tools/eslint-rules/**/*', - { externalDependencies: ['eslint'] }, - ], - options: { cwd: 'apps/third-app', command: 'eslint .' }, - executor: 'nx:run-commands', - configurations: {}, - } - ); - expect(merged).toMatchInlineSnapshot(` - { - "cache": true, - "configurations": {}, - "executor": "nx:run-commands", - "inputs": [ - "default", - "{workspaceRoot}/.eslintrc.json", - "{workspaceRoot}/apps/third-app/.eslintrc.json", - "{workspaceRoot}/tools/eslint-rules/**/*", - { - "externalDependencies": [ - "eslint", - ], - }, - ], - "options": { - "command": "eslint . -o outputfile.json", - "cwd": "apps/third-app", - }, - "outputs": [ - "{projectRoot}/outputfile.json", - ], - } - `); - }); - - it('should merge targets when the base uses command syntactic sugar', () => { - const merged = mergeTargetConfigurations( - { - outputs: ['{projectRoot}/outputfile.json'], - options: { - command: 'eslint . -o outputfile.json', - }, - }, - { - cache: true, - inputs: [ - 'default', - '{workspaceRoot}/.eslintrc.json', - '{workspaceRoot}/apps/third-app/.eslintrc.json', - '{workspaceRoot}/tools/eslint-rules/**/*', - { externalDependencies: ['eslint'] }, - ], - options: { cwd: 'apps/third-app' }, - configurations: {}, - command: 'eslint .', - } + it('should create nodes for files matching included patterns only', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + [['libs/a/project.json', 'libs/b/project.json']], + [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + include: ['libs/a/**'], + }), + ] ); - expect(merged).toMatchInlineSnapshot(` - { - "cache": true, - "command": "eslint .", - "configurations": {}, - "inputs": [ - "default", - "{workspaceRoot}/.eslintrc.json", - "{workspaceRoot}/apps/third-app/.eslintrc.json", - "{workspaceRoot}/tools/eslint-rules/**/*", - { - "externalDependencies": [ - "eslint", - ], - }, - ], - "options": { - "command": "eslint . -o outputfile.json", - "cwd": "apps/third-app", - }, - "outputs": [ - "{projectRoot}/outputfile.json", - ], - } - `); - }); - }); - describe('cache', () => { - it('should not be merged for incompatible targets', () => { - const result = mergeTargetConfigurations( - { - executor: 'foo', - }, - { - executor: 'bar', - cache: true, - } - ); - expect(result.cache).not.toBeDefined(); + expect(projectConfigurations.projects).toEqual({ + 'libs/a': { + name: 'a', + root: 'libs/a', + tags: ['fake-lib'], + }, }); }); - describe('metadata', () => { - it('should be added', () => { - const rootMap = new RootMapBuilder() - .addProject({ - root: 'libs/lib-a', - name: 'lib-a', - }) - .getRootMap(); - const sourceMap: ConfigurationSourceMaps = { - 'libs/lib-a': {}, - }; - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - targets: { - build: { - metadata: { - description: 'do stuff', - technologies: ['tech'], - }, - }, - }, - }, - sourceMap, - ['dummy', 'dummy.ts'] + it('should not create nodes for files matching excluded patterns', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + [['libs/a/project.json', 'libs/b/project.json']], + [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + exclude: ['libs/b/**'], + }), + ] ); - expect(rootMap['libs/lib-a'].targets.build.metadata).toEqual({ - description: 'do stuff', - technologies: ['tech'], - }); - expect(sourceMap['libs/lib-a']).toMatchObject({ - 'targets.build.metadata.description': ['dummy', 'dummy.ts'], - 'targets.build.metadata.technologies': ['dummy', 'dummy.ts'], - 'targets.build.metadata.technologies.0': ['dummy', 'dummy.ts'], - }); + expect(projectConfigurations.projects).toEqual({ + 'libs/a': { + name: 'a', + root: 'libs/a', + tags: ['fake-lib'], + }, }); + }); - it('should be merged', () => { - const rootMap = new RootMapBuilder() - .addProject({ - root: 'libs/lib-a', - name: 'lib-a', - targets: { - build: { - metadata: { - description: 'do stuff', - technologies: ['tech'], - }, - }, - }, - }) - .getRootMap(); - const sourceMap: ConfigurationSourceMaps = { - 'libs/lib-a': { - 'targets.build.metadata.technologies': ['existing', 'existing.ts'], - 'targets.build.metadata.technologies.0': [ - 'existing', - 'existing.ts', - ], - }, - }; - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - targets: { - build: { - metadata: { - description: 'do cool stuff', - technologies: ['tech2'], - }, - }, - }, - }, - sourceMap, - ['dummy', 'dummy.ts'] - ); - - expect(rootMap['libs/lib-a'].targets.build.metadata).toEqual({ - description: 'do cool stuff', - technologies: ['tech', 'tech2'], - }); - expect(sourceMap['libs/lib-a']).toMatchObject({ - 'targets.build.metadata.description': ['dummy', 'dummy.ts'], - 'targets.build.metadata.technologies': ['existing', 'existing.ts'], - 'targets.build.metadata.technologies.0': ['existing', 'existing.ts'], - 'targets.build.metadata.technologies.1': ['dummy', 'dummy.ts'], - }); - }); - }); - }); - - describe('mergeProjectConfigurationIntoRootMap', () => { - it('should merge targets from different configurations', () => { - const rootMap = new RootMapBuilder() - .addProject({ - root: 'libs/lib-a', - name: 'lib-a', - targets: { - echo: { - command: 'echo lib-a', - }, - }, - }) - .getRootMap(); - mergeProjectConfigurationIntoRootMap(rootMap, { - root: 'libs/lib-a', - name: 'lib-a', - targets: { - build: { - command: 'tsc', - }, - }, - }); - expect(rootMap['libs/lib-a']).toMatchInlineSnapshot(` - { - "name": "lib-a", - "root": "libs/lib-a", - "targets": { - "build": { - "executor": "nx:run-commands", - "options": { - "command": "tsc", - }, - }, - "echo": { - "command": "echo lib-a", - }, - }, - } - `); - }); - - // Target configuration merging is tested more thoroughly in mergeTargetConfigurations - it('should merge target configurations of compatible target declarations', () => { - const existingTargetConfiguration = { - command: 'already present', - }; - const shouldMergeConfigurationA = { - executor: 'build', - options: { - a: 1, - b: { - c: 2, - }, - }, - configurations: { - dev: { - foo: 'bar', - }, - prod: { - optimize: true, - }, - }, - }; - const shouldMergeConfigurationB = { - executor: 'build', - options: { - d: 3, - b: { - c: 2, - }, - }, - configurations: { - prod: { - foo: 'baz', - }, - }, - }; - const shouldntMergeConfigurationA = { - executor: 'build', - options: { - a: 1, - }, - }; - const shouldntMergeConfigurationB = { - executor: 'test', - options: { - test: 1, - }, - }; - const newTargetConfiguration = { - executor: 'echo', - options: { - echo: 'echo', - }, - }; - - const rootMap = new RootMapBuilder() - .addProject({ - root: 'libs/lib-a', - name: 'lib-a', - targets: { - existingTarget: existingTargetConfiguration, - shouldMerge: shouldMergeConfigurationA, - shouldntMerge: shouldntMergeConfigurationA, - }, - }) - .getRootMap(); - mergeProjectConfigurationIntoRootMap(rootMap, { - root: 'libs/lib-a', - name: 'lib-a', - targets: { - shouldMerge: shouldMergeConfigurationB, - shouldntMerge: shouldntMergeConfigurationB, - newTarget: newTargetConfiguration, - }, - }); - const merged = rootMap['libs/lib-a']; - expect(merged.targets['existingTarget']).toEqual( - existingTargetConfiguration + it('should normalize targets', async () => { + const { projects } = await createProjectConfigurationsWithPlugins( + undefined, + {}, + [['libs/a/project.json'], ['libs/a/project.json']], + [ + new LoadedNxPlugin(fakeTargetsPlugin, 'fake-targets-plugin'), + new LoadedNxPlugin(fakeTagPlugin, 'fake-tag-plugin'), + ] ); - expect(merged.targets['shouldMerge']).toMatchInlineSnapshot(` + expect(projects['libs/a'].targets.build).toMatchInlineSnapshot(` { - "configurations": { - "dev": { - "foo": "bar", - }, - "prod": { - "foo": "baz", - "optimize": true, - }, - }, - "executor": "build", + "configurations": {}, + "executor": "nx:run-commands", "options": { - "a": 1, - "b": { - "c": 2, - }, - "d": 3, + "command": "echo a @ libs/a", }, + "parallelism": true, } `); - expect(merged.targets['shouldntMerge']).toEqual( - shouldntMergeConfigurationB - ); - expect(merged.targets['newTarget']).toEqual(newTargetConfiguration); }); - it('should merge target configurations with glob pattern matching', () => { - const existingTargetConfiguration = { - command: 'already present', - }; - const partialA = { - executor: 'build', - dependsOn: ['^build'], - }; - const partialB = { - executor: 'build', - dependsOn: ['^build'], - }; - const partialC = { - executor: 'build', - dependsOn: ['^build'], - }; - const globMatch = { - dependsOn: ['^build', { project: 'app', target: 'build' }], - }; - const nonMatchingGlob = { - dependsOn: ['^production', 'build'], - }; + it('should validate that project names are unique', async () => { + const error = await createProjectConfigurationsWithPlugins( + undefined, + {}, + [['libs/a/project.json', 'libs/b/project.json', 'libs/c/project.json']], + [new LoadedNxPlugin(sameNamePlugin, 'same-name-plugin')] + ).catch((e) => e); + const isErrorType = isProjectConfigurationsError(error); + expect(isErrorType).toBe(true); + if (isErrorType) { + expect(error.errors).toMatchInlineSnapshot(` + [ + [MultipleProjectsWithSameNameError: The following projects are defined in multiple locations: + - same-name: + - libs/a + - libs/b + - libs/c - const rootMap = new RootMapBuilder() - .addProject({ - root: 'libs/lib-a', - name: 'lib-a', - targets: { - existingTarget: existingTargetConfiguration, - 'partial-path/a': partialA, - 'partial-path/b': partialB, - 'partial-path/c': partialC, - }, - }) - .getRootMap(); - mergeProjectConfigurationIntoRootMap(rootMap, { - root: 'libs/lib-a', - name: 'lib-a', - targets: { - 'partial-**/*': globMatch, - 'ci-*': nonMatchingGlob, - }, - }); - const merged = rootMap['libs/lib-a']; - expect(merged.targets['partial-path/a']).toMatchInlineSnapshot(` - { - "dependsOn": [ - "^build", - { - "project": "app", - "target": "build", - }, - ], - "executor": "build", - } - `); - expect(merged.targets['partial-path/b']).toMatchInlineSnapshot(` - { - "dependsOn": [ - "^build", - { - "project": "app", - "target": "build", - }, - ], - "executor": "build", - } - `); - expect(merged.targets['partial-path/c']).toMatchInlineSnapshot(` - { - "dependsOn": [ - "^build", - { - "project": "app", - "target": "build", - }, - ], - "executor": "build", - } - `); - // if the glob pattern doesn't match, the target is not merged - expect(merged.targets['ci-*']).toMatchInlineSnapshot(` - { - "dependsOn": [ - "^production", - "build", - ], - } - `); - // if the glob pattern matches, the target is merged - expect(merged.targets['partial-**/*']).toBeUndefined(); + To fix this, set a unique name for each project in a project.json inside the project's root. If the project does not currently have a project.json, you can create one that contains only a name.], + ] + `); + } }); - it('should concatenate tags and implicitDependencies', () => { - const rootMap = new RootMapBuilder() - .addProject({ - root: 'libs/lib-a', - name: 'lib-a', - tags: ['a', 'b'], - implicitDependencies: ['lib-b'], - }) - .getRootMap(); - mergeProjectConfigurationIntoRootMap(rootMap, { - root: 'libs/lib-a', - name: 'lib-a', - tags: ['b', 'c'], - implicitDependencies: ['lib-c', '!lib-b'], - }); - expect(rootMap['libs/lib-a'].tags).toEqual(['a', 'b', 'c']); - expect(rootMap['libs/lib-a'].implicitDependencies).toEqual([ - 'lib-b', - 'lib-c', - '!lib-b', - ]); + it('should validate that projects have a name', async () => { + const error = await createProjectConfigurationsWithPlugins( + undefined, + {}, + [['libs/a/project.json', 'libs/b/project.json', 'libs/c/project.json']], + [new LoadedNxPlugin(fakeTargetsPlugin, 'fake-targets-plugin')] + ).catch((e) => e); + const isErrorType = isProjectConfigurationsError(error); + expect(isErrorType).toBe(true); + if (isErrorType) { + expect(error.errors).toMatchInlineSnapshot(` + [ + [ProjectsWithNoNameError: The projects in the following directories have no name provided: + - libs/a + - libs/b + - libs/c], + ] + `); + } }); - it('should merge generator options', () => { - const rootMap = new RootMapBuilder() - .addProject({ - root: 'libs/lib-a', - name: 'lib-a', - generators: { - '@nx/angular:component': { - style: 'scss', - }, - }, - }) - .getRootMap(); - mergeProjectConfigurationIntoRootMap(rootMap, { - root: 'libs/lib-a', - name: 'lib-a', - generators: { - '@nx/angular:component': { - flat: true, - }, - '@nx/angular:service': { - spec: false, - }, - }, - }); - expect(rootMap['libs/lib-a'].generators).toMatchInlineSnapshot(` - { - "@nx/angular:component": { - "flat": true, - "style": "scss", - }, - "@nx/angular:service": { - "spec": false, + it('should provide helpful error if project has task containing cache and continuous', async () => { + const invalidCachePlugin: NxPluginV2 = { + name: 'invalid-cache-plugin', + createNodesV2: [ + 'libs/*/project.json', + (projectJsonPaths) => { + const results = []; + for (const projectJsonPath of projectJsonPaths) { + const root = dirname(projectJsonPath); + const name = root.split('/')[1]; + results.push([ + projectJsonPath, + { + projects: { + [root]: { + name, + root, + targets: { + build: { + executor: 'nx:run-commands', + options: { + command: 'echo foo', + }, + cache: true, + continuous: true, + }, + }, + }, + }, + }, + ] as const); + } + return results; }, - } - `); + ], + }; + + const error = await createProjectConfigurationsWithPlugins( + undefined, + {}, + [['libs/my-lib/project.json']], + [new LoadedNxPlugin(invalidCachePlugin, 'invalid-cache-plugin')] + ).catch((e) => e); + + const isErrorType = isProjectConfigurationsError(error); + expect(isErrorType).toBe(true); + if (isErrorType) { + expect(error.errors.map((m) => m.toString())).toMatchInlineSnapshot(` + [ + "[Configuration Error]: + Errors detected in targets of project "my-lib": + - "build" has both "cache" and "continuous" set to true. Continuous targets cannot be cached. Please remove the "cache" property.", + ] + `); + } }); - it('should merge namedInputs', () => { - const rootMap = new RootMapBuilder() - .addProject({ - root: 'libs/lib-a', - name: 'lib-a', - namedInputs: { - production: [ - '{projectRoot}/**/*.ts', - '!{projectRoot}/**/*.spec.ts', + it('should correctly set source maps', async () => { + const { sourceMaps } = await createProjectConfigurationsWithPlugins( + undefined, + {}, + [['libs/a/project.json'], ['libs/a/project.json']], + [ + new LoadedNxPlugin(fakeTargetsPlugin, 'fake-targets-plugin'), + new LoadedNxPlugin(fakeTagPlugin, 'fake-tag-plugin'), + ] + ); + expect(sourceMaps).toMatchInlineSnapshot(` + { + "libs/a": { + "name": [ + "libs/a/project.json", + "fake-tag-plugin", + ], + "root": [ + "libs/a/project.json", + "fake-tag-plugin", + ], + "tags": [ + "libs/a/project.json", + "fake-tag-plugin", + ], + "tags.fake-lib": [ + "libs/a/project.json", + "fake-tag-plugin", + ], + "targets": [ + "libs/a/project.json", + "fake-targets-plugin", + ], + "targets.build": [ + "libs/a/project.json", + "fake-targets-plugin", + ], + "targets.build.executor": [ + "libs/a/project.json", + "fake-targets-plugin", + ], + "targets.build.options": [ + "libs/a/project.json", + "fake-targets-plugin", + ], + "targets.build.options.command": [ + "libs/a/project.json", + "fake-targets-plugin", ], - test: ['{projectRoot}/**/*.spec.ts'], }, - }) - .getRootMap(); - mergeProjectConfigurationIntoRootMap(rootMap, { - root: 'libs/lib-a', - name: 'lib-a', - namedInputs: { - another: ['{projectRoot}/**/*.ts'], - production: ['{projectRoot}/**/*.prod.ts'], - }, - }); - expect(rootMap['libs/lib-a'].namedInputs).toMatchInlineSnapshot(` - { - "another": [ - "{projectRoot}/**/*.ts", - ], - "production": [ - "{projectRoot}/**/*.prod.ts", - ], - "test": [ - "{projectRoot}/**/*.spec.ts", - ], } `); }); - it('should merge release', () => { - const rootMap = new RootMapBuilder() - .addProject({ - root: 'libs/lib-a', - name: 'lib-a', - }) - .getRootMap(); - mergeProjectConfigurationIntoRootMap(rootMap, { - root: 'libs/lib-a', - name: 'lib-a', - release: { - version: { - versionActionsOptions: { fo: 'bar' }, - }, - }, - }); - expect(rootMap['libs/lib-a'].release).toMatchInlineSnapshot(` - { - "version": { - "versionActionsOptions": { - "fo": "bar", - }, - }, - } - `); + it('should include project and target context in error message when plugin returns invalid {workspaceRoot} token', async () => { + const invalidTokenPlugin: NxPluginV2 = { + name: 'invalid-token-plugin', + createNodesV2: [ + 'libs/*/project.json', + (projectJsonPaths) => + createNodesFromFiles( + (projectJsonPath) => { + const root = dirname(projectJsonPath); + const name = root.split('/')[1]; + return { + projects: { + [root]: { + name, + root, + targets: { + build: { + executor: 'nx:run-commands', + options: { + command: 'echo foo/{workspaceRoot}/bar', + }, + }, + }, + }, + }, + }; + }, + projectJsonPaths, + null, + null + ), + ], + }; + + const error = await createProjectConfigurationsWithPlugins( + undefined, + {}, + [['libs/my-app/project.json']], + [new LoadedNxPlugin(invalidTokenPlugin, 'invalid-token-plugin')] + ).catch((e) => e); + + expect(error.message).toContain( + 'The {workspaceRoot} token is only valid at the beginning of an option' + ); + expect(error.message).toContain('libs/my-app:build'); }); - describe('metadata', () => { - it('should be set if not previously defined', () => { - const rootMap = new RootMapBuilder() - .addProject({ - root: 'libs/lib-a', - name: 'lib-a', - }) - .getRootMap(); - const sourceMap: ConfigurationSourceMaps = { - 'libs/lib-a': {}, - }; - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - metadata: { - technologies: ['technology'], - targetGroups: { - group1: ['target1', 'target2'], + it('should include nx.json context in error message when target defaults have invalid {workspaceRoot} token', async () => { + const simplePlugin: NxPluginV2 = { + name: 'simple-plugin', + createNodesV2: [ + 'libs/*/project.json', + (projectJsonPaths) => + createNodesFromFiles( + (projectJsonPath) => { + const root = dirname(projectJsonPath); + const name = root.split('/')[1]; + return { + projects: { + [root]: { + name, + root, + targets: { + test: { + executor: 'nx:run-commands', + options: { + command: 'echo test', + }, + }, + }, + }, + }, + }; }, + projectJsonPaths, + null, + null + ), + ], + }; + + const nxJsonWithInvalidDefaults = { + targetDefaults: { + test: { + options: { + config: 'path/{workspaceRoot}/config.json', }, }, - sourceMap, - ['dummy', 'dummy.ts'] - ); + }, + }; + + const error = await createProjectConfigurationsWithPlugins( + undefined, + nxJsonWithInvalidDefaults, + [['libs/my-lib/project.json']], + [new LoadedNxPlugin(simplePlugin, 'simple-plugin')] + ).catch((e) => e); + + expect(error.message).toContain( + 'The {workspaceRoot} token is only valid at the beginning of an option' + ); + expect(error.message).toContain('nx.json[targetDefaults]:test'); + }); + + describe('negation pattern support', () => { + it('should support negation patterns in exclude to re-include specific files', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + [ + [ + 'libs/a-e2e/project.json', + 'libs/b-e2e/project.json', + 'libs/toolkit-workspace-e2e/project.json', + ], + ], + [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + exclude: ['**/*-e2e/**', '!**/toolkit-workspace-e2e/**'], + }), + ] + ); - expect(rootMap['libs/lib-a'].metadata).toEqual({ - technologies: ['technology'], - targetGroups: { - group1: ['target1', 'target2'], + expect(projectConfigurations.projects).toEqual({ + 'libs/toolkit-workspace-e2e': { + name: 'toolkit-workspace-e2e', + root: 'libs/toolkit-workspace-e2e', + tags: ['fake-lib'], }, }); - expect(sourceMap['libs/lib-a']).toMatchObject({ - 'metadata.technologies': ['dummy', 'dummy.ts'], - 'metadata.targetGroups': ['dummy', 'dummy.ts'], - 'metadata.targetGroups.group1': ['dummy', 'dummy.ts'], - }); }); - it('should concat arrays', () => { - const rootMap = new RootMapBuilder() - .addProject({ - root: 'libs/lib-a', - name: 'lib-a', - metadata: { - technologies: ['technology1'], - }, - }) - .getRootMap(); - const sourceMap: ConfigurationSourceMaps = { - 'libs/lib-a': { - 'metadata.technologies': ['existing', 'existing.ts'], - 'metadata.technologies.0': ['existing', 'existing.ts'], + it('should support negation patterns in include to exclude specific files', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + [ + [ + 'libs/a/project.json', + 'libs/b/project.json', + 'libs/c/project.json', + ], + ], + [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + include: ['libs/**', '!libs/b/**'], + }), + ] + ); + + expect(projectConfigurations.projects).toEqual({ + 'libs/a': { + name: 'a', + root: 'libs/a', + tags: ['fake-lib'], }, - }; - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - metadata: { - technologies: ['technology2'], - }, + 'libs/c': { + name: 'c', + root: 'libs/c', + tags: ['fake-lib'], }, - sourceMap, - ['dummy', 'dummy.ts'] - ); - - expect(rootMap['libs/lib-a'].metadata).toEqual({ - technologies: ['technology1', 'technology2'], - }); - expect(sourceMap['libs/lib-a']).toMatchObject({ - 'metadata.technologies': ['existing', 'existing.ts'], - 'metadata.technologies.0': ['existing', 'existing.ts'], - 'metadata.technologies.1': ['dummy', 'dummy.ts'], }); }); - it('should concat second level arrays', () => { - const rootMap = new RootMapBuilder() - .addProject({ - root: 'libs/lib-a', - name: 'lib-a', - metadata: { - targetGroups: { - group1: ['target1'], - }, - }, - }) - .getRootMap(); - const sourceMap: ConfigurationSourceMaps = { - 'libs/lib-a': { - 'metadata.targetGroups': ['existing', 'existing.ts'], - 'metadata.targetGroups.group1': ['existing', 'existing.ts'], - 'metadata.targetGroups.group1.0': ['existing', 'existing.ts'], - }, - }; - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - metadata: { - targetGroups: { - group1: ['target2'], - }, - }, - }, - sourceMap, - ['dummy', 'dummy.ts'] - ); + it('should handle multiple negation patterns correctly', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + [ + [ + 'libs/a/project.json', + 'libs/b/project.json', + 'libs/c/project.json', + 'libs/d/project.json', + ], + ], + [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + exclude: ['libs/**', '!libs/b/**', '!libs/c/**'], + }), + ] + ); - expect(rootMap['libs/lib-a'].metadata).toEqual({ - targetGroups: { - group1: ['target1', 'target2'], + expect(projectConfigurations.projects).toEqual({ + 'libs/b': { + name: 'b', + root: 'libs/b', + tags: ['fake-lib'], + }, + 'libs/c': { + name: 'c', + root: 'libs/c', + tags: ['fake-lib'], }, }); - - expect(sourceMap['libs/lib-a']).toMatchObject({ - 'metadata.targetGroups': ['existing', 'existing.ts'], - 'metadata.targetGroups.group1': ['existing', 'existing.ts'], - 'metadata.targetGroups.group1.0': ['existing', 'existing.ts'], - 'metadata.targetGroups.group1.1': ['dummy', 'dummy.ts'], - }); - - expect(sourceMap['libs/lib-a']['metadata.targetGroups']).toEqual([ - 'existing', - 'existing.ts', - ]); - expect(sourceMap['libs/lib-a']['metadata.targetGroups.group1']).toEqual( - ['existing', 'existing.ts'] - ); - expect( - sourceMap['libs/lib-a']['metadata.targetGroups.group1.0'] - ).toEqual(['existing', 'existing.ts']); - expect( - sourceMap['libs/lib-a']['metadata.targetGroups.group1.1'] - ).toEqual(['dummy', 'dummy.ts']); }); - it('should not clobber targetGroups', () => { - const rootMap = new RootMapBuilder() - .addProject({ - root: 'libs/lib-a', - name: 'lib-a', - metadata: { - targetGroups: { - group2: ['target3'], - }, - }, - }) - .getRootMap(); - const sourceMap: ConfigurationSourceMaps = { - 'libs/lib-a': {}, - }; - - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - metadata: { - technologies: ['technology'], - targetGroups: { - group1: ['target1', 'target2'], - }, - }, - }, - sourceMap, - ['dummy', 'dummy.ts'] - ); + it('should handle starting with negation pattern in exclude', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + [ + [ + 'libs/a/project.json', + 'libs/b/project.json', + 'libs/c/project.json', + ], + ], + [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + exclude: ['!libs/a/**'], + }), + ] + ); - expect(rootMap['libs/lib-a'].metadata).toEqual({ - technologies: ['technology'], - targetGroups: { - group1: ['target1', 'target2'], - group2: ['target3'], + // Should exclude everything except libs/a (first pattern is negation) + expect(projectConfigurations.projects).toEqual({ + 'libs/a': { + name: 'a', + root: 'libs/a', + tags: ['fake-lib'], }, }); }); - }); - describe('source map', () => { - it('should add new project info', () => { - const rootMap = new RootMapBuilder().getRootMap(); - const sourceMap: ConfigurationSourceMaps = { - 'libs/lib-a': {}, - }; - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - targets: { - build: { - executor: 'nx:run-commands', - options: { - command: 'echo hello', - }, - configurations: { - dev: { - command: 'echo dev', - }, - production: { - command: 'echo production', - }, - }, - }, - }, - tags: ['a', 'b'], - implicitDependencies: ['lib-b'], - }, - sourceMap, - ['dummy', 'dummy.ts'] - ); - expect(sourceMap).toMatchInlineSnapshot(` - { - "libs/lib-a": { - "implicitDependencies": [ - "dummy", - "dummy.ts", + it('should handle starting with negation pattern in include', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + [ + [ + 'libs/a/project.json', + 'libs/b/project.json', + 'libs/c/project.json', ], - "implicitDependencies.lib-b": [ - "dummy", - "dummy.ts", - ], - "name": [ - "dummy", - "dummy.ts", - ], - "root": [ - "dummy", - "dummy.ts", - ], - "tags": [ - "dummy", - "dummy.ts", - ], - "tags.a": [ - "dummy", - "dummy.ts", - ], - "tags.b": [ - "dummy", - "dummy.ts", - ], - "targets": [ - "dummy", - "dummy.ts", - ], - "targets.build": [ - "dummy", - "dummy.ts", - ], - "targets.build.configurations": [ - "dummy", - "dummy.ts", - ], - "targets.build.configurations.dev": [ - "dummy", - "dummy.ts", - ], - "targets.build.configurations.dev.command": [ - "dummy", - "dummy.ts", - ], - "targets.build.configurations.production": [ - "dummy", - "dummy.ts", - ], - "targets.build.configurations.production.command": [ - "dummy", - "dummy.ts", - ], - "targets.build.executor": [ - "dummy", - "dummy.ts", - ], - "targets.build.options": [ - "dummy", - "dummy.ts", - ], - "targets.build.options.command": [ - "dummy", - "dummy.ts", - ], - }, - } - `); - }); + ], + [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + include: ['!libs/b/**'], + }), + ] + ); - it('should merge root level properties', () => { - const rootMap: Record = {}; - const sourceMap: ConfigurationSourceMaps = { - 'libs/lib-a': {}, - }; - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - tags: ['a', 'b'], - projectType: 'application', + // Should include everything except libs/b (first pattern is negation) + expect(projectConfigurations.projects).toEqual({ + 'libs/a': { + name: 'a', + root: 'libs/a', + tags: ['fake-lib'], }, - sourceMap, - ['dummy', 'dummy.ts'] - ); - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - projectType: 'library', - tags: ['c'], - implicitDependencies: ['lib-b'], + 'libs/c': { + name: 'c', + root: 'libs/c', + tags: ['fake-lib'], }, - sourceMap, - ['dummy2', 'dummy2.ts'] - ); - assertCorrectKeysInSourceMap( - sourceMap, - 'libs/lib-a', - ['tags.a', 'dummy'], - ['tags.c', 'dummy2'], - ['projectType', 'dummy2'], - ['implicitDependencies.lib-b', 'dummy2'] - ); + }); }); - it('should merge target properties for compatible targets', () => { - const rootMap = new RootMapBuilder().getRootMap(); - const sourceMap: ConfigurationSourceMaps = { - 'libs/lib-a': {}, - }; - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - targets: { - build: { - executor: 'nx:run-commands', - inputs: ['input1'], - options: { - command: 'echo hello', - oldOption: 'value', - }, - configurations: { - dev: { - command: 'echo dev', - oldOption: 'old option', - }, - }, - }, - }, - }, - sourceMap, - ['dummy', 'dummy.ts'] - ); - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - targets: { - build: { - inputs: ['input2'], - outputs: ['output2'], - options: { - oldOption: 'new value', - newOption: 'value', - }, - configurations: { - dev: { - command: 'echo dev 2', - newOption: 'new option', - }, - production: { - command: 'echo production', - }, - }, - }, - }, - }, - sourceMap, - ['dummy2', 'dummy2.ts'] - ); - - assertCorrectKeysInSourceMap( - sourceMap, - 'libs/lib-a', - ['targets.build', 'dummy2'], - ['targets.build.executor', 'dummy'], - ['targets.build.inputs', 'dummy2'], - ['targets.build.outputs', 'dummy2'], - ['targets.build.options', 'dummy2'], - ['targets.build.options.command', 'dummy'], - ['targets.build.options.oldOption', 'dummy2'], - ['targets.build.options.newOption', 'dummy2'], - ['targets.build.configurations', 'dummy2'], - ['targets.build.configurations.dev.command', 'dummy2'], - ['targets.build.configurations.dev.oldOption', 'dummy'], - ['targets.build.configurations.dev.newOption', 'dummy2'], - ['targets.build.configurations.production.command', 'dummy2'] - ); - }); + it('should maintain backward compatibility with non-negation patterns', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + [['libs/a/project.json', 'libs/b/project.json']], + [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + include: ['libs/a/**'], + exclude: ['libs/b/**'], + }), + ] + ); - it('should override target options & configurations for incompatible targets', () => { - const rootMap = new RootMapBuilder().getRootMap(); - const sourceMap: ConfigurationSourceMaps = { - 'libs/lib-a': {}, - }; - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - targets: { - build: { - executor: 'nx:run-commands', - options: { - command: 'echo hello', - oldOption: 'value', - }, - configurations: { - dev: { - command: 'echo dev', - oldOption: 'old option', - }, - }, - }, - }, - }, - sourceMap, - ['dummy', 'dummy.ts'] - ); - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - targets: { - build: { - executor: 'other-executor', - options: { - option1: 'option1', - }, - configurations: { - prod: { - command: 'echo dev', - }, - }, - }, - }, + expect(projectConfigurations.projects).toEqual({ + 'libs/a': { + name: 'a', + root: 'libs/a', + tags: ['fake-lib'], }, - sourceMap, - ['dummy2', 'dummy2.ts'] - ); - assertCorrectKeysInSourceMap( - sourceMap, - 'libs/lib-a', - ['targets.build', 'dummy2'], - ['targets.build.executor', 'dummy2'], - ['targets.build.options', 'dummy2'], - ['targets.build.options.option1', 'dummy2'], - ['targets.build.configurations', 'dummy2'], - ['targets.build.configurations.prod', 'dummy2'], - ['targets.build.configurations.prod.command', 'dummy2'] - ); - - expect( - sourceMap['libs/lib-a']['targets.build.configurations.dev'] - ).toBeFalsy(); - expect(sourceMap['libs/lib-a']['targets.build.outputs']).toBeFalsy(); - expect( - sourceMap['libs/lib-a']['targets.build.options.command'] - ).toBeFalsy(); + }); }); - it('should not merge top level properties for incompatible targets', () => { - const rootMap = new RootMapBuilder().getRootMap(); - const sourceMap: ConfigurationSourceMaps = { - 'libs/lib-a': {}, - }; - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - targets: { - build: { - executor: 'nx:run-commands', - inputs: ['input1'], - }, - }, - }, - sourceMap, - ['dummy', 'dummy.ts'] - ); - mergeProjectConfigurationIntoRootMap( - rootMap, - { - root: 'libs/lib-a', - name: 'lib-a', - targets: { - build: { - executor: 'other-executor', - outputs: ['output1'], - }, - }, - }, - sourceMap, - ['dummy2', 'dummy2.ts'] - ); - assertCorrectKeysInSourceMap( - sourceMap, - 'libs/lib-a', - ['targets.build', 'dummy2'], - ['targets.build.executor', 'dummy2'], - ['targets.build.outputs', 'dummy2'] - ); - - expect(sourceMap['libs/lib-a']['targets.build.inputs']).toBeFalsy(); - }); + it('should handle overlapping patterns with last match winning', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + [ + [ + 'libs/a/project.json', + 'libs/a/special/project.json', + 'libs/b/project.json', + ], + ], + [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + exclude: ['libs/**', '!libs/a/**', 'libs/a/special/**'], + }), + ] + ); - it('should merge generator property', () => { - const rootMap = new RootMapBuilder().getRootMap(); - const sourceMap: ConfigurationSourceMaps = { - 'libs/lib-a': {}, - }; - mergeProjectConfigurationIntoRootMap( - rootMap, - { - name: 'lib-a', - root: 'libs/lib-a', - generators: { - '@nx/angular:component': { - option1: true, - option2: 'true', - }, - }, - }, - sourceMap, - ['dummy', 'dummy.ts'] - ); - mergeProjectConfigurationIntoRootMap( - rootMap, - { - name: 'lib-a', - root: 'libs/lib-a', - generators: { - '@nx/angular:component': { - option1: false, - option3: { - nested: 3, - }, - }, - }, + // Exclude all libs, except a, but re-exclude a/special (last match wins) + expect(projectConfigurations.projects).toEqual({ + 'libs/a': { + name: 'a', + root: 'libs/a', + tags: ['fake-lib'], }, - sourceMap, - ['dummy2', 'dummy2.ts'] - ); - - assertCorrectKeysInSourceMap( - sourceMap, - 'libs/lib-a', - ['generators.@nx/angular:component.option1', 'dummy2'], - ['generators.@nx/angular:component.option2', 'dummy'], - ['generators.@nx/angular:component.option3', 'dummy2'] - ); + }); }); - }); - }); - - describe('readProjectsConfigurationsFromRootMap', () => { - it('should error if multiple roots point to the same project', () => { - const rootMap = new RootMapBuilder() - .addProject({ - name: 'lib', - root: 'apps/lib-a', - }) - .addProject({ - name: 'lib', - root: 'apps/lib-b', - }) - .getRootMap(); - expect(() => { - readProjectConfigurationsFromRootMap(rootMap); - }).toThrowErrorMatchingInlineSnapshot(` - "The following projects are defined in multiple locations: - - lib: - - apps/lib-a - - apps/lib-b - - To fix this, set a unique name for each project in a project.json inside the project's root. If the project does not currently have a project.json, you can create one that contains only a name." - `); - }); - - it('should read root map into standard projects configurations form', () => { - const rootMap = new RootMapBuilder() - .addProject({ - name: 'lib-a', - root: 'libs/a', - }) - .addProject({ - name: 'lib-b', - root: 'libs/b', - }) - .addProject({ - name: 'lib-shared-b', - root: 'libs/shared/b', - }) - .getRootMap(); - expect(readProjectConfigurationsFromRootMap(rootMap)) - .toMatchInlineSnapshot(` - { - "lib-a": { - "name": "lib-a", - "root": "libs/a", - }, - "lib-b": { - "name": "lib-b", - "root": "libs/b", - }, - "lib-shared-b": { - "name": "lib-shared-b", - "root": "libs/shared/b", - }, - } - `); - }); - }); - - describe('isCompatibleTarget', () => { - it('should return true if only one target specifies an executor', () => { - expect( - isCompatibleTarget( - { - executor: 'nx:run-commands', - }, - {} - ) - ).toBe(true); - }); + it('should work with both include and exclude having negation patterns', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + [ + [ + 'libs/a/project.json', + 'libs/b/project.json', + 'libs/c/project.json', + 'libs/d/project.json', + ], + ], + [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + include: ['libs/**', '!libs/d/**'], + exclude: ['libs/b/**', '!libs/c/**'], + }), + ] + ); - it('should return true if both targets specify the same executor', () => { - expect( - isCompatibleTarget( - { - executor: 'nx:run-commands', + // Include: a, b, c (all except d) + // Exclude: b (but not c due to negation) + // Result: a, c + expect(projectConfigurations.projects).toEqual({ + 'libs/a': { + name: 'a', + root: 'libs/a', + tags: ['fake-lib'], }, - { - executor: 'nx:run-commands', - } - ) - ).toBe(true); - }); - - it('should return false if both targets specify different executors', () => { - expect( - isCompatibleTarget( - { - executor: 'nx:run-commands', + 'libs/c': { + name: 'c', + root: 'libs/c', + tags: ['fake-lib'], }, - { - executor: 'other-executor', - } - ) - ).toBe(false); - }); + }); + }); - it('should return true if both targets specify the same command', () => { - expect( - isCompatibleTarget( - { - executor: 'nx:run-commands', - options: { - command: 'echo', - }, - }, - { - executor: 'nx:run-commands', - options: { - command: 'echo', - }, - } - ) - ).toBe(true); - }); + it('should handle empty arrays with negation support intact', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + [['libs/a/project.json', 'libs/b/project.json']], + [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + include: [], + exclude: [], + }), + ] + ); - it('should return false if both targets specify different commands', () => { - expect( - isCompatibleTarget( - { - executor: 'nx:run-commands', - options: { - command: 'echo', - }, + // Empty arrays should not filter anything + expect(projectConfigurations.projects).toEqual({ + 'libs/a': { + name: 'a', + root: 'libs/a', + tags: ['fake-lib'], }, - { - executor: 'nx:run-commands', - options: { - command: 'echo2', - }, - } - ) - ).toBe(false); - }); - - it('should return false if one target specifies a command, and the other specifies commands', () => { - expect( - isCompatibleTarget( - { - executor: 'nx:run-commands', - options: { - command: 'echo', - }, + 'libs/b': { + name: 'b', + root: 'libs/b', + tags: ['fake-lib'], }, - { - executor: 'nx:run-commands', - options: { - commands: ['echo', 'other'], - }, - } - ) - ).toBe(false); - }); - }); - - describe('normalizeTarget', () => { - it('should support {projectRoot}, {workspaceRoot}, and {projectName} tokens', () => { - const config = { - name: 'project', - root: 'libs/project', - targets: { - foo: { command: 'echo {projectRoot}' }, - }, - }; - expect(normalizeTarget(config.targets.foo, config, workspaceRoot, {}, '')) - .toMatchInlineSnapshot(` - { - "configurations": {}, - "executor": "nx:run-commands", - "options": { - "command": "echo libs/project", - }, - "parallelism": true, - } - `); - }); - it('should not mutate the target', () => { - const config = { - name: 'project', - root: 'libs/project', - targets: { - foo: { - executor: 'nx:noop', - options: { - config: '{projectRoot}/config.json', - }, - configurations: { - prod: { - config: '{projectRoot}/config.json', - }, - }, - }, - bar: { - command: 'echo {projectRoot}', - options: { - config: '{projectRoot}/config.json', - }, - configurations: { - prod: { - config: '{projectRoot}/config.json', - }, - }, - }, - }, - }; - const originalConfig = JSON.stringify(config, null, 2); - - normalizeTarget(config.targets.foo, config, workspaceRoot, {}, ''); - normalizeTarget(config.targets.bar, config, workspaceRoot, {}, ''); - expect(JSON.stringify(config, null, 2)).toEqual(originalConfig); - }); - }); - - describe('createProjectConfigurations', () => { - /* A fake plugin that sets `fake-lib` tag to libs. */ - const fakeTagPlugin: NxPluginV2 = { - name: 'fake-tag-plugin', - createNodesV2: [ - 'libs/*/project.json', - (vitestConfigPaths) => - createNodesFromFiles( - (vitestConfigPath) => { - const [_libs, name, _config] = vitestConfigPath.split('/'); - return { - projects: { - [name]: { - name: name, - root: `libs/${name}`, - tags: ['fake-lib'], - }, - }, - }; - }, - vitestConfigPaths, - null, - null - ), - ], - }; - - const fakeTargetsPlugin: NxPluginV2 = { - name: 'fake-targets-plugin', - createNodesV2: [ - 'libs/*/project.json', - (projectJsonPaths) => - createNodesFromFiles( - (projectJsonPath) => { - const root = dirname(projectJsonPath); - return { - projects: { - [root]: { - root, - targets: { - build: { - executor: 'nx:run-commands', - options: { - command: 'echo {projectName} @ {projectRoot}', - }, - }, - }, - }, - }, - }; - }, - projectJsonPaths, - null, - null - ), - ], - }; - - const sameNamePlugin: NxPluginV2 = { - name: 'same-name-plugin', - createNodesV2: [ - 'libs/*/project.json', - (projectJsonPaths) => - createNodesFromFiles( - (projectJsonPath) => { - const root = dirname(projectJsonPath); - return { - projects: { - [root]: { - root, - name: 'same-name', - }, - }, - }; - }, - projectJsonPaths, - null, - null - ), - ], - }; - - it('should create nodes for files matching included patterns only', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/a/project.json', 'libs/b/project.json']], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - }), - ] - ); - - expect(projectConfigurations.projects).toEqual({ - 'libs/a': { - name: 'a', - root: 'libs/a', - tags: ['fake-lib'], - }, - 'libs/b': { - name: 'b', - root: 'libs/b', - tags: ['fake-lib'], - }, - }); - }); - - it('should create nodes for files matching included patterns only', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/a/project.json', 'libs/b/project.json']], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - include: ['libs/a/**'], - }), - ] - ); - - expect(projectConfigurations.projects).toEqual({ - 'libs/a': { - name: 'a', - root: 'libs/a', - tags: ['fake-lib'], - }, - }); - }); - - it('should not create nodes for files matching excluded patterns', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/a/project.json', 'libs/b/project.json']], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - exclude: ['libs/b/**'], - }), - ] - ); - - expect(projectConfigurations.projects).toEqual({ - 'libs/a': { - name: 'a', - root: 'libs/a', - tags: ['fake-lib'], - }, - }); - }); - - describe('negation pattern support', () => { - it('should support negation patterns in exclude to re-include specific files', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [ - [ - 'libs/a-e2e/project.json', - 'libs/b-e2e/project.json', - 'libs/toolkit-workspace-e2e/project.json', - ], - ], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - exclude: ['**/*-e2e/**', '!**/toolkit-workspace-e2e/**'], - }), - ] - ); - - expect(projectConfigurations.projects).toEqual({ - 'libs/toolkit-workspace-e2e': { - name: 'toolkit-workspace-e2e', - root: 'libs/toolkit-workspace-e2e', - tags: ['fake-lib'], - }, - }); - }); - - it('should support negation patterns in include to exclude specific files', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [ - [ - 'libs/a/project.json', - 'libs/b/project.json', - 'libs/c/project.json', - ], - ], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - include: ['libs/**', '!libs/b/**'], - }), - ] - ); - - expect(projectConfigurations.projects).toEqual({ - 'libs/a': { - name: 'a', - root: 'libs/a', - tags: ['fake-lib'], - }, - 'libs/c': { - name: 'c', - root: 'libs/c', - tags: ['fake-lib'], - }, - }); - }); - - it('should handle multiple negation patterns correctly', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [ - [ - 'libs/a/project.json', - 'libs/b/project.json', - 'libs/c/project.json', - 'libs/d/project.json', - ], - ], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - exclude: ['libs/**', '!libs/b/**', '!libs/c/**'], - }), - ] - ); - - expect(projectConfigurations.projects).toEqual({ - 'libs/b': { - name: 'b', - root: 'libs/b', - tags: ['fake-lib'], - }, - 'libs/c': { - name: 'c', - root: 'libs/c', - tags: ['fake-lib'], - }, - }); - }); - - it('should handle starting with negation pattern in exclude', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [ - [ - 'libs/a/project.json', - 'libs/b/project.json', - 'libs/c/project.json', - ], - ], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - exclude: ['!libs/a/**'], - }), - ] - ); - - // Should exclude everything except libs/a (first pattern is negation) - expect(projectConfigurations.projects).toEqual({ - 'libs/a': { - name: 'a', - root: 'libs/a', - tags: ['fake-lib'], - }, - }); - }); - - it('should handle starting with negation pattern in include', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [ - [ - 'libs/a/project.json', - 'libs/b/project.json', - 'libs/c/project.json', - ], - ], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - include: ['!libs/b/**'], - }), - ] - ); - - // Should include everything except libs/b (first pattern is negation) - expect(projectConfigurations.projects).toEqual({ - 'libs/a': { - name: 'a', - root: 'libs/a', - tags: ['fake-lib'], - }, - 'libs/c': { - name: 'c', - root: 'libs/c', - tags: ['fake-lib'], - }, - }); - }); - - it('should maintain backward compatibility with non-negation patterns', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/a/project.json', 'libs/b/project.json']], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - include: ['libs/a/**'], - exclude: ['libs/b/**'], - }), - ] - ); - - expect(projectConfigurations.projects).toEqual({ - 'libs/a': { - name: 'a', - root: 'libs/a', - tags: ['fake-lib'], - }, - }); - }); - - it('should handle overlapping patterns with last match winning', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [ - [ - 'libs/a/project.json', - 'libs/a/special/project.json', - 'libs/b/project.json', - ], - ], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - exclude: ['libs/**', '!libs/a/**', 'libs/a/special/**'], - }), - ] - ); - - // Exclude all libs, except a, but re-exclude a/special (last match wins) - expect(projectConfigurations.projects).toEqual({ - 'libs/a': { - name: 'a', - root: 'libs/a', - tags: ['fake-lib'], - }, - }); - }); - - it('should work with both include and exclude having negation patterns', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [ - [ - 'libs/a/project.json', - 'libs/b/project.json', - 'libs/c/project.json', - 'libs/d/project.json', - ], - ], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - include: ['libs/**', '!libs/d/**'], - exclude: ['libs/b/**', '!libs/c/**'], - }), - ] - ); - - // Include: a, b, c (all except d) - // Exclude: b (but not c due to negation) - // Result: a, c - expect(projectConfigurations.projects).toEqual({ - 'libs/a': { - name: 'a', - root: 'libs/a', - tags: ['fake-lib'], - }, - 'libs/c': { - name: 'c', - root: 'libs/c', - tags: ['fake-lib'], - }, - }); - }); - - it('should handle empty arrays with negation support intact', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/a/project.json', 'libs/b/project.json']], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - include: [], - exclude: [], - }), - ] - ); - - // Empty arrays should not filter anything - expect(projectConfigurations.projects).toEqual({ - 'libs/a': { - name: 'a', - root: 'libs/a', - tags: ['fake-lib'], - }, - 'libs/b': { - name: 'b', - root: 'libs/b', - tags: ['fake-lib'], - }, - }); - }); - }); - - it('should normalize targets', async () => { - const { projects } = await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/a/project.json'], ['libs/a/project.json']], - [ - new LoadedNxPlugin(fakeTargetsPlugin, 'fake-targets-plugin'), - new LoadedNxPlugin(fakeTagPlugin, 'fake-tag-plugin'), - ] - ); - expect(projects['libs/a'].targets.build).toMatchInlineSnapshot(` - { - "configurations": {}, - "executor": "nx:run-commands", - "options": { - "command": "echo a @ libs/a", - }, - "parallelism": true, - } - `); - }); - - it('should validate that project names are unique', async () => { - const error = await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/a/project.json', 'libs/b/project.json', 'libs/c/project.json']], - [new LoadedNxPlugin(sameNamePlugin, 'same-name-plugin')] - ).catch((e) => e); - const isErrorType = isProjectConfigurationsError(error); - expect(isErrorType).toBe(true); - if (isErrorType) { - expect(error.errors).toMatchInlineSnapshot(` - [ - [MultipleProjectsWithSameNameError: The following projects are defined in multiple locations: - - same-name: - - libs/a - - libs/b - - libs/c - - To fix this, set a unique name for each project in a project.json inside the project's root. If the project does not currently have a project.json, you can create one that contains only a name.], - ] - `); - } - }); - - it('should validate that projects have a name', async () => { - const error = await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/a/project.json', 'libs/b/project.json', 'libs/c/project.json']], - [new LoadedNxPlugin(fakeTargetsPlugin, 'fake-targets-plugin')] - ).catch((e) => e); - const isErrorType = isProjectConfigurationsError(error); - expect(isErrorType).toBe(true); - if (isErrorType) { - expect(error.errors).toMatchInlineSnapshot(` - [ - [ProjectsWithNoNameError: The projects in the following directories have no name provided: - - libs/a - - libs/b - - libs/c], - ] - `); - } - }); - - it('should provide helpful error if project has task containing cache and continuous', async () => { - const invalidCachePlugin: NxPluginV2 = { - name: 'invalid-cache-plugin', - createNodesV2: [ - 'libs/*/project.json', - (projectJsonPaths) => { - const results = []; - for (const projectJsonPath of projectJsonPaths) { - const root = dirname(projectJsonPath); - const name = root.split('/')[1]; - results.push([ - projectJsonPath, - { - projects: { - [root]: { - name, - root, - targets: { - build: { - executor: 'nx:run-commands', - options: { - command: 'echo foo', - }, - cache: true, - continuous: true, - }, - }, - }, - }, - }, - ] as const); - } - return results; - }, - ], - }; - - const error = await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/my-lib/project.json']], - [new LoadedNxPlugin(invalidCachePlugin, 'invalid-cache-plugin')] - ).catch((e) => e); - - const isErrorType = isProjectConfigurationsError(error); - expect(isErrorType).toBe(true); - if (isErrorType) { - expect(error.errors.map((m) => m.toString())).toMatchInlineSnapshot(` - [ - "[Configuration Error]: - Errors detected in targets of project "my-lib": - - "build" has both "cache" and "continuous" set to true. Continuous targets cannot be cached. Please remove the "cache" property.", - ] - `); - } - }); - - it('should correctly set source maps', async () => { - const { sourceMaps } = await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/a/project.json'], ['libs/a/project.json']], - [ - new LoadedNxPlugin(fakeTargetsPlugin, 'fake-targets-plugin'), - new LoadedNxPlugin(fakeTagPlugin, 'fake-tag-plugin'), - ] - ); - expect(sourceMaps).toMatchInlineSnapshot(` - { - "libs/a": { - "name": [ - "libs/a/project.json", - "fake-tag-plugin", - ], - "root": [ - "libs/a/project.json", - "fake-tag-plugin", - ], - "tags": [ - "libs/a/project.json", - "fake-tag-plugin", - ], - "tags.fake-lib": [ - "libs/a/project.json", - "fake-tag-plugin", - ], - "targets": [ - "libs/a/project.json", - "fake-targets-plugin", - ], - "targets.build": [ - "libs/a/project.json", - "fake-targets-plugin", - ], - "targets.build.executor": [ - "libs/a/project.json", - "fake-targets-plugin", - ], - "targets.build.options": [ - "libs/a/project.json", - "fake-targets-plugin", - ], - "targets.build.options.command": [ - "libs/a/project.json", - "fake-targets-plugin", - ], - }, - } - `); - }); - - it('should include project and target context in error message when plugin returns invalid {workspaceRoot} token', async () => { - const invalidTokenPlugin: NxPluginV2 = { - name: 'invalid-token-plugin', - createNodesV2: [ - 'libs/*/project.json', - (projectJsonPaths) => - createNodesFromFiles( - (projectJsonPath) => { - const root = dirname(projectJsonPath); - const name = root.split('/')[1]; - return { - projects: { - [root]: { - name, - root, - targets: { - build: { - executor: 'nx:run-commands', - options: { - command: 'echo foo/{workspaceRoot}/bar', - }, - }, - }, - }, - }, - }; - }, - projectJsonPaths, - null, - null - ), - ], - }; - - const error = await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/my-app/project.json']], - [new LoadedNxPlugin(invalidTokenPlugin, 'invalid-token-plugin')] - ).catch((e) => e); - - expect(error.message).toContain( - 'The {workspaceRoot} token is only valid at the beginning of an option' - ); - expect(error.message).toContain('libs/my-app:build'); - }); - - it('should include nx.json context in error message when target defaults have invalid {workspaceRoot} token', async () => { - const simplePlugin: NxPluginV2 = { - name: 'simple-plugin', - createNodesV2: [ - 'libs/*/project.json', - (projectJsonPaths) => - createNodesFromFiles( - (projectJsonPath) => { - const root = dirname(projectJsonPath); - const name = root.split('/')[1]; - return { - projects: { - [root]: { - name, - root, - targets: { - test: { - executor: 'nx:run-commands', - options: { - command: 'echo test', - }, - }, - }, - }, - }, - }; - }, - projectJsonPaths, - null, - null - ), - ], - }; - - const nxJsonWithInvalidDefaults = { - targetDefaults: { - test: { - options: { - config: 'path/{workspaceRoot}/config.json', - }, - }, - }, - }; - - const error = await createProjectConfigurationsWithPlugins( - undefined, - nxJsonWithInvalidDefaults, - [['libs/my-lib/project.json']], - [new LoadedNxPlugin(simplePlugin, 'simple-plugin')] - ).catch((e) => e); - - expect(error.message).toContain( - 'The {workspaceRoot} token is only valid at the beginning of an option' - ); - expect(error.message).toContain('nx.json[targetDefaults]:test'); - }); - }); - - describe('merge target default with target definition', () => { - it('should merge options', () => { - const sourceMap: Record = { - targets: ['dummy', 'dummy.ts'], - 'targets.build': ['dummy', 'dummy.ts'], - 'targets.build.options': ['dummy', 'dummy.ts'], - 'targets.build.options.command': ['dummy', 'dummy.ts'], - 'targets.build.options.cwd': ['project.json', 'nx/project-json'], - }; - const result = mergeTargetDefaultWithTargetDefinition( - 'build', - { - name: 'myapp', - root: 'apps/myapp', - targets: { - build: { - executor: 'nx:run-commands', - options: { - command: 'echo', - cwd: '{workspaceRoot}', - }, - }, - }, - }, - { - options: { - command: 'tsc', - cwd: 'apps/myapp', - }, - }, - sourceMap - ); - - // Command was defined by a non-core plugin so it should be - // overwritten. - expect(result.options.command).toEqual('tsc'); - expect(sourceMap['targets.build.options.command']).toEqual([ - 'nx.json', - 'nx/target-defaults', - ]); - // Cwd was defined by a core plugin so it should be left unchanged. - expect(result.options.cwd).toEqual('{workspaceRoot}'); - expect(sourceMap['targets.build.options.cwd']).toEqual([ - 'project.json', - 'nx/project-json', - ]); - // other source map entries should be left unchanged - expect(sourceMap['targets']).toEqual(['dummy', 'dummy.ts']); - }); - - it('should not overwrite dependsOn', () => { - const sourceMap: Record = { - targets: ['dummy', 'dummy.ts'], - 'targets.build': ['dummy', 'dummy.ts'], - 'targets.build.options': ['dummy', 'dummy.ts'], - 'targets.build.options.command': ['dummy', 'dummy.ts'], - 'targets.build.options.cwd': ['project.json', 'nx/project-json'], - 'targets.build.dependsOn': ['project.json', 'nx/project-json'], - }; - const result = mergeTargetDefaultWithTargetDefinition( - 'build', - { - name: 'myapp', - root: 'apps/myapp', - targets: { - build: { - executor: 'nx:run-commands', - options: { - command: 'echo', - cwd: '{workspaceRoot}', - }, - dependsOn: [], - }, - }, - }, - { - options: { - command: 'tsc', - cwd: 'apps/myapp', - }, - dependsOn: ['^build'], - }, - sourceMap - ); - - // Command was defined by a core plugin so it should - // not be replaced by target default - expect(result.dependsOn).toEqual([]); + }); + }); }); }); }); - -class RootMapBuilder { - private rootMap: Record = {}; - - addProject(p: ProjectConfiguration) { - this.rootMap[p.root] = p; - return this; - } - - getRootMap() { - return this.rootMap; - } -} - -function assertCorrectKeysInSourceMap( - sourceMaps: ConfigurationSourceMaps, - root: string, - ...tuples: [string, string][] -) { - const sourceMap = sourceMaps[root]; - tuples.forEach(([key, value]) => { - if (!sourceMap[key]) { - throw new Error(`Expected sourceMap to contain key ${key}`); - } - try { - expect(sourceMap[key][0]).toEqual(value); - } catch (error) { - // Enhancing the error message with the problematic key - throw new Error(`Assertion failed for key '${key}': \n ${error.message}`); - } - }); -} diff --git a/packages/nx/src/project-graph/utils/project-configuration-utils.ts b/packages/nx/src/project-graph/utils/project-configuration-utils.ts index 67d96a09c3f..e25dbd854e7 100644 --- a/packages/nx/src/project-graph/utils/project-configuration-utils.ts +++ b/packages/nx/src/project-graph/utils/project-configuration-utils.ts @@ -1,49 +1,28 @@ -import { NxJsonConfiguration, TargetDefaults } from '../../config/nx-json'; +import { NxJsonConfiguration } from '../../config/nx-json'; import { ProjectGraphExternalNode } from '../../config/project-graph'; -import { - ProjectConfiguration, - ProjectMetadata, - TargetConfiguration, - TargetMetadata, -} from '../../config/workspace-json-project-json'; -import { readJsonFile } from '../../utils/fileutils'; -import { NX_PREFIX } from '../../utils/logger'; +import { ProjectConfiguration } from '../../config/workspace-json-project-json'; import { workspaceRoot } from '../../utils/workspace-root'; -import { ProjectNameInNodePropsManager } from './project-configuration/name-substitution-manager'; import { - recordSourceMapKeysByIndex, - targetConfigurationsSourceMapKey, - targetOptionSourceMapKey, - targetSourceMapKey, -} from './project-configuration/source-maps'; + createRootMap, + ProjectNodesManager, +} from './project-configuration/project-nodes-manager'; +import { validateAndNormalizeProjectRootMap } from './project-configuration/target-normalization'; import { minimatch } from 'minimatch'; -import { existsSync } from 'node:fs'; -import { join } from 'path'; import { performance } from 'perf_hooks'; -import { - getExecutorInformation, - parseExecutor, -} from '../../command-line/run/executor-utils'; -import { toProjectName } from '../../config/to-project-name'; import { DelayedSpinner } from '../../utils/delayed-spinner'; -import { isGlobPattern } from '../../utils/globs'; import { AggregateCreateNodesError, formatAggregateCreateNodesError, isAggregateCreateNodesError, isMultipleProjectsWithSameNameError, isProjectsWithNoNameError, - isProjectWithExistingNameError, - isProjectWithNoNameError, isWorkspaceValidityError, MergeNodesError, MultipleProjectsWithSameNameError, ProjectConfigurationsError, ProjectsWithNoNameError, - ProjectWithExistingNameError, - ProjectWithNoNameError, WorkspaceValidityError, } from '../error-types'; import type { LoadedNxPlugin } from '../plugins/loaded-nx-plugin'; @@ -53,289 +32,11 @@ import type { ConfigurationSourceMaps, SourceInformation, } from './project-configuration/source-maps'; -export type { ConfigurationSourceMaps, SourceInformation }; - -export function mergeProjectConfigurationIntoRootMap( - projectRootMap: Record, - project: ProjectConfiguration, - configurationSourceMaps?: ConfigurationSourceMaps, - sourceInformation?: SourceInformation, - // This function is used when reading project configuration - // in generators, where we don't want to do this. - skipTargetNormalization?: boolean -): { - previousName?: string; -} { - project.root = project.root === '' ? '.' : project.root; - if (configurationSourceMaps && !configurationSourceMaps[project.root]) { - configurationSourceMaps[project.root] = {}; - } - const sourceMap = configurationSourceMaps?.[project.root]; - - let matchingProject = projectRootMap[project.root]; - - if (!matchingProject) { - projectRootMap[project.root] = { - root: project.root, - }; - matchingProject = projectRootMap[project.root]; - if (sourceMap) { - sourceMap[`root`] = sourceInformation; - } - } - - // This handles top level properties that are overwritten. - // e.g. `srcRoot`, `projectType`, or other fields that shouldn't be extended - // Note: `name` is set specifically here to keep it from changing. The name is - // always determined by the first inference plugin to ID a project, unless it has - // a project.json in which case it was already updated above. - const updatedProjectConfiguration = { - ...matchingProject, - }; - - for (const k in project) { - if ( - ![ - 'tags', - 'implicitDependencies', - 'generators', - 'targets', - 'metadata', - 'namedInputs', - ].includes(k) - ) { - updatedProjectConfiguration[k] = project[k]; - if (sourceMap) { - sourceMap[`${k}`] = sourceInformation; - } - } - } - - // The next blocks handle properties that should be themselves merged (e.g. targets, tags, and implicit dependencies) - if (project.tags) { - updatedProjectConfiguration.tags = Array.from( - new Set((matchingProject.tags ?? []).concat(project.tags)) - ); - - if (sourceMap) { - sourceMap['tags'] ??= sourceInformation; - project.tags.forEach((tag) => { - sourceMap[`tags.${tag}`] = sourceInformation; - }); - } - } - - if (project.implicitDependencies) { - updatedProjectConfiguration.implicitDependencies = ( - matchingProject.implicitDependencies ?? [] - ).concat(project.implicitDependencies); - - if (sourceMap) { - sourceMap['implicitDependencies'] ??= sourceInformation; - project.implicitDependencies.forEach((implicitDependency) => { - sourceMap[`implicitDependencies.${implicitDependency}`] = - sourceInformation; - }); - } - } - - if (project.generators) { - // Start with generators config in new project. - updatedProjectConfiguration.generators = { ...project.generators }; - - if (sourceMap) { - sourceMap['generators'] ??= sourceInformation; - for (const generator in project.generators) { - sourceMap[`generators.${generator}`] = sourceInformation; - for (const property in project.generators[generator]) { - sourceMap[`generators.${generator}.${property}`] = sourceInformation; - } - } - } - - if (matchingProject.generators) { - // For each generator that was already defined, shallow merge the options. - // Project contains the new info, so it has higher priority. - for (const generator in matchingProject.generators) { - updatedProjectConfiguration.generators[generator] = { - ...matchingProject.generators[generator], - ...project.generators[generator], - }; - } - } - } - - if (project.namedInputs) { - updatedProjectConfiguration.namedInputs = { - ...matchingProject.namedInputs, - ...project.namedInputs, - }; - - if (sourceMap) { - sourceMap['namedInputs'] ??= sourceInformation; - for (const namedInput in project.namedInputs) { - sourceMap[`namedInputs.${namedInput}`] = sourceInformation; - } - } - } - - if (project.metadata) { - updatedProjectConfiguration.metadata = mergeMetadata( - sourceMap, - sourceInformation, - 'metadata', - project.metadata, - matchingProject.metadata - ); - } - - if (project.targets) { - // We merge the targets with special handling, so clear this back to the - // targets as defined originally before merging. - updatedProjectConfiguration.targets = matchingProject?.targets ?? {}; - if (sourceMap) { - sourceMap['targets'] ??= sourceInformation; - } - - // For each target defined in the new config - for (const targetName in project.targets) { - // Always set source map info for the target, but don't overwrite info already there - // if augmenting an existing target. - - const target = project.targets?.[targetName]; - - if (sourceMap) { - sourceMap[targetSourceMapKey(targetName)] = sourceInformation; - } - - const normalizedTarget = skipTargetNormalization - ? target - : resolveCommandSyntacticSugar(target, project.root); - - let matchingTargets = []; - if (isGlobPattern(targetName)) { - // find all targets matching the glob pattern - // this will map atomized targets to the glob pattern same as it does for targetDefaults - matchingTargets = Object.keys( - updatedProjectConfiguration.targets - ).filter((key) => minimatch(key, targetName)); - } - // If no matching targets were found, we can assume that the target name is not (meant to be) a glob pattern - if (!matchingTargets.length) { - matchingTargets = [targetName]; - } - - for (const matchingTargetName of matchingTargets) { - const mergedTarget = mergeTargetConfigurations( - normalizedTarget, - matchingProject.targets?.[matchingTargetName], - sourceMap, - sourceInformation, - `targets.${matchingTargetName}` - ); - - updatedProjectConfiguration.targets[matchingTargetName] = mergedTarget; - } - } - } - - projectRootMap[updatedProjectConfiguration.root] = - updatedProjectConfiguration; - - const previousName = - matchingProject?.name && - project.name && - matchingProject.name !== project.name - ? matchingProject.name - : undefined; - return { previousName }; -} - -export function mergeMetadata( - sourceMap: Record, - sourceInformation: [file: string, plugin: string], - baseSourceMapPath: string, - metadata: T, - matchingMetadata?: T -): T { - const result: T = { - ...(matchingMetadata ?? ({} as T)), - }; - for (const [metadataKey, value] of Object.entries(metadata)) { - const existingValue = matchingMetadata?.[metadataKey]; - - if (Array.isArray(value) && Array.isArray(existingValue)) { - const startIndex = result[metadataKey].length; - result[metadataKey].push(...value); - if (sourceMap) { - recordSourceMapKeysByIndex( - sourceMap, - `${baseSourceMapPath}.${metadataKey}`, - result[metadataKey], - sourceInformation, - startIndex - ); - } - } else if (Array.isArray(value) && existingValue === undefined) { - result[metadataKey] ??= value; - if (sourceMap) { - sourceMap[`${baseSourceMapPath}.${metadataKey}`] = sourceInformation; - recordSourceMapKeysByIndex( - sourceMap, - `${baseSourceMapPath}.${metadataKey}`, - value, - sourceInformation - ); - } - } else if (typeof value === 'object' && typeof existingValue === 'object') { - for (const key in value) { - const existingValue = matchingMetadata?.[metadataKey]?.[key]; - - if (Array.isArray(value[key]) && Array.isArray(existingValue)) { - const startIndex = result[metadataKey][key].length; - result[metadataKey][key].push(...value[key]); - if (sourceMap) { - recordSourceMapKeysByIndex( - sourceMap, - `${baseSourceMapPath}.${metadataKey}.${key}`, - result[metadataKey][key], - sourceInformation, - startIndex - ); - } - } else { - result[metadataKey][key] = value[key]; - if (sourceMap) { - sourceMap[`${baseSourceMapPath}.${metadataKey}`] = - sourceInformation; - } - } - } - } else { - result[metadataKey] = value; - if (sourceMap) { - sourceMap[`${baseSourceMapPath}.${metadataKey}`] = sourceInformation; - - if (typeof value === 'object') { - for (const k in value) { - sourceMap[`${baseSourceMapPath}.${metadataKey}.${k}`] = - sourceInformation; - if (Array.isArray(value[k])) { - recordSourceMapKeysByIndex( - sourceMap, - `${baseSourceMapPath}.${metadataKey}.${k}`, - value[k], - sourceInformation - ); - } - } - } - } - } - } - return result; -} +export { + mergeTargetConfigurations, + readTargetDefaultsForTarget, +} from './project-configuration/target-merging'; export type ConfigurationResult = { /** @@ -513,7 +214,7 @@ export async function createProjectConfigurationsWithPlugins( }); } -function mergeCreateNodesResults( +export function mergeCreateNodesResults( results: (readonly [ plugin: string, file: string, @@ -531,44 +232,72 @@ function mergeCreateNodesResults( )[] ) { performance.mark('createNodes:merge - start'); - const projectRootMap: Record = {}; + const nodesManager = new ProjectNodesManager(); const externalNodes: Record = {}; - const projectNameManager = new ProjectNameInNodePropsManager(); const configurationSourceMaps: Record< string, Record > = {}; - for (const result of results.flat()) { - const [pluginName, file, nodes, pluginIndex] = result; + // Process each plugin's results in two phases: + // Phase 1: Merge all projects from this plugin into rootMap/nameMap + // Phase 2: Register substitutors for this plugin's results + // + // Per-plugin batching ensures that: + // - All same-plugin projects are in the nameMap before substitutor + // registration (fixes cross-file references like kafka-stream) + // - Later-plugin renames haven't occurred yet, so dependsOn strings + // that reference old names can still be resolved via the nameMap + for (const pluginResults of results) { + // Phase 1: Merge all projects from this plugin batch + for (const result of pluginResults) { + const [pluginName, file, nodes, pluginIndex] = result; + + const { projects: projectNodes, externalNodes: pluginExternalNodes } = + nodes; + + const sourceInfo: SourceInformation = [file, pluginName]; + + for (const root in projectNodes) { + // Handles `{projects: {'libs/foo': undefined}}`. + if (!projectNodes[root]) { + continue; + } + const project = { + root: root, + ...projectNodes[root], + }; - const { projects: projectNodes, externalNodes: pluginExternalNodes } = - nodes; + try { + nodesManager.mergeProjectNode( + project, + configurationSourceMaps, + sourceInfo + ); + } catch (error) { + errors.push( + new MergeNodesError({ + file, + pluginName, + error, + pluginIndex, + }) + ); + } + } - const sourceInfo: SourceInformation = [file, pluginName]; + Object.assign(externalNodes, pluginExternalNodes); + } - for (const root in projectNodes) { - // Handles `{projects: {'libs/foo': undefined}}`. - if (!projectNodes[root]) { - continue; - } - const project = { - root: root, - ...projectNodes[root], - }; + // Phase 2: Register substitutors for this plugin batch. The nameMap + // now contains all projects from this plugin (and all prior plugins) + // so splitTargetFromConfigurations can resolve colon-delimited strings. + for (const result of pluginResults) { + const [pluginName, file, nodes, pluginIndex] = result; + const { projects: projectNodes } = nodes; try { - const { previousName } = mergeProjectConfigurationIntoRootMap( - projectRootMap, - project, - configurationSourceMaps, - sourceInfo - ); - // If this project's name changed, record the rename so substitutors - // registered for the old name can fire during applySubstitutions. - if (previousName) { - projectNameManager.markDirty(root, previousName); - } + nodesManager.registerSubstitutors(projectNodes); } catch (error) { errors.push( new MergeNodesError({ @@ -580,27 +309,12 @@ function mergeCreateNodesResults( ); } } - // Register substitutors for any project-name references in this result's - // targets. Substitutors are keyed by the referenced name (not by root), - // so registration requires no lookup and is safe regardless of whether - // the referenced project has been processed yet. - try { - projectNameManager.registerSubstitutorsForNodeResults(projectNodes); - } catch (error) { - errors.push( - new MergeNodesError({ - file, - pluginName, - error, - pluginIndex, - }) - ); - } - Object.assign(externalNodes, pluginExternalNodes); } + const projectRootMap = nodesManager.getRootMap(); + try { - projectNameManager.applySubstitutions(projectRootMap); + nodesManager.applySubstitutions(); validateAndNormalizeProjectRootMap( workspaceRoot, projectRootMap, @@ -608,7 +322,6 @@ function mergeCreateNodesResults( configurationSourceMaps ); } catch (error) { - const unknownErrors: Error[] = []; let _errors = error instanceof AggregateError ? error.errors : [error]; for (const e of _errors) { if ( @@ -713,695 +426,3 @@ export function findMatchingConfigFiles( return matchingConfigFiles; } - -export function readProjectConfigurationsFromRootMap( - projectRootMap: Record -) { - const projects: Record = {}; - // If there are projects that have the same name, that is an error. - // This object tracks name -> (all roots of projects with that name) - // to provide better error messaging. - const conflicts = new Map(); - const projectRootsWithNoName: string[] = []; - - for (const root in projectRootMap) { - const project = projectRootMap[root]; - // We're setting `// targets` as a comment `targets` is empty due to Project Crystal. - // Strip it before returning configuration for usage. - if (project['// targets']) delete project['// targets']; - - try { - validateProject(project, projects); - projects[project.name] = project; - } catch (e) { - if (isProjectWithNoNameError(e)) { - projectRootsWithNoName.push(e.projectRoot); - } else if (isProjectWithExistingNameError(e)) { - const rootErrors = conflicts.get(e.projectName) ?? [ - projects[e.projectName].root, - ]; - rootErrors.push(e.projectRoot); - conflicts.set(e.projectName, rootErrors); - } else { - throw e; - } - } - } - - if (conflicts.size > 0) { - throw new MultipleProjectsWithSameNameError(conflicts, projects); - } - if (projectRootsWithNoName.length > 0) { - throw new ProjectsWithNoNameError(projectRootsWithNoName, projects); - } - return projects; -} - -function validateAndNormalizeProjectRootMap( - workspaceRoot: string, - projectRootMap: Record, - nxJsonConfiguration: NxJsonConfiguration, - sourceMaps: ConfigurationSourceMaps = {} -) { - // Name -> Project, used to validate that all projects have unique names - const projects: Record = {}; - // If there are projects that have the same name, that is an error. - // This object tracks name -> (all roots of projects with that name) - // to provide better error messaging. - const conflicts = new Map(); - const projectRootsWithNoName: string[] = []; - const validityErrors: WorkspaceValidityError[] = []; - - for (const root in projectRootMap) { - const project = projectRootMap[root]; - // We're setting `// targets` as a comment `targets` is empty due to Project Crystal. - // Strip it before returning configuration for usage. - if (project['// targets']) delete project['// targets']; - - // We initially did this in the project.json plugin, but - // that resulted in project.json files without names causing - // the resulting project to change names from earlier plugins... - if ( - !project.name && - existsSync(join(workspaceRoot, project.root, 'project.json')) - ) { - project.name = toProjectName(join(root, 'project.json')); - } - - try { - validateProject(project, projects); - projects[project.name] = project; - } catch (e) { - if (isProjectWithNoNameError(e)) { - projectRootsWithNoName.push(e.projectRoot); - } else if (isProjectWithExistingNameError(e)) { - const rootErrors = conflicts.get(e.projectName) ?? [ - projects[e.projectName].root, - ]; - rootErrors.push(e.projectRoot); - conflicts.set(e.projectName, rootErrors); - } else { - throw e; - } - } - } - - for (const root in projectRootMap) { - const project = projectRootMap[root]; - try { - normalizeTargets( - project, - sourceMaps, - nxJsonConfiguration, - workspaceRoot, - projects - ); - } catch (e) { - if (e instanceof WorkspaceValidityError) { - validityErrors.push(e); - } else { - throw e; - } - } - } - - const errors: Error[] = []; - - if (conflicts.size > 0) { - errors.push(new MultipleProjectsWithSameNameError(conflicts, projects)); - } - if (projectRootsWithNoName.length > 0) { - errors.push(new ProjectsWithNoNameError(projectRootsWithNoName, projects)); - } - if (validityErrors.length > 0) { - errors.push(...validityErrors); - } - if (errors.length > 0) { - throw new AggregateError(errors); - } - return projectRootMap; -} - -function normalizeTargets( - project: ProjectConfiguration, - sourceMaps: ConfigurationSourceMaps, - nxJsonConfiguration: NxJsonConfiguration, - workspaceRoot: string, - /** - * Project configurations keyed by project name - */ - projects: Record -) { - const targetErrorMessage: string[] = []; - - for (const targetName in project.targets) { - project.targets[targetName] = normalizeTarget( - project.targets[targetName], - project, - workspaceRoot, - projects, - [project.root, targetName].join(':') - ); - - const projectSourceMaps = sourceMaps[project.root]; - - const targetConfig = project.targets[targetName]; - const targetDefaults = deepClone( - readTargetDefaultsForTarget( - targetName, - nxJsonConfiguration.targetDefaults, - targetConfig.executor - ) - ); - - // We only apply defaults if they exist - if (targetDefaults && isCompatibleTarget(targetConfig, targetDefaults)) { - project.targets[targetName] = mergeTargetDefaultWithTargetDefinition( - targetName, - project, - normalizeTarget( - targetDefaults, - project, - workspaceRoot, - projects, - ['nx.json[targetDefaults]', targetName].join(':') - ), - projectSourceMaps - ); - } - - const target = project.targets[targetName]; - - if ( - // If the target has no executor or command, it doesn't do anything - !target.executor && - !target.command - ) { - // But it may have dependencies that do something - if (target.dependsOn && target.dependsOn.length > 0) { - target.executor = 'nx:noop'; - } else { - // If it does nothing, and has no depenencies, - // we can remove it. - delete project.targets[targetName]; - } - } - - if (target.cache && target.continuous) { - targetErrorMessage.push( - `- "${targetName}" has both "cache" and "continuous" set to true. Continuous targets cannot be cached. Please remove the "cache" property.` - ); - } - } - if (targetErrorMessage.length > 0) { - targetErrorMessage.unshift( - `Errors detected in targets of project "${project.name}":` - ); - throw new WorkspaceValidityError(targetErrorMessage.join('\n')); - } -} - -export function validateProject( - project: ProjectConfiguration, - // name -> project - knownProjects: Record -) { - if (!project.name) { - try { - const { name } = readJsonFile(join(project.root, 'package.json')); - if (!name) { - throw new Error(`Project at ${project.root} has no name provided.`); - } - project.name = name; - } catch { - throw new ProjectWithNoNameError(project.root); - } - } else if ( - knownProjects[project.name] && - knownProjects[project.name].root !== project.root - ) { - throw new ProjectWithExistingNameError(project.name, project.root); - } -} - -function targetDefaultShouldBeApplied( - key: string, - sourceMap: Record -) { - const sourceInfo = sourceMap[key]; - if (!sourceInfo) { - return true; - } - // The defined value of the target is from a plugin that - // isn't part of Nx's core plugins, so target defaults are - // applied on top of it. - const [, plugin] = sourceInfo; - return !plugin?.startsWith('nx/'); -} - -function deepClone(obj) { - return structuredClone(obj); -} - -export function mergeTargetDefaultWithTargetDefinition( - targetName: string, - project: ProjectConfiguration, - targetDefault: Partial, - sourceMap: Record -): TargetConfiguration { - const targetDefinition = project.targets[targetName] ?? {}; - const result = deepClone(targetDefinition); - - for (const key in targetDefault) { - switch (key) { - case 'options': { - const normalizedDefaults = resolveNxTokensInOptions( - targetDefault.options, - project, - targetName - ); - for (const optionKey in normalizedDefaults) { - const sourceMapKey = targetOptionSourceMapKey(targetName, optionKey); - if ( - targetDefinition.options[optionKey] === undefined || - targetDefaultShouldBeApplied(sourceMapKey, sourceMap) - ) { - result.options[optionKey] = targetDefault.options[optionKey]; - sourceMap[sourceMapKey] = ['nx.json', 'nx/target-defaults']; - } - } - break; - } - case 'configurations': { - if (!result.configurations) { - result.configurations = {}; - sourceMap[targetConfigurationsSourceMapKey(targetName)] = [ - 'nx.json', - 'nx/target-defaults', - ]; - } - for (const configuration in targetDefault.configurations) { - if (!result.configurations[configuration]) { - result.configurations[configuration] = {}; - sourceMap[ - targetConfigurationsSourceMapKey(targetName, configuration) - ] = ['nx.json', 'nx/target-defaults']; - } - const normalizedConfigurationDefaults = resolveNxTokensInOptions( - targetDefault.configurations[configuration], - project, - targetName - ); - for (const configurationKey in normalizedConfigurationDefaults) { - const sourceMapKey = targetConfigurationsSourceMapKey( - targetName, - configuration, - configurationKey - ); - if ( - targetDefinition.configurations?.[configuration]?.[ - configurationKey - ] === undefined || - targetDefaultShouldBeApplied(sourceMapKey, sourceMap) - ) { - result.configurations[configuration][configurationKey] = - targetDefault.configurations[configuration][configurationKey]; - sourceMap[sourceMapKey] = ['nx.json', 'nx/target-defaults']; - } - } - } - break; - } - default: { - const sourceMapKey = `targets.${targetName}.${key}`; - if ( - targetDefinition[key] === undefined || - targetDefaultShouldBeApplied(sourceMapKey, sourceMap) - ) { - result[key] = targetDefault[key]; - sourceMap[sourceMapKey] = ['nx.json', 'nx/target-defaults']; - } - break; - } - } - } - return result; -} - -/** - * Merges two targets. - * - * Most properties from `target` will overwrite any properties from `baseTarget`. - * Options and configurations are treated differently - they are merged together if the executor definition is compatible. - * - * @param target The target definition with higher priority - * @param baseTarget The target definition that should be overwritten. Can be undefined, in which case the target is returned as-is. - * @param projectConfigSourceMap The source map to be filled with metadata about where each property came from - * @param sourceInformation The metadata about where the new target was defined - * @param targetIdentifier The identifier for the target to merge, used for source map - * @returns A merged target configuration - */ -export function mergeTargetConfigurations( - target: TargetConfiguration, - baseTarget?: TargetConfiguration, - projectConfigSourceMap?: Record, - sourceInformation?: SourceInformation, - targetIdentifier?: string -): TargetConfiguration { - const { - configurations: defaultConfigurations, - options: defaultOptions, - ...baseTargetProperties - } = baseTarget ?? {}; - - // Target is "compatible", e.g. executor is defined only once or is the same - // in both places. This means that it is likely safe to merge - const isCompatible = isCompatibleTarget(baseTarget ?? {}, target); - - if (!isCompatible && projectConfigSourceMap) { - // if the target is not compatible, we will simply override the options - // we have to delete old entries from the source map - for (const key in projectConfigSourceMap) { - if (key.startsWith(`${targetIdentifier}`)) { - delete projectConfigSourceMap[key]; - } - } - } - - // merge top level properties if they're compatible - const result = { - ...(isCompatible ? baseTargetProperties : {}), - ...target, - }; - - // record top level properties in source map - if (projectConfigSourceMap) { - projectConfigSourceMap[targetIdentifier] = sourceInformation; - - // record root level target properties to source map - for (const targetProperty in target) { - const targetPropertyId = `${targetIdentifier}.${targetProperty}`; - projectConfigSourceMap[targetPropertyId] = sourceInformation; - } - } - - // merge options if there are any - // if the targets aren't compatible, we simply discard the old options during the merge - if (target.options || defaultOptions) { - result.options = mergeOptions( - target.options, - isCompatible ? defaultOptions : undefined, - projectConfigSourceMap, - sourceInformation, - targetIdentifier - ); - } - - // merge configurations if there are any - // if the targets aren't compatible, we simply discard the old configurations during the merge - if (target.configurations || defaultConfigurations) { - result.configurations = mergeConfigurations( - target.configurations, - isCompatible ? defaultConfigurations : undefined, - projectConfigSourceMap, - sourceInformation, - targetIdentifier - ); - } - - if (target.metadata) { - result.metadata = mergeMetadata( - projectConfigSourceMap, - sourceInformation, - `${targetIdentifier}.metadata`, - target.metadata, - baseTarget?.metadata - ); - } - - return result as TargetConfiguration; -} - -/** - * Checks if targets options are compatible - used when merging configurations - * to avoid merging options for @nx/js:tsc into something like @nx/webpack:webpack. - * - * If the executors are both specified and don't match, the options aren't considered - * "compatible" and shouldn't be merged. - */ -export function isCompatibleTarget( - a: TargetConfiguration, - b: TargetConfiguration -) { - const oneHasNoExecutor = !a.executor || !b.executor; - const bothHaveSameExecutor = a.executor === b.executor; - - if (oneHasNoExecutor) return true; - if (!bothHaveSameExecutor) return false; - - const isRunCommands = a.executor === 'nx:run-commands'; - if (isRunCommands) { - const aCommand = a.options?.command ?? a.options?.commands?.join(' && '); - const bCommand = b.options?.command ?? b.options?.commands?.join(' && '); - - const oneHasNoCommand = !aCommand || !bCommand; - const hasSameCommand = aCommand === bCommand; - - return oneHasNoCommand || hasSameCommand; - } - - const isRunScript = a.executor === 'nx:run-script'; - if (isRunScript) { - const aScript = a.options?.script; - const bScript = b.options?.script; - - const oneHasNoScript = !aScript || !bScript; - const hasSameScript = aScript === bScript; - - return oneHasNoScript || hasSameScript; - } - - return true; -} - -function mergeConfigurations( - newConfigurations: Record | undefined, - baseConfigurations: Record | undefined, - projectConfigSourceMap?: Record, - sourceInformation?: SourceInformation, - targetIdentifier?: string -): Record | undefined { - const mergedConfigurations = {}; - - const configurations = new Set([ - ...Object.keys(baseConfigurations ?? {}), - ...Object.keys(newConfigurations ?? {}), - ]); - for (const configuration of configurations) { - mergedConfigurations[configuration] = { - ...(baseConfigurations?.[configuration] ?? {}), - ...(newConfigurations?.[configuration] ?? {}), - }; - } - - // record new configurations & configuration properties in source map - if (projectConfigSourceMap) { - for (const newConfiguration in newConfigurations) { - projectConfigSourceMap[ - `${targetIdentifier}.configurations.${newConfiguration}` - ] = sourceInformation; - for (const configurationProperty in newConfigurations[newConfiguration]) { - projectConfigSourceMap[ - `${targetIdentifier}.configurations.${newConfiguration}.${configurationProperty}` - ] = sourceInformation; - } - } - } - - return mergedConfigurations; -} - -function mergeOptions( - newOptions: Record | undefined, - baseOptions: Record | undefined, - projectConfigSourceMap?: Record, - sourceInformation?: SourceInformation, - targetIdentifier?: string -): Record | undefined { - const mergedOptions = { - ...(baseOptions ?? {}), - ...(newOptions ?? {}), - }; - - // record new options & option properties in source map - if (projectConfigSourceMap) { - for (const newOption in newOptions) { - projectConfigSourceMap[`${targetIdentifier}.options.${newOption}`] = - sourceInformation; - } - } - - return mergedOptions; -} - -export function resolveNxTokensInOptions>( - object: T, - project: ProjectConfiguration, - key: string -): T { - const result: T = Array.isArray(object) ? ([...object] as T) : { ...object }; - for (let [opt, value] of Object.entries(object ?? {})) { - if (typeof value === 'string') { - const workspaceRootMatch = /^(\{workspaceRoot\}\/?)/.exec(value); - if (workspaceRootMatch?.length) { - value = value.replace(workspaceRootMatch[0], ''); - } - if (value.includes('{workspaceRoot}')) { - throw new Error( - `${NX_PREFIX} The {workspaceRoot} token is only valid at the beginning of an option. (${key})` - ); - } - value = value.replace(/\{projectRoot\}/g, project.root); - result[opt] = value.replace(/\{projectName\}/g, project.name); - } else if (typeof value === 'object' && value) { - result[opt] = resolveNxTokensInOptions( - value, - project, - [key, opt].join('.') - ); - } - } - return result; -} - -export function readTargetDefaultsForTarget( - targetName: string, - targetDefaults: TargetDefaults, - executor?: string -): TargetDefaults[string] { - if (executor && targetDefaults?.[executor]) { - // If an executor is defined in project.json, defaults should be read - // from the most specific key that matches that executor. - // e.g. If executor === run-commands, and the target is named build: - // Use, use nx:run-commands if it is present - // If not, use build if it is present. - return targetDefaults?.[executor]; - } else if (targetDefaults?.[targetName]) { - // If the executor is not defined, the only key we have is the target name. - return targetDefaults?.[targetName]; - } - - let matchingTargetDefaultKey: string | null = null; - for (const key in targetDefaults ?? {}) { - if (isGlobPattern(key) && minimatch(targetName, key)) { - if ( - !matchingTargetDefaultKey || - matchingTargetDefaultKey.length < key.length - ) { - matchingTargetDefaultKey = key; - } - } - } - if (matchingTargetDefaultKey) { - return targetDefaults[matchingTargetDefaultKey]; - } - - return null; -} - -function createRootMap(projectRootMap: Record) { - const map: Record = {}; - for (const projectRoot in projectRootMap) { - const projectName = projectRootMap[projectRoot].name; - map[projectRoot] = projectName; - } - return map; -} - -function resolveCommandSyntacticSugar( - target: TargetConfiguration, - key: string -): TargetConfiguration { - const { command, ...config } = target ?? {}; - - if (!command) { - return target; - } - - if (config.executor) { - throw new Error( - `${NX_PREFIX} Project at ${key} should not have executor and command both configured.` - ); - } else { - return { - ...config, - executor: 'nx:run-commands', - options: { - ...config.options, - command: command, - }, - }; - } -} - -/** - * Expand's `command` syntactic sugar, replaces tokens in options, and adds information from executor schema. - * @param target The target to normalize - * @param project The project that the target belongs to - * @returns The normalized target configuration - */ -export function normalizeTarget( - target: TargetConfiguration, - project: ProjectConfiguration, - workspaceRoot: string, - projectsMap: Record, - errorMsgKey: string -) { - target = { - ...target, - configurations: { - ...target.configurations, - }, - }; - - target = resolveCommandSyntacticSugar(target, project.root); - - target.options = resolveNxTokensInOptions( - target.options, - project, - errorMsgKey - ); - - for (const configuration in target.configurations) { - target.configurations[configuration] = resolveNxTokensInOptions( - target.configurations[configuration], - project, - `${project.root}:${target}:${configuration}` - ); - } - - target.parallelism ??= true; - - if (target.executor && !('continuous' in target)) { - try { - const [executorNodeModule, executorName] = parseExecutor(target.executor); - - const { schema } = getExecutorInformation( - executorNodeModule, - executorName, - workspaceRoot, - projectsMap - ); - - if (schema.continuous) { - target.continuous ??= schema.continuous; - } - } catch (e) { - // If the executor is not found, we assume that it is not a valid executor. - // This means that we should not set the continuous property. - // We could throw an error here, but it would be better to just ignore it. - } - } - - return target; -} diff --git a/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.spec.ts b/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.spec.ts index 9ea06dc8c26..ff4b34644d9 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.spec.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.spec.ts @@ -4,6 +4,22 @@ import { ProjectNameInNodePropsManager } from './name-substitution-manager'; describe('ProjectNameInNodePropsManager', () => { // ============== Helper Functions ============== + // Shared nameMap populated by identifyProjects, passed to the manager + // via a lazy accessor — mirrors how ProjectNodesManager works in + // production. + let nameMap: Record; + + beforeEach(() => { + nameMap = {}; + }); + + /** + * Creates a manager wired to the shared nameMap. + */ + function createManager(): ProjectNameInNodePropsManager { + return new ProjectNameInNodePropsManager(() => nameMap); + } + /** * Helper to create a mock project configuration with common defaults. * Uses 'any' to allow flexible test data without strict typing. @@ -46,6 +62,70 @@ describe('ProjectNameInNodePropsManager', () => { return map; } + /** + * Helper to simulate the merge phase: calls identifyProjectWithRoot for + * every project in the plugin result and populates the shared nameMap — + * matching the real ProjectNodesManager integration flow. + */ + function identifyProjects( + manager: ProjectNameInNodePropsManager, + ...pluginResults: Array> + ) { + for (const result of pluginResults) { + for (const root in result) { + const project = result[root]; + if (project.name) { + // Simulate what ProjectNodesManager does: track previous name, + // update nameMap, notify manager on name change. + const previousName = Object.keys(nameMap).find( + (n) => nameMap[n]?.root === root + ); + if (previousName && previousName !== project.name) { + delete nameMap[previousName]; + } + // Store in nameMap — in real code this is the same object as + // rootMap[root], but for tests we create a minimal config. + nameMap[project.name] = { + root, + name: project.name, + targets: project.targets, + } as ProjectConfiguration; + // Notify manager when name changes — matching + // ProjectNodesManager.mergeProjectNode behavior. This includes + // first identification (previousName undefined) for forward-ref + // promotion, and renames (previousName differs). + if (project.name !== previousName) { + manager.identifyProjectWithRoot(root, project.name); + } + } + } + } + } + + /** + * Helper to simulate a rename: updates the nameMap and notifies the + * manager. Call this instead of manager.identifyProjectWithRoot directly + * when simulating a later plugin renaming a project. + */ + function renameProject( + manager: ProjectNameInNodePropsManager, + root: string, + newName: string + ) { + // Find and remove old name + const oldName = Object.keys(nameMap).find((n) => nameMap[n]?.root === root); + const oldTargets = oldName ? nameMap[oldName]?.targets : undefined; + if (oldName) { + delete nameMap[oldName]; + } + nameMap[newName] = { + root, + name: newName, + targets: oldTargets, + } as ProjectConfiguration; + manager.identifyProjectWithRoot(root, newName); + } + /** * Helper to build a target with inputs that reference projects. * Note: The InputDefinition type requires both 'input' and 'projects' properties. @@ -73,19 +153,19 @@ describe('ProjectNameInNodePropsManager', () => { describe('basic functionality', () => { it('should create an instance without errors', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); expect(manager).toBeDefined(); }); it('should handle empty plugin results without errors', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); manager.registerSubstitutorsForNodeResults({}); manager.applySubstitutions({}); // No error should be thrown }); it('should handle undefined plugin results', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); manager.registerSubstitutorsForNodeResults(undefined); // No error should be thrown }); @@ -95,7 +175,7 @@ describe('ProjectNameInNodePropsManager', () => { describe('inputs with projects reference', () => { it('should substitute a single project name in inputs.projects (string)', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); // Project A references project B by name in its inputs const projectA = createProject('project-a', 'libs/a', { @@ -108,10 +188,11 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectB]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); // Simulate project B's name being changed - manager.markDirty('libs/b', 'project-b'); + renameProject(manager, 'libs/b', 'project-b-renamed'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -127,7 +208,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should substitute multiple project names in inputs.projects (array)', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); // Project A references projects B and C by name in its inputs const projectA = createProject('project-a', 'libs/a', { @@ -145,11 +226,12 @@ describe('ProjectNameInNodePropsManager', () => { projectC, ]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); // Simulate both projects being renamed - manager.markDirty('libs/b', 'project-b'); - manager.markDirty('libs/c', 'project-c'); + renameProject(manager, 'libs/b', 'new-b'); + renameProject(manager, 'libs/c', 'new-c'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -167,7 +249,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should not reintroduce removed entries when inputs.projects array shrinks', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectAInitial = createProject('project-a', 'libs/a', { targets: { @@ -175,9 +257,9 @@ describe('ProjectNameInNodePropsManager', () => { }, }); - manager.registerSubstitutorsForNodeResults( - createPluginResult([projectAInitial]) - ); + const initialResult = createPluginResult([projectAInitial]); + identifyProjects(manager, initialResult); + manager.registerSubstitutorsForNodeResults(initialResult); const projectAUpdated = createProject('project-a', 'libs/a', { targets: { @@ -185,10 +267,10 @@ describe('ProjectNameInNodePropsManager', () => { }, }); - manager.registerSubstitutorsForNodeResults( - createPluginResult([projectAUpdated]) - ); - manager.markDirty('libs/c', 'project-c'); + const updatedResult = createPluginResult([projectAUpdated]); + identifyProjects(manager, updatedResult); + manager.registerSubstitutorsForNodeResults(updatedResult); + renameProject(manager, 'libs/c', 'renamed-c'); const rootMap = createRootMap([ { @@ -207,7 +289,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should not substitute "self" in inputs.projects', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -217,10 +299,11 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); // Even if we mark this dirty, 'self' should not be substituted - manager.markDirty('libs/a', 'project-a'); + renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ { name: 'renamed-a', root: 'libs/a', targets: projectA.targets }, @@ -233,7 +316,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should not substitute "dependencies" in inputs.projects', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -243,8 +326,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/a', 'project-a'); + renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ { name: 'renamed-a', root: 'libs/a', targets: projectA.targets }, @@ -261,7 +345,7 @@ describe('ProjectNameInNodePropsManager', () => { describe('dependsOn with projects reference', () => { it('should substitute a single project name in dependsOn.projects (string)', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -273,8 +357,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectB]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/b', 'project-b'); + renameProject(manager, 'libs/b', 'project-b-renamed'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -289,7 +374,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should substitute multiple project names in dependsOn.projects (array)', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -306,9 +391,10 @@ describe('ProjectNameInNodePropsManager', () => { projectC, ]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/b', 'project-b'); - manager.markDirty('libs/c', 'project-c'); + renameProject(manager, 'libs/b', 'new-b'); + renameProject(manager, 'libs/c', 'new-c'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -325,7 +411,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should not substitute "*" in dependsOn.projects', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -335,8 +421,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/a', 'project-a'); + renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ { name: 'renamed-a', root: 'libs/a', targets: projectA.targets }, @@ -348,7 +435,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should not substitute "self" in dependsOn.projects', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -358,8 +445,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/a', 'project-a'); + renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ { name: 'renamed-a', root: 'libs/a', targets: projectA.targets }, @@ -371,7 +459,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should not substitute "dependencies" in dependsOn.projects', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -381,8 +469,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/a', 'project-a'); + renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ { name: 'renamed-a', root: 'libs/a', targets: projectA.targets }, @@ -394,7 +483,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should not substitute glob patterns in dependsOn.projects array', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -406,8 +495,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectB]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/b', 'project-b'); + renameProject(manager, 'libs/b', 'new-b'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -425,7 +515,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should not reintroduce removed entries when dependsOn.projects array shrinks', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectAInitial = createProject('project-a', 'libs/a', { targets: { @@ -433,9 +523,9 @@ describe('ProjectNameInNodePropsManager', () => { }, }); - manager.registerSubstitutorsForNodeResults( - createPluginResult([projectAInitial]) - ); + const initialResult = createPluginResult([projectAInitial]); + identifyProjects(manager, initialResult); + manager.registerSubstitutorsForNodeResults(initialResult); const projectAUpdated = createProject('project-a', 'libs/a', { targets: { @@ -443,10 +533,10 @@ describe('ProjectNameInNodePropsManager', () => { }, }); - manager.registerSubstitutorsForNodeResults( - createPluginResult([projectAUpdated]) - ); - manager.markDirty('libs/c', 'project-c'); + const updatedResult = createPluginResult([projectAUpdated]); + identifyProjects(manager, updatedResult); + manager.registerSubstitutorsForNodeResults(updatedResult); + renameProject(manager, 'libs/c', 'renamed-c'); const rootMap = createRootMap([ { @@ -465,18 +555,19 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should substitute references for targets expanded from glob keys', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { 'build-*': createTargetWithDependsOn('project-b'), }, }); + const projectB = createProject('project-b', 'libs/b'); - manager.registerSubstitutorsForNodeResults( - createPluginResult([projectA]) - ); - manager.markDirty('libs/b', 'project-b'); + const globResult = createPluginResult([projectA, projectB]); + identifyProjects(manager, globResult); + manager.registerSubstitutorsForNodeResults(globResult); + renameProject(manager, 'libs/b', 'renamed-b'); const expandedTargets = { 'build-prod': createTargetWithDependsOn('project-b'), @@ -498,7 +589,7 @@ describe('ProjectNameInNodePropsManager', () => { describe('dependsOn with string-form target strings', () => { it('should substitute a project name in a "project:target" string entry', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -512,8 +603,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectB]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/b', 'project-b'); + renameProject(manager, 'libs/b', 'project-b-renamed'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -528,7 +620,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should not substitute "^target" dependency-mode strings', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -540,8 +632,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/a', 'project-a'); + renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ { name: 'renamed-a', root: 'libs/a', targets: projectA.targets }, @@ -553,7 +646,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should not substitute bare target name strings (no colon)', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -565,8 +658,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/a', 'project-a'); + renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ { name: 'renamed-a', root: 'libs/a', targets: projectA.targets }, @@ -578,7 +672,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle mixed string and object dependsOn entries', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -601,9 +695,10 @@ describe('ProjectNameInNodePropsManager', () => { projectC, ]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/b', 'project-b'); - manager.markDirty('libs/c', 'project-c'); + renameProject(manager, 'libs/b', 'new-b'); + renameProject(manager, 'libs/c', 'new-c'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -619,7 +714,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle "project:target" string from a separate plugin result', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projA = createProject('project-a', 'proj-a', { targets: { @@ -633,9 +728,11 @@ describe('ProjectNameInNodePropsManager', () => { const projB = createProject('project-b-original', 'proj-b'); const projBResult = createPluginResult([projB]); + identifyProjects(manager, projAResult); manager.registerSubstitutorsForNodeResults(projAResult); + identifyProjects(manager, projBResult); manager.registerSubstitutorsForNodeResults(projBResult); - manager.markDirty('proj-b', 'project-b-original'); + renameProject(manager, 'proj-b', 'project-b-renamed'); const rootMap = createRootMap([ { name: 'project-a', root: 'proj-a', targets: projA.targets }, @@ -650,7 +747,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle quoted project names with colons', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); // Register the colon-bearing project so splitTargetFromNodes // can recognise it in the string-form dependsOn entry. @@ -667,10 +764,10 @@ describe('ProjectNameInNodePropsManager', () => { }, }); - manager.registerSubstitutorsForNodeResults( - createPluginResult([scopedPkg, projectA]) - ); - manager.markDirty('libs/scoped', '@scope:pkg'); + const quotedResult = createPluginResult([scopedPkg, projectA]); + identifyProjects(manager, quotedResult); + manager.registerSubstitutorsForNodeResults(quotedResult); + renameProject(manager, 'libs/scoped', 'new-pkg'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -685,7 +782,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle project names with colons when the final name also has colons', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectB = createProject('project-b', 'libs/b', { targets: { compile: {} }, @@ -699,10 +796,10 @@ describe('ProjectNameInNodePropsManager', () => { }, }); - manager.registerSubstitutorsForNodeResults( - createPluginResult([projectA, projectB]) - ); - manager.markDirty('libs/b', 'project-b'); + const colonResult = createPluginResult([projectA, projectB]); + identifyProjects(manager, colonResult); + manager.registerSubstitutorsForNodeResults(colonResult); + renameProject(manager, 'libs/b', '@scope:new-b'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -718,7 +815,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should disambiguate colon strings using known project nodes', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); // Register a project whose name contains colons so // splitTargetFromNodes can match it against "a:b:c". @@ -734,10 +831,10 @@ describe('ProjectNameInNodePropsManager', () => { }, }); - manager.registerSubstitutorsForNodeResults( - createPluginResult([projectAB, projectOwner]) - ); - manager.markDirty('libs/ab', 'a:b'); + const result = createPluginResult([projectAB, projectOwner]); + identifyProjects(manager, result); + manager.registerSubstitutorsForNodeResults(result); + renameProject(manager, 'libs/ab', 'new-ab'); const rootMap = createRootMap([ { @@ -755,8 +852,42 @@ describe('ProjectNameInNodePropsManager', () => { expect(projectOwner.targets.build.dependsOn[0]).toBe('new-ab:c'); }); + it('should substitute a colon-leading project name in a "project:target" string entry', () => { + const manager = createManager(); + + // Project whose name starts with ':' — no targets of its own, + // so findMatchingSegments cannot resolve the string-form entry. + const colonPkg = createProject(':pkg', 'libs/pkg'); + + const projectA = createProject('project-a', 'libs/a', { + targets: { + build: { + dependsOn: [':pkg:compile'], + }, + }, + }); + + const colonLeadingResult = createPluginResult([colonPkg, projectA]); + identifyProjects(manager, colonLeadingResult); + manager.registerSubstitutorsForNodeResults(colonLeadingResult); + renameProject(manager, 'libs/pkg', 'renamed-pkg'); + + const rootMap = createRootMap([ + { name: 'project-a', root: 'libs/a', targets: projectA.targets }, + { name: 'renamed-pkg', root: 'libs/pkg' }, + ]); + + manager.applySubstitutions(rootMap); + + expect(projectA.targets.build.dependsOn[0]).toBe('renamed-pkg:compile'); + }); + it('should substitute for targets expanded from glob keys with string dependsOn', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); + + const projectB = createProject('project-b', 'libs/b', { + targets: { compile: {} }, + }); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -766,10 +897,10 @@ describe('ProjectNameInNodePropsManager', () => { }, }); - manager.registerSubstitutorsForNodeResults( - createPluginResult([projectA]) - ); - manager.markDirty('libs/b', 'project-b'); + const globStringResult = createPluginResult([projectB, projectA]); + identifyProjects(manager, globStringResult); + manager.registerSubstitutorsForNodeResults(globStringResult); + renameProject(manager, 'libs/b', 'renamed-b'); const expandedTargets = { 'build-prod': { @@ -802,7 +933,7 @@ describe('ProjectNameInNodePropsManager', () => { // 'project-b-renamed' when substitutors are registered, so // nameMap.get('project-b-original') returns undefined and no // substitutor is ever registered. - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projA = createProject('project-a-original', 'proj-a', { targets: { @@ -822,9 +953,11 @@ describe('ProjectNameInNodePropsManager', () => { { name: 'project-b-renamed', root: 'proj-b' }, ]); + identifyProjects(manager, projAResult); manager.registerSubstitutorsForNodeResults(projAResult); + identifyProjects(manager, projBResult); manager.registerSubstitutorsForNodeResults(projBResult); - manager.markDirty('proj-b', 'project-b-original'); + renameProject(manager, 'proj-b', 'project-b-renamed'); manager.applySubstitutions(projectRootMap); expect(projA.targets.build.dependsOn[0].projects).toBe( @@ -833,7 +966,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle registering substitutors from multiple plugin calls', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); // First plugin creates projects A and B, where A references B const projectA = createProject('project-a', 'libs/a', { @@ -845,6 +978,7 @@ describe('ProjectNameInNodePropsManager', () => { const firstPluginResult = createPluginResult([projectA, projectB]); + identifyProjects(manager, firstPluginResult); manager.registerSubstitutorsForNodeResults(firstPluginResult); // Second plugin creates projects C and D, where C references D @@ -857,11 +991,12 @@ describe('ProjectNameInNodePropsManager', () => { const secondPluginResult = createPluginResult([projectC, projectD]); + identifyProjects(manager, secondPluginResult); manager.registerSubstitutorsForNodeResults(secondPluginResult); // Mark both B and D as dirty - manager.markDirty('libs/b', 'project-b'); - manager.markDirty('libs/d', 'project-d'); + renameProject(manager, 'libs/b', 'renamed-b'); + renameProject(manager, 'libs/d', 'renamed-d'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -878,11 +1013,12 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle cross-plugin references through merged configurations', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); // First plugin creates project B const projectB = createProject('project-b', 'libs/b'); const firstPluginResult = createPluginResult([projectB]); + identifyProjects(manager, firstPluginResult); manager.registerSubstitutorsForNodeResults(firstPluginResult); // Second plugin creates project A that references B (which is in merged configs) @@ -893,10 +1029,11 @@ describe('ProjectNameInNodePropsManager', () => { }); const secondPluginResult = createPluginResult([projectA]); + identifyProjects(manager, secondPluginResult); manager.registerSubstitutorsForNodeResults(secondPluginResult); // Mark B as dirty - manager.markDirty('libs/b', 'project-b'); + renameProject(manager, 'libs/b', 'renamed-b'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -909,7 +1046,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle sequential registrations with overlapping projects', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); // Both plugins reference the same project const projectA = createProject('project-a', 'libs/a', { @@ -928,14 +1065,16 @@ describe('ProjectNameInNodePropsManager', () => { // First call const firstResult = createPluginResult([projectA, sharedLib]); + identifyProjects(manager, firstResult); manager.registerSubstitutorsForNodeResults(firstResult); // Second call const secondResult = createPluginResult([projectB]); + identifyProjects(manager, secondResult); manager.registerSubstitutorsForNodeResults(secondResult); // Rename shared-lib - manager.markDirty('libs/shared', 'shared-lib'); + renameProject(manager, 'libs/shared', 'renamed-shared'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -957,7 +1096,7 @@ describe('ProjectNameInNodePropsManager', () => { describe('complex scenarios', () => { it('should handle multiple targets with various reference types', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -987,12 +1126,13 @@ describe('ProjectNameInNodePropsManager', () => { projectD, ]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); // Rename all referenced projects - manager.markDirty('libs/b', 'project-b'); - manager.markDirty('libs/c', 'project-c'); - manager.markDirty('libs/d', 'project-d'); + renameProject(manager, 'libs/b', 'new-b'); + renameProject(manager, 'libs/c', 'new-c'); + renameProject(manager, 'libs/d', 'new-d'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -1023,32 +1163,39 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle a project being renamed multiple times in sequence', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); + // Plugin 1: creates project-b-original and project-a referencing it const projectA = createProject('project-a', 'libs/a', { targets: { build: createTargetWithProjectInput('project-b-original'), }, }); + const projectB = createProject('project-b-original', 'libs/b'); + const renameResult1 = createPluginResult([projectA, projectB]); + identifyProjects(manager, renameResult1); + manager.registerSubstitutorsForNodeResults(renameResult1); + + // Plugin 2: renames project-b to intermediate and project-c references it + const projectBIntermediate = createProject( + 'project-b-intermediate', + 'libs/b' + ); const projectC = createProject('project-c', 'libs/c', { targets: { build: createTargetWithProjectInput('project-b-intermediate'), }, }); - - const projectB = createProject('project-b-original', 'libs/b'); - - const pluginResultProjects = createPluginResult([ - projectA, - projectB, + const renameResult2 = createPluginResult([ + projectBIntermediate, projectC, ]); - - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + identifyProjects(manager, renameResult2); + manager.registerSubstitutorsForNodeResults(renameResult2); // project-b-original -> project-b-intermediate -> project-b-final - manager.markDirty('libs/b', 'project-b-original'); - manager.markDirty('libs/b', 'project-b-intermediate'); + renameProject(manager, 'libs/b', 'project-b-intermediate'); + renameProject(manager, 'libs/b', 'project-b-final'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, { name: 'project-c', root: 'libs/c', targets: projectC.targets }, @@ -1061,7 +1208,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle circular references (A -> B -> A)', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -1077,11 +1224,12 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectB]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); // Rename both projects - manager.markDirty('libs/a', 'project-a'); - manager.markDirty('libs/b', 'project-b'); + renameProject(manager, 'libs/a', 'renamed-a'); + renameProject(manager, 'libs/b', 'renamed-b'); const rootMap = createRootMap([ { name: 'renamed-a', root: 'libs/a', targets: projectA.targets }, @@ -1096,7 +1244,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle large number of projects and references', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectCount = 50; const projects: any[] = []; const rootMap = createRootMap([]); @@ -1114,11 +1262,12 @@ describe('ProjectNameInNodePropsManager', () => { } const pluginResultProjects = createPluginResult(projects); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); // Mark all as dirty for (let i = 0; i < projectCount; i++) { - manager.markDirty(`libs/project-${i}`, `project-${i}`); + renameProject(manager, `libs/project-${i}`, `renamed-${i}`); rootMap[`libs/project-${i}`] = { name: `renamed-${i}`, root: `libs/project-${i}`, @@ -1138,7 +1287,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should only substitute dirty roots', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -1156,22 +1305,23 @@ describe('ProjectNameInNodePropsManager', () => { projectC, ]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - // Only mark B as dirty - manager.markDirty('libs/b', 'project-b'); + // Only rename B — C stays the same + renameProject(manager, 'libs/b', 'renamed-b'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, { name: 'renamed-b', root: 'libs/b' }, - { name: 'renamed-c', root: 'libs/c' }, // C is renamed but not marked dirty + { name: 'project-c', root: 'libs/c' }, ]); manager.applySubstitutions(rootMap); - // B reference should be updated + // B reference should be updated (dirty because renamed) expect(projectA.targets.build.inputs[0].projects).toBe('renamed-b'); - // C reference should still have the original name (not marked dirty) + // C reference should still have the original name (not dirty, never renamed) expect(projectA.targets.test.inputs[0].projects).toBe('project-c'); }); }); @@ -1180,13 +1330,14 @@ describe('ProjectNameInNodePropsManager', () => { describe('edge cases', () => { it('should handle projects without targets', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a'); const pluginResultProjects = createPluginResult([projectA]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/a', 'project-a'); + renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([{ name: 'renamed-a', root: 'libs/a' }]); @@ -1195,7 +1346,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle targets without inputs or dependsOn', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -1208,8 +1359,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/a', 'project-a'); + renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ { name: 'renamed-a', root: 'libs/a', targets: projectA.targets }, @@ -1220,7 +1372,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should ignore malformed target entries during registration', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -1230,13 +1382,13 @@ describe('ProjectNameInNodePropsManager', () => { }); const projectB = createProject('project-b', 'libs/b'); + const malformedResult = createPluginResult([projectA, projectB]); + identifyProjects(manager, malformedResult); expect(() => - manager.registerSubstitutorsForNodeResults( - createPluginResult([projectA, projectB]) - ) + manager.registerSubstitutorsForNodeResults(malformedResult) ).not.toThrow(); - manager.markDirty('libs/b', 'project-b'); + renameProject(manager, 'libs/b', 'renamed-b'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, { name: 'renamed-b', root: 'libs/b' }, @@ -1247,7 +1399,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle inputs with non-projects entries', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -1266,8 +1418,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectB]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/b', 'project-b'); + renameProject(manager, 'libs/b', 'renamed-b'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -1288,7 +1441,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle dependsOn with non-object entries', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -1305,8 +1458,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectB]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/b', 'project-b'); + renameProject(manager, 'libs/b', 'renamed-b'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -1322,7 +1476,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle dependsOn without projects property', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -1336,8 +1490,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/a', 'project-a'); + renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ { name: 'renamed-a', root: 'libs/a', targets: projectA.targets }, @@ -1349,7 +1504,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle referencing a non-existent project', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -1361,6 +1516,7 @@ describe('ProjectNameInNodePropsManager', () => { // This should not throw, since the referenced project doesn't exist // No substitutor will be registered for it + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); const rootMap = createRootMap([ @@ -1374,7 +1530,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should handle empty arrays in projects references', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); const projectA = createProject('project-a', 'libs/a', { targets: { @@ -1387,8 +1543,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/a', 'project-a'); + renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ { name: 'renamed-a', root: 'libs/a', targets: projectA.targets }, @@ -1403,19 +1560,28 @@ describe('ProjectNameInNodePropsManager', () => { describe('integration with merged configurations', () => { it('should find projects in merged configurations when not in plugin result', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); + + // Plugin 1: creates existing-project at libs/existing + const existingProject = createProject( + 'existing-project', + 'libs/existing' + ); + const existingResult = createPluginResult([existingProject]); + identifyProjects(manager, existingResult); + manager.registerSubstitutorsForNodeResults(existingResult); - // Project A references project B, but B is not in this plugin's result + // Plugin 2: Project A references existing-project (from a different plugin result) const projectA = createProject('project-a', 'libs/a', { targets: { build: createTargetWithProjectInput('existing-project'), }, }); + const projectAResult = createPluginResult([projectA]); + identifyProjects(manager, projectAResult); + manager.registerSubstitutorsForNodeResults(projectAResult); - const pluginResultProjects = createPluginResult([projectA]); - - manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/existing', 'existing-project'); + renameProject(manager, 'libs/existing', 'renamed-existing'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -1430,7 +1596,7 @@ describe('ProjectNameInNodePropsManager', () => { }); it('should prefer plugin result over merged configurations', () => { - const manager = new ProjectNameInNodePropsManager(); + const manager = createManager(); // Project A references project B which exists in both plugin result and merged configs const projectA = createProject('project-a', 'libs/a', { @@ -1444,8 +1610,9 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectBNew]); + identifyProjects(manager, pluginResultProjects); manager.registerSubstitutorsForNodeResults(pluginResultProjects); - manager.markDirty('libs/b-new', 'project-b'); + renameProject(manager, 'libs/b-new', 'renamed-b'); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -1459,4 +1626,356 @@ describe('ProjectNameInNodePropsManager', () => { expect(projectA.targets.build.inputs[0].projects).toBe('renamed-b'); }); }); + + // ============== Rename + New Project With Same Name ============== + + describe('rename with new project reusing old name', () => { + it('should not substitute references to A when A is renamed to B and a new project takes name A', () => { + // Scenario: + // Plugin 1 creates project "A" at libs/a (nothing depends on it) + // Plugin 2: + // - Renames libs/a from "A" to "B" + // - Adds a new project "A" at libs/new-a + // - Adds project C at libs/c that depends on "A" + // + // After merging, C's dependsOn "A" refers to the NEW project at + // libs/new-a, NOT the renamed project at libs/a. Substitutions + // should leave C's reference unchanged. + + const manager = createManager(); + + // Plugin 1: creates project "A" at libs/a — nothing references it + const originalA = createProject('A', 'libs/a'); + const plugin1Result = createPluginResult([originalA]); + + // Plugin 2: renames libs/a → "B", introduces new "A" at libs/new-a, + // and project C that depends on "A" (the new one) + const renamedA = createProject('B', 'libs/a'); + const newA = createProject('A', 'libs/new-a'); + const projectC = createProject('C', 'libs/c', { + targets: { + build: createTargetWithDependsOn('A'), + }, + }); + const plugin2Result = createPluginResult([renamedA, newA, projectC]); + + identifyProjects(manager, plugin1Result); + manager.registerSubstitutorsForNodeResults(plugin1Result); + identifyProjects(manager, plugin2Result); + manager.registerSubstitutorsForNodeResults(plugin2Result); + + // The merge phase detected that libs/a changed from "A" to "B" + renameProject(manager, 'libs/a', 'B'); + + const rootMap = createRootMap([ + { name: 'B', root: 'libs/a' }, + { name: 'A', root: 'libs/new-a' }, + { name: 'C', root: 'libs/c', targets: projectC.targets }, + ]); + + manager.applySubstitutions(rootMap); + + // C's dependsOn should still reference "A" (the new project), + // NOT "B" (the renamed project) + expect(projectC.targets.build.dependsOn[0].projects).toBe('A'); + }); + + it('should not substitute input references to A when A is renamed to B and a new project takes name A', () => { + const manager = createManager(); + + // Plugin 1: creates project "A" at libs/a — nothing references it + const originalA = createProject('A', 'libs/a'); + const plugin1Result = createPluginResult([originalA]); + + // Plugin 2: renames libs/a → "B", introduces new "A" at libs/new-a, + // and project C with inputs referencing "A" (the new one) + const renamedA = createProject('B', 'libs/a'); + const newA = createProject('A', 'libs/new-a'); + const projectC = createProject('C', 'libs/c', { + targets: { + build: createTargetWithProjectInput('A'), + }, + }); + const plugin2Result = createPluginResult([renamedA, newA, projectC]); + + identifyProjects(manager, plugin1Result); + manager.registerSubstitutorsForNodeResults(plugin1Result); + identifyProjects(manager, plugin2Result); + manager.registerSubstitutorsForNodeResults(plugin2Result); + + renameProject(manager, 'libs/a', 'B'); + + const rootMap = createRootMap([ + { name: 'B', root: 'libs/a' }, + { name: 'A', root: 'libs/new-a' }, + { name: 'C', root: 'libs/c', targets: projectC.targets }, + ]); + + manager.applySubstitutions(rootMap); + + // C's input reference should still point to "A" (the new project) + expect(projectC.targets.build.inputs[0].projects).toBe('A'); + }); + + it('should not substitute array input references to A when A is renamed and a new project takes name A', () => { + const manager = createManager(); + + // Plugin 1: creates project "A" at libs/a — nothing references it + const originalA = createProject('A', 'libs/a'); + const plugin1Result = createPluginResult([originalA]); + + // Plugin 2: renames libs/a → "B", introduces new "A", and project C + // with array input referencing ["A", "other"] + const renamedA = createProject('B', 'libs/a'); + const newA = createProject('A', 'libs/new-a'); + const otherProject = createProject('other', 'libs/other'); + const projectC = createProject('C', 'libs/c', { + targets: { + build: createTargetWithProjectInput(['A', 'other']), + }, + }); + const plugin2Result = createPluginResult([ + renamedA, + newA, + otherProject, + projectC, + ]); + + identifyProjects(manager, plugin1Result); + manager.registerSubstitutorsForNodeResults(plugin1Result); + identifyProjects(manager, plugin2Result); + manager.registerSubstitutorsForNodeResults(plugin2Result); + + renameProject(manager, 'libs/a', 'B'); + + const rootMap = createRootMap([ + { name: 'B', root: 'libs/a' }, + { name: 'A', root: 'libs/new-a' }, + { name: 'other', root: 'libs/other' }, + { name: 'C', root: 'libs/c', targets: projectC.targets }, + ]); + + manager.applySubstitutions(rootMap); + + // "A" in the array should still point to the new project "A" + expect((projectC.targets.build.inputs[0].projects as string[])[0]).toBe( + 'A' + ); + expect((projectC.targets.build.inputs[0].projects as string[])[1]).toBe( + 'other' + ); + }); + }); + + // ============== Same-Project Colon Target Name Tests ============== + + describe('same-project colon target names in dependsOn', () => { + it('should not substitute a dependsOn string that matches an owning project target name', () => { + // Reproduces the real-world bug: devkit has a target literally named + // "nx:echo" and a "parent" target with dependsOn: ["nx:echo"]. A + // different project at root "." is transiently named "nx" by an early + // plugin, then renamed to "@nx/nx-source". Without the fix, the + // substitution manager incorrectly rewrites "nx:echo" → + // "@nx/nx-source:echo" because it parses "nx:echo" as project "nx" + // target "echo" and a dirty entry exists for the name "nx". + const manager = createManager(); + + // Project at root that transiently has name "nx", then renamed + const rootProject = createProject('nx', 'root-dir'); + + // The "nx" package — a separate project with a stable name "nx" + const nxProject = createProject('nx', 'packages/nx'); + + // devkit has a target literally named "nx:echo" and a "parent" + // target that depends on it (same-project reference) + const devkit = createProject('devkit', 'packages/devkit', { + targets: { + 'nx:echo': { + command: "echo 'Hello'", + }, + parent: { + dependsOn: ['nx:echo'], + }, + }, + }); + + // First plugin result: root project with name "nx" + const plugin1Result = createPluginResult([rootProject]); + identifyProjects(manager, plugin1Result); + manager.registerSubstitutorsForNodeResults(plugin1Result); + + // Second plugin result: devkit with the colon target + const plugin2Result = createPluginResult([nxProject, devkit]); + identifyProjects(manager, plugin2Result); + manager.registerSubstitutorsForNodeResults(plugin2Result); + + // Root project renamed from "nx" to "@nx/nx-source" + renameProject(manager, 'root-dir', '@nx/nx-source'); + + const rootMap = createRootMap([ + { name: '@nx/nx-source', root: 'root-dir' }, + { name: 'nx', root: 'packages/nx' }, + { + name: 'devkit', + root: 'packages/devkit', + targets: devkit.targets, + }, + ]); + + manager.applySubstitutions(rootMap); + + // "nx:echo" should remain unchanged — it is a same-project target, + // not a cross-project reference to the "nx" project. + expect(devkit.targets.parent.dependsOn[0]).toBe('nx:echo'); + }); + + it('should substitute colon-prefixed cross-project dependsOn strings when all projects are renamed by a later plugin', () => { + // Reproduces a Gradle-style workspace where: + // Plugin 1 (e.g. @nx/gradle) infers projects with colon-prefixed + // names and dependsOn entries like ":libs:java:kafka-stream:jar". + // The target names are only the last segment (e.g. "jar"). + // Plugin 2 (e.g. package.json) renames ALL of those projects. + const manager = createManager(); + + // Plugin 1: Gradle plugin infers all projects in one result + const compactor = createProject( + ':apps:ovm-compactor', + 'apps/ovm-compactor', + { + targets: { + compileTestJava: {}, + testClasses: {}, + classes: {}, + compileJava: {}, + test: { + dependsOn: [ + ':apps:ovm-compactor:compileTestJava', + ':apps:ovm-compactor:testClasses', + ':apps:ovm-compactor:classes', + ':apps:ovm-compactor:compileJava', + ':libs:java:kafka-stream:jar', + ':libs:java:split-client:jar', + ], + }, + }, + } + ); + const kafkaStream = createProject( + ':libs:java:kafka-stream', + 'libs/java/kafka-stream', + { + targets: { jar: {} }, + } + ); + const splitClient = createProject( + ':libs:java:split-client', + 'libs/java/split-client', + { + targets: { jar: {} }, + } + ); + + const plugin1Result = createPluginResult([ + compactor, + kafkaStream, + splitClient, + ]); + + // Plugin 2: renames all projects (e.g. package.json names) + const renamedCompactor = createProject( + 'ovm-compactor', + 'apps/ovm-compactor' + ); + const renamedKafkaStream = createProject( + 'kafka-stream', + 'libs/java/kafka-stream' + ); + const renamedSplitClient = createProject( + 'split-client', + 'libs/java/split-client' + ); + + const plugin2Result = createPluginResult([ + renamedCompactor, + renamedKafkaStream, + renamedSplitClient, + ]); + + // Merge-before-register: identify plugin1 projects, then register + renameProject(manager, 'apps/ovm-compactor', ':apps:ovm-compactor'); + renameProject( + manager, + 'libs/java/kafka-stream', + ':libs:java:kafka-stream' + ); + renameProject( + manager, + 'libs/java/split-client', + ':libs:java:split-client' + ); + manager.registerSubstitutorsForNodeResults(plugin1Result); + + // Plugin 2 renames all projects — identify new names, then register + renameProject(manager, 'apps/ovm-compactor', 'ovm-compactor'); + renameProject(manager, 'libs/java/kafka-stream', 'kafka-stream'); + renameProject(manager, 'libs/java/split-client', 'split-client'); + manager.registerSubstitutorsForNodeResults(plugin2Result); + + const rootMap = createRootMap([ + { + name: 'ovm-compactor', + root: 'apps/ovm-compactor', + targets: compactor.targets, + }, + { name: 'kafka-stream', root: 'libs/java/kafka-stream' }, + { name: 'split-client', root: 'libs/java/split-client' }, + ]); + + manager.applySubstitutions(rootMap); + + const dependsOn = compactor.targets.test.dependsOn; + // Same-project refs should use the new name + expect(dependsOn[0]).toBe('ovm-compactor:compileTestJava'); + expect(dependsOn[1]).toBe('ovm-compactor:testClasses'); + expect(dependsOn[2]).toBe('ovm-compactor:classes'); + expect(dependsOn[3]).toBe('ovm-compactor:compileJava'); + // Cross-project refs should use the new names + expect(dependsOn[4]).toBe('kafka-stream:jar'); + expect(dependsOn[5]).toBe('split-client:jar'); + }); + + it('should still substitute genuine cross-project "project:target" references', () => { + const manager = createManager(); + + // Project B has a "compile" target + const projectB = createProject('project-b', 'libs/b', { + targets: { compile: {} }, + }); + + // Project A references project-b:compile (a real cross-project ref) + // and does NOT have a target named "project-b:compile" + const projectA = createProject('project-a', 'libs/a', { + targets: { + build: { + dependsOn: ['project-b:compile'], + }, + }, + }); + + const crossProjectResult = createPluginResult([projectA, projectB]); + identifyProjects(manager, crossProjectResult); + manager.registerSubstitutorsForNodeResults(crossProjectResult); + renameProject(manager, 'libs/b', 'renamed-b'); + + const rootMap = createRootMap([ + { name: 'project-a', root: 'libs/a', targets: projectA.targets }, + { name: 'renamed-b', root: 'libs/b' }, + ]); + + manager.applySubstitutions(rootMap); + + // The genuine cross-project reference should be substituted + expect(projectA.targets.build.dependsOn[0]).toBe('renamed-b:compile'); + }); + }); }); diff --git a/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.ts b/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.ts index bac1815e4b4..294535616aa 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.ts @@ -1,7 +1,6 @@ -import { ProjectGraphProjectNode } from '../../../config/project-graph'; import { ProjectConfiguration } from '../../../config/workspace-json-project-json'; import { isGlobPattern } from '../../../utils/globs'; -import { splitTargetFromNodes } from '../../../utils/split-target'; +import { splitTargetFromConfigurations } from '../../../utils/split-target'; import { minimatch } from 'minimatch'; // A substitutor receives the final resolved name of the project that was @@ -20,6 +19,17 @@ type SubstitutorEntry = { substitutor: ProjectNameSubstitutor; }; +// Tracking item stored in substitutorsByArrayKey. Associates a substitutor +// entry with the root (or pending name) key so it can be evicted when +// overwritten. When `keyedByRoot` is true the item lives in +// `substitutorsByReferencedRoot`; otherwise it lives in +// `pendingSubstitutorsByName`. +type TrackingItem = { + referencedRoot?: string; + referencedName?: string; + entry: SubstitutorEntry; +}; + /** * Manages deferred project name substitutions across the plugin result * merge phase of project graph construction. @@ -33,24 +43,26 @@ type SubstitutorEntry = { * hold a stale reference to the now-nonexistent name `B`. * * This class solves that by: - * 1. Scanning each plugin's results for project-name references in + * 1. Receiving a live nameMap accessor (maintained by ProjectNodesManager) + * for name → root resolution and colon-delimited string parsing. + * 2. Tracking dirty roots via {@link identifyProjectWithRoot} when a + * project name changes at a root. + * 3. Scanning each plugin's results for project-name references in * `inputs` and `dependsOn` ({@link registerSubstitutorsForNodeResults}). - * Substitutors are indexed by the **referenced name as it appeared in - * the plugin result** — no lookup into any project map is needed at - * registration time. - * 2. When a project's name changes during the merge, recording that change - * via {@link markDirty} so substitutors for the old name can be located. - * 3. After all results are merged, applying the substitutors for every + * 4. After all results are merged, applying the substitutors for every * renamed project so that references are updated to the final name * ({@link applySubstitutions}). */ export class ProjectNameInNodePropsManager { - // Maps the *referenced project name at registration time* → set of - // substitutor entries that should run when a project is renamed FROM - // that name. Keying by name (not root) means no project-map lookup is - // needed at registration time, and ordering between result entries does - // not matter. - private projectNameSubstitutors = new Map>(); + // Maps the *root of the referenced project* → set of substitutor entries + // that should run when that project is renamed. Keying by root (not name) + // ensures that when project "A" is renamed to "B" and a *new* project + // takes the name "A" at a different root, references to the new "A" are + // not incorrectly rewritten. + private substitutorsByReferencedRoot = new Map< + string, + Set + >(); // Tracks substitutor entries by (array path, index, subIndex). This // serves two purposes: @@ -67,35 +79,48 @@ export class ProjectNameInNodePropsManager { // single `projects` array can hold multiple name references. private substitutorsByArrayKey = new Map< string, - Array< - | Array<{ referencedName: string; entry: SubstitutorEntry } | undefined> - | undefined - > + Array | undefined> >(); - // Projects whose names changed during the merge phase. Key = root of the - // renamed project, value = all names it previously held in this merge. - // These are used in applySubstitutions to locate substitutors keyed by - // old names. - private dirtyEntries = new Map>(); - - // Partial project graph nodes accumulated across plugin registrations. - // Used by splitTargetFromNodes to properly parse string-form dependsOn - // entries like "project:target" — including project / target names that - // contain colons. - private knownProjectNodes: Record = {}; - - private removeSubstitutorEntry(item: { - referencedName: string; - entry: SubstitutorEntry; - }) { - const substitutors = this.projectNameSubstitutors.get(item.referencedName); - if (!substitutors) { - return; + // Holds substitutors for project names that haven't been identified yet + // (forward references). When identifyProjectWithRoot is later called for + // a name in this map, the entries are promoted to substitutorsByReferencedRoot. + private pendingSubstitutorsByName = new Map>(); + + // Roots of projects whose names changed during the merge phase. + private dirtyRoots = new Set(); + + // Lazy accessor for the name-keyed project configuration map maintained + // by the owning ProjectNodesManager. Points to the same object references + // as the rootMap, so targets are always up-to-date without manual copying. + private getNameMap: () => Record; + + constructor(getNameMap?: () => Record) { + this.getNameMap = getNameMap ?? (() => ({})); + } + + private removeSubstitutorEntry(item: TrackingItem) { + if (item.referencedRoot !== undefined) { + const substitutors = this.substitutorsByReferencedRoot.get( + item.referencedRoot + ); + if (substitutors) { + substitutors.delete(item.entry); + if (substitutors.size === 0) { + this.substitutorsByReferencedRoot.delete(item.referencedRoot); + } + } } - substitutors.delete(item.entry); - if (substitutors.size === 0) { - this.projectNameSubstitutors.delete(item.referencedName); + if (item.referencedName !== undefined) { + const substitutors = this.pendingSubstitutorsByName.get( + item.referencedName + ); + if (substitutors) { + substitutors.delete(item.entry); + if (substitutors.size === 0) { + this.pendingSubstitutorsByName.delete(item.referencedName); + } + } } } @@ -218,9 +243,10 @@ export class ProjectNameInNodePropsManager { } } - // Registers a new substitutor keyed by `referencedName` (the project name - // as it appears in the reference), tracked at (arrayKey, index, subIndex) - // for deduplication and tail-clearing. + // Registers a new substitutor for `referencedName`, tracked at + // (arrayKey, index, subIndex) for deduplication and tail-clearing. + // The substitutor is keyed by root when the referenced project is + // already in the nameMap, otherwise parked in pendingSubstitutorsByName. private registerProjectNameSubstitutor( referencedName: string, ownerRoot: string, @@ -232,14 +258,34 @@ export class ProjectNameInNodePropsManager { // Evict any existing substitutor at this exact position first. this.clearSubstitutorAtIndex(arrayKey, index, subIndex); - let substitutorsForName = this.projectNameSubstitutors.get(referencedName); - if (!substitutorsForName) { - substitutorsForName = new Set(); - this.projectNameSubstitutors.set(referencedName, substitutorsForName); - } - const entry: SubstitutorEntry = { ownerRoot, substitutor }; - substitutorsForName.add(entry); + const nameMap = this.getNameMap(); + const referencedRoot = nameMap[referencedName]?.root; + + let trackingItem: TrackingItem; + if (referencedRoot !== undefined) { + // Project is already known — key directly by root. + let substitutorsForRoot = + this.substitutorsByReferencedRoot.get(referencedRoot); + if (!substitutorsForRoot) { + substitutorsForRoot = new Set(); + this.substitutorsByReferencedRoot.set( + referencedRoot, + substitutorsForRoot + ); + } + substitutorsForRoot.add(entry); + trackingItem = { referencedRoot, entry }; + } else { + // Forward reference — park in pending map keyed by name. + let pendingSet = this.pendingSubstitutorsByName.get(referencedName); + if (!pendingSet) { + pendingSet = new Set(); + this.pendingSubstitutorsByName.set(referencedName, pendingSet); + } + pendingSet.add(entry); + trackingItem = { referencedName, entry }; + } let byIndex = this.substitutorsByArrayKey.get(arrayKey); if (!byIndex) { @@ -248,17 +294,13 @@ export class ProjectNameInNodePropsManager { } if (subIndex === undefined) { - // Single project reference — store directly - byIndex[index] = [{ referencedName, entry }]; + byIndex[index] = [trackingItem]; } else { - // Multiple projects in an array — ensure the slot exists if (!byIndex[index]) { byIndex[index] = []; } - const subArray = byIndex[index] as Array< - { referencedName: string; entry: SubstitutorEntry } | undefined - >; - subArray[subIndex] = { referencedName, entry }; + const subArray = byIndex[index] as Array; + subArray[subIndex] = trackingItem; } } @@ -267,10 +309,9 @@ export class ProjectNameInNodePropsManager { * reference another project by name, and registers substitutors so those * references are updated if the target project is later renamed. * - * Project nodes from each call are accumulated internally so that - * string-form `dependsOn` entries (e.g. `"project:target"`) can be - * properly parsed with {@link splitTargetFromNodes}, even when project - * or target names contain colons. + * **Important**: call {@link identifyProjectWithRoot} for all projects in + * this result (and all prior results) before calling this method, so that + * referenced project names can be resolved to roots. * * @param pluginResultProjects Projects from a single plugin's createNodes call. */ @@ -284,19 +325,6 @@ export class ProjectNameInNodePropsManager { return; } - // Accumulate partial project graph nodes for splitTargetFromNodes. - for (const root in pluginResultProjects) { - const project = pluginResultProjects[root]; - const name = project.name; - if (name) { - this.knownProjectNodes[name] = { - type: 'lib', - name, - data: { root, ...project } as ProjectConfiguration, - }; - } - } - for (const ownerRoot in pluginResultProjects) { const project = pluginResultProjects[ownerRoot]; if (!project.targets) { @@ -318,7 +346,9 @@ export class ProjectNameInNodePropsManager { this.registerSubstitutorsForDependsOn( ownerRoot, targetName, - targetConfig.dependsOn + targetConfig.dependsOn, + project.targets, + project.name ); } } @@ -474,7 +504,11 @@ export class ProjectNameInNodePropsManager { private registerSubstitutorsForDependsOn( ownerRoot: string, targetName: string, - dependsOn: NonNullable + dependsOn: NonNullable< + ProjectConfiguration['targets'][string]['dependsOn'] + >, + ownerTargets?: Record, + ownerProjectName?: string ) { const arrayKey = `${ownerRoot}:targets.${targetName}.dependsOn`; for (let i = 0; i < dependsOn.length; i++) { @@ -482,12 +516,17 @@ export class ProjectNameInNodePropsManager { if (typeof dep === 'string') { // String-form dependsOn entries like "project:target". Strings // starting with '^' are dependency-mode references (no project - // name). Use splitTargetFromNodes with accumulated project nodes - // to properly handle project / target names containing colons. - if (!dep.startsWith('^')) { - const [maybeProject, ...rest] = splitTargetFromNodes( + // name). Use splitTargetFromConfigurations with the nameMap to + // properly handle project / target names containing colons. + // + // However, if the string matches a target name in the owning + // project, it is a same-project target reference (e.g. a target + // literally named "nx:echo"), not a cross-project reference. + if (!dep.startsWith('^') && !(ownerTargets && dep in ownerTargets)) { + const [maybeProject, ...rest] = splitTargetFromConfigurations( dep, - this.knownProjectNodes + this.getNameMap(), + { silent: true, currentProject: ownerProjectName } ); if (rest.length > 0) { const targetPart = rest.join(':'); @@ -549,17 +588,55 @@ export class ProjectNameInNodePropsManager { } /** - * Records that the project at `root` was renamed from `previousName`. - * Substitutors registered for `previousName` will fire during - * {@link applySubstitutions}. + * Records that a project with `name` exists at the given `root`. Call + * this during the merge phase whenever a project's name changes at a + * root — **before** calling + * {@link registerSubstitutorsForNodeResults} for that result. + * + * The nameMap (maintained externally by ProjectNodesManager) is always + * current — this method only needs to mark the root as dirty and + * promote any pending substitutors keyed by name. */ - markDirty(root: string, previousName: string) { - let previousNames = this.dirtyEntries.get(root); - if (!previousNames) { - previousNames = new Set(); - this.dirtyEntries.set(root, previousNames); + identifyProjectWithRoot(root: string, name: string) { + // Always mark dirty when called — the caller only invokes this when + // the name actually changed at this root (first identification or + // rename). If there are pending substitutors for this name, those + // forward refs need updating. If it's a rename, existing refs need + // updating. Either way, the root is dirty. + this.dirtyRoots.add(root); + + // Promote any pending substitutors that were waiting for this name. + const pending = this.pendingSubstitutorsByName.get(name); + if (pending) { + this.pendingSubstitutorsByName.delete(name); + + let substitutorsForRoot = this.substitutorsByReferencedRoot.get(root); + if (!substitutorsForRoot) { + substitutorsForRoot = new Set(); + this.substitutorsByReferencedRoot.set(root, substitutorsForRoot); + } + + for (const entry of pending) { + substitutorsForRoot.add(entry); + } + + // Update tracking items to reflect the promotion from name → root. + for (const [, byIndex] of this.substitutorsByArrayKey) { + for (const atIndex of byIndex) { + if (!atIndex) continue; + for (const item of atIndex) { + if ( + item && + item.referencedName === name && + pending.has(item.entry) + ) { + item.referencedName = undefined; + item.referencedRoot = root; + } + } + } + } } - previousNames.add(previousName); } /** @@ -568,23 +645,21 @@ export class ProjectNameInNodePropsManager { * called once after all plugin results have been merged. */ applySubstitutions(rootMap: Record) { - for (const [root, previousNames] of this.dirtyEntries) { + for (const root of this.dirtyRoots) { const finalName = rootMap[root]?.name; if (!finalName) { continue; } - for (const previousName of previousNames) { - const substitutors = this.projectNameSubstitutors.get(previousName); - if (!substitutors) { - continue; - } - for (const { ownerRoot, substitutor } of substitutors) { - // Each entry stores the ownerRoot of the project holding the stale - // reference so we can look up its final merged config here. - const ownerConfig = rootMap[ownerRoot]; - if (ownerConfig) { - substitutor(finalName, ownerConfig); - } + + const substitutors = this.substitutorsByReferencedRoot.get(root); + if (!substitutors) { + continue; + } + + for (const { ownerRoot, substitutor } of substitutors) { + const ownerConfig = rootMap[ownerRoot]; + if (ownerConfig) { + substitutor(finalName, ownerConfig); } } } diff --git a/packages/nx/src/project-graph/utils/project-configuration/project-nodes-manager.spec.ts b/packages/nx/src/project-graph/utils/project-configuration/project-nodes-manager.spec.ts new file mode 100644 index 00000000000..3f45f31c5c4 --- /dev/null +++ b/packages/nx/src/project-graph/utils/project-configuration/project-nodes-manager.spec.ts @@ -0,0 +1,1125 @@ +import { ProjectConfiguration } from '../../../config/workspace-json-project-json'; +import { + mergeProjectConfigurationIntoRootMap, + readProjectConfigurationsFromRootMap, +} from './project-nodes-manager'; +import type { ConfigurationSourceMaps } from './source-maps'; + +describe('mergeProjectConfigurationIntoRootMap', () => { + it('should merge targets from different configurations', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + targets: { + echo: { + command: 'echo lib-a', + }, + }, + }) + .getRootMap(); + mergeProjectConfigurationIntoRootMap(rootMap, { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + command: 'tsc', + }, + }, + }); + expect(rootMap['libs/lib-a']).toMatchInlineSnapshot(` + { + "name": "lib-a", + "root": "libs/lib-a", + "targets": { + "build": { + "executor": "nx:run-commands", + "options": { + "command": "tsc", + }, + }, + "echo": { + "command": "echo lib-a", + }, + }, + } + `); + }); + + // Target configuration merging is tested more thoroughly in mergeTargetConfigurations + it('should merge target configurations of compatible target declarations', () => { + const existingTargetConfiguration = { + command: 'already present', + }; + const shouldMergeConfigurationA = { + executor: 'build', + options: { + a: 1, + b: { + c: 2, + }, + }, + configurations: { + dev: { + foo: 'bar', + }, + prod: { + optimize: true, + }, + }, + }; + const shouldMergeConfigurationB = { + executor: 'build', + options: { + d: 3, + b: { + c: 2, + }, + }, + configurations: { + prod: { + foo: 'baz', + }, + }, + }; + const shouldntMergeConfigurationA = { + executor: 'build', + options: { + a: 1, + }, + }; + const shouldntMergeConfigurationB = { + executor: 'test', + options: { + test: 1, + }, + }; + const newTargetConfiguration = { + executor: 'echo', + options: { + echo: 'echo', + }, + }; + + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + targets: { + existingTarget: existingTargetConfiguration, + shouldMerge: shouldMergeConfigurationA, + shouldntMerge: shouldntMergeConfigurationA, + }, + }) + .getRootMap(); + mergeProjectConfigurationIntoRootMap(rootMap, { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + shouldMerge: shouldMergeConfigurationB, + shouldntMerge: shouldntMergeConfigurationB, + newTarget: newTargetConfiguration, + }, + }); + const merged = rootMap['libs/lib-a']; + expect(merged.targets['existingTarget']).toEqual( + existingTargetConfiguration + ); + expect(merged.targets['shouldMerge']).toMatchInlineSnapshot(` + { + "configurations": { + "dev": { + "foo": "bar", + }, + "prod": { + "foo": "baz", + "optimize": true, + }, + }, + "executor": "build", + "options": { + "a": 1, + "b": { + "c": 2, + }, + "d": 3, + }, + } + `); + expect(merged.targets['shouldntMerge']).toEqual( + shouldntMergeConfigurationB + ); + expect(merged.targets['newTarget']).toEqual(newTargetConfiguration); + }); + + it('should merge target configurations with glob pattern matching', () => { + const existingTargetConfiguration = { + command: 'already present', + }; + const partialA = { + executor: 'build', + dependsOn: ['^build'], + }; + const partialB = { + executor: 'build', + dependsOn: ['^build'], + }; + const partialC = { + executor: 'build', + dependsOn: ['^build'], + }; + const globMatch = { + dependsOn: ['^build', { project: 'app', target: 'build' }], + }; + const nonMatchingGlob = { + dependsOn: ['^production', 'build'], + }; + + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + targets: { + existingTarget: existingTargetConfiguration, + 'partial-path/a': partialA, + 'partial-path/b': partialB, + 'partial-path/c': partialC, + }, + }) + .getRootMap(); + mergeProjectConfigurationIntoRootMap(rootMap, { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + 'partial-**/*': globMatch, + 'ci-*': nonMatchingGlob, + }, + }); + const merged = rootMap['libs/lib-a']; + expect(merged.targets['partial-path/a']).toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + { + "project": "app", + "target": "build", + }, + ], + "executor": "build", + } + `); + expect(merged.targets['partial-path/b']).toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + { + "project": "app", + "target": "build", + }, + ], + "executor": "build", + } + `); + expect(merged.targets['partial-path/c']).toMatchInlineSnapshot(` + { + "dependsOn": [ + "^build", + { + "project": "app", + "target": "build", + }, + ], + "executor": "build", + } + `); + // if the glob pattern doesn't match, the target is not merged + expect(merged.targets['ci-*']).toMatchInlineSnapshot(` + { + "dependsOn": [ + "^production", + "build", + ], + } + `); + // if the glob pattern matches, the target is merged + expect(merged.targets['partial-**/*']).toBeUndefined(); + }); + + it('should concatenate tags and implicitDependencies', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + tags: ['a', 'b'], + implicitDependencies: ['lib-b'], + }) + .getRootMap(); + mergeProjectConfigurationIntoRootMap(rootMap, { + root: 'libs/lib-a', + name: 'lib-a', + tags: ['b', 'c'], + implicitDependencies: ['lib-c', '!lib-b'], + }); + expect(rootMap['libs/lib-a'].tags).toEqual(['a', 'b', 'c']); + expect(rootMap['libs/lib-a'].implicitDependencies).toEqual([ + 'lib-b', + 'lib-c', + '!lib-b', + ]); + }); + + it('should merge generator options', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + generators: { + '@nx/angular:component': { + style: 'scss', + }, + }, + }) + .getRootMap(); + mergeProjectConfigurationIntoRootMap(rootMap, { + root: 'libs/lib-a', + name: 'lib-a', + generators: { + '@nx/angular:component': { + flat: true, + }, + '@nx/angular:service': { + spec: false, + }, + }, + }); + expect(rootMap['libs/lib-a'].generators).toMatchInlineSnapshot(` + { + "@nx/angular:component": { + "flat": true, + "style": "scss", + }, + "@nx/angular:service": { + "spec": false, + }, + } + `); + }); + + it('should merge namedInputs', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + namedInputs: { + production: ['{projectRoot}/**/*.ts', '!{projectRoot}/**/*.spec.ts'], + test: ['{projectRoot}/**/*.spec.ts'], + }, + }) + .getRootMap(); + mergeProjectConfigurationIntoRootMap(rootMap, { + root: 'libs/lib-a', + name: 'lib-a', + namedInputs: { + another: ['{projectRoot}/**/*.ts'], + production: ['{projectRoot}/**/*.prod.ts'], + }, + }); + expect(rootMap['libs/lib-a'].namedInputs).toMatchInlineSnapshot(` + { + "another": [ + "{projectRoot}/**/*.ts", + ], + "production": [ + "{projectRoot}/**/*.prod.ts", + ], + "test": [ + "{projectRoot}/**/*.spec.ts", + ], + } + `); + }); + + it('should merge release', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + }) + .getRootMap(); + mergeProjectConfigurationIntoRootMap(rootMap, { + root: 'libs/lib-a', + name: 'lib-a', + release: { + version: { + versionActionsOptions: { fo: 'bar' }, + }, + }, + }); + expect(rootMap['libs/lib-a'].release).toMatchInlineSnapshot(` + { + "version": { + "versionActionsOptions": { + "fo": "bar", + }, + }, + } + `); + }); + + describe('metadata', () => { + it('should be set if not previously defined', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + }) + .getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': {}, + }; + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + metadata: { + technologies: ['technology'], + targetGroups: { + group1: ['target1', 'target2'], + }, + }, + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + + expect(rootMap['libs/lib-a'].metadata).toEqual({ + technologies: ['technology'], + targetGroups: { + group1: ['target1', 'target2'], + }, + }); + expect(sourceMap['libs/lib-a']).toMatchObject({ + 'metadata.technologies': ['dummy', 'dummy.ts'], + 'metadata.targetGroups': ['dummy', 'dummy.ts'], + 'metadata.targetGroups.group1': ['dummy', 'dummy.ts'], + }); + }); + + it('should concat arrays', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + metadata: { + technologies: ['technology1'], + }, + }) + .getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': { + 'metadata.technologies': ['existing', 'existing.ts'], + 'metadata.technologies.0': ['existing', 'existing.ts'], + }, + }; + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + metadata: { + technologies: ['technology2'], + }, + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + + expect(rootMap['libs/lib-a'].metadata).toEqual({ + technologies: ['technology1', 'technology2'], + }); + expect(sourceMap['libs/lib-a']).toMatchObject({ + 'metadata.technologies': ['existing', 'existing.ts'], + 'metadata.technologies.0': ['existing', 'existing.ts'], + 'metadata.technologies.1': ['dummy', 'dummy.ts'], + }); + }); + + it('should concat second level arrays', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + metadata: { + targetGroups: { + group1: ['target1'], + }, + }, + }) + .getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': { + 'metadata.targetGroups': ['existing', 'existing.ts'], + 'metadata.targetGroups.group1': ['existing', 'existing.ts'], + 'metadata.targetGroups.group1.0': ['existing', 'existing.ts'], + }, + }; + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + metadata: { + targetGroups: { + group1: ['target2'], + }, + }, + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + + expect(rootMap['libs/lib-a'].metadata).toEqual({ + targetGroups: { + group1: ['target1', 'target2'], + }, + }); + + expect(sourceMap['libs/lib-a']).toMatchObject({ + 'metadata.targetGroups': ['existing', 'existing.ts'], + 'metadata.targetGroups.group1': ['existing', 'existing.ts'], + 'metadata.targetGroups.group1.0': ['existing', 'existing.ts'], + 'metadata.targetGroups.group1.1': ['dummy', 'dummy.ts'], + }); + + expect(sourceMap['libs/lib-a']['metadata.targetGroups']).toEqual([ + 'existing', + 'existing.ts', + ]); + expect(sourceMap['libs/lib-a']['metadata.targetGroups.group1']).toEqual([ + 'existing', + 'existing.ts', + ]); + expect(sourceMap['libs/lib-a']['metadata.targetGroups.group1.0']).toEqual( + ['existing', 'existing.ts'] + ); + expect(sourceMap['libs/lib-a']['metadata.targetGroups.group1.1']).toEqual( + ['dummy', 'dummy.ts'] + ); + }); + + it('should not clobber targetGroups', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + metadata: { + targetGroups: { + group2: ['target3'], + }, + }, + }) + .getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': {}, + }; + + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + metadata: { + technologies: ['technology'], + targetGroups: { + group1: ['target1', 'target2'], + }, + }, + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + + expect(rootMap['libs/lib-a'].metadata).toEqual({ + technologies: ['technology'], + targetGroups: { + group1: ['target1', 'target2'], + group2: ['target3'], + }, + }); + }); + + it('should be added to targets', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + }) + .getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': {}, + }; + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + metadata: { + description: 'do stuff', + technologies: ['tech'], + }, + }, + }, + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + + expect(rootMap['libs/lib-a'].targets.build.metadata).toEqual({ + description: 'do stuff', + technologies: ['tech'], + }); + expect(sourceMap['libs/lib-a']).toMatchObject({ + 'targets.build.metadata.description': ['dummy', 'dummy.ts'], + 'targets.build.metadata.technologies': ['dummy', 'dummy.ts'], + 'targets.build.metadata.technologies.0': ['dummy', 'dummy.ts'], + }); + }); + + it('should be merged on targets', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + metadata: { + description: 'do stuff', + technologies: ['tech'], + }, + }, + }, + }) + .getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': { + 'targets.build.metadata.technologies': ['existing', 'existing.ts'], + 'targets.build.metadata.technologies.0': ['existing', 'existing.ts'], + }, + }; + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + metadata: { + description: 'do cool stuff', + technologies: ['tech2'], + }, + }, + }, + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + + expect(rootMap['libs/lib-a'].targets.build.metadata).toEqual({ + description: 'do cool stuff', + technologies: ['tech', 'tech2'], + }); + expect(sourceMap['libs/lib-a']).toMatchObject({ + 'targets.build.metadata.description': ['dummy', 'dummy.ts'], + 'targets.build.metadata.technologies': ['existing', 'existing.ts'], + 'targets.build.metadata.technologies.0': ['existing', 'existing.ts'], + 'targets.build.metadata.technologies.1': ['dummy', 'dummy.ts'], + }); + }); + }); + + describe('source map', () => { + it('should add new project info', () => { + const rootMap = new RootMapBuilder().getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': {}, + }; + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + executor: 'nx:run-commands', + options: { + command: 'echo hello', + }, + configurations: { + dev: { + command: 'echo dev', + }, + production: { + command: 'echo production', + }, + }, + }, + }, + tags: ['a', 'b'], + implicitDependencies: ['lib-b'], + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + expect(sourceMap).toMatchInlineSnapshot(` + { + "libs/lib-a": { + "implicitDependencies": [ + "dummy", + "dummy.ts", + ], + "implicitDependencies.lib-b": [ + "dummy", + "dummy.ts", + ], + "name": [ + "dummy", + "dummy.ts", + ], + "root": [ + "dummy", + "dummy.ts", + ], + "tags": [ + "dummy", + "dummy.ts", + ], + "tags.a": [ + "dummy", + "dummy.ts", + ], + "tags.b": [ + "dummy", + "dummy.ts", + ], + "targets": [ + "dummy", + "dummy.ts", + ], + "targets.build": [ + "dummy", + "dummy.ts", + ], + "targets.build.configurations": [ + "dummy", + "dummy.ts", + ], + "targets.build.configurations.dev": [ + "dummy", + "dummy.ts", + ], + "targets.build.configurations.dev.command": [ + "dummy", + "dummy.ts", + ], + "targets.build.configurations.production": [ + "dummy", + "dummy.ts", + ], + "targets.build.configurations.production.command": [ + "dummy", + "dummy.ts", + ], + "targets.build.executor": [ + "dummy", + "dummy.ts", + ], + "targets.build.options": [ + "dummy", + "dummy.ts", + ], + "targets.build.options.command": [ + "dummy", + "dummy.ts", + ], + }, + } + `); + }); + + it('should merge root level properties', () => { + const rootMap: Record = {}; + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': {}, + }; + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + tags: ['a', 'b'], + projectType: 'application', + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + projectType: 'library', + tags: ['c'], + implicitDependencies: ['lib-b'], + }, + sourceMap, + ['dummy2', 'dummy2.ts'] + ); + assertCorrectKeysInSourceMap( + sourceMap, + 'libs/lib-a', + ['tags.a', 'dummy'], + ['tags.c', 'dummy2'], + ['projectType', 'dummy2'], + ['implicitDependencies.lib-b', 'dummy2'] + ); + }); + + it('should merge target properties for compatible targets', () => { + const rootMap = new RootMapBuilder().getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': {}, + }; + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + executor: 'nx:run-commands', + inputs: ['input1'], + options: { + command: 'echo hello', + oldOption: 'value', + }, + configurations: { + dev: { + command: 'echo dev', + oldOption: 'old option', + }, + }, + }, + }, + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + inputs: ['input2'], + outputs: ['output2'], + options: { + oldOption: 'new value', + newOption: 'value', + }, + configurations: { + dev: { + command: 'echo dev 2', + newOption: 'new option', + }, + production: { + command: 'echo production', + }, + }, + }, + }, + }, + sourceMap, + ['dummy2', 'dummy2.ts'] + ); + + assertCorrectKeysInSourceMap( + sourceMap, + 'libs/lib-a', + ['targets.build', 'dummy2'], + ['targets.build.executor', 'dummy'], + ['targets.build.inputs', 'dummy2'], + ['targets.build.outputs', 'dummy2'], + ['targets.build.options', 'dummy2'], + ['targets.build.options.command', 'dummy'], + ['targets.build.options.oldOption', 'dummy2'], + ['targets.build.options.newOption', 'dummy2'], + ['targets.build.configurations', 'dummy2'], + ['targets.build.configurations.dev.command', 'dummy2'], + ['targets.build.configurations.dev.oldOption', 'dummy'], + ['targets.build.configurations.dev.newOption', 'dummy2'], + ['targets.build.configurations.production.command', 'dummy2'] + ); + }); + + it('should override target options & configurations for incompatible targets', () => { + const rootMap = new RootMapBuilder().getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': {}, + }; + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + executor: 'nx:run-commands', + options: { + command: 'echo hello', + oldOption: 'value', + }, + configurations: { + dev: { + command: 'echo dev', + oldOption: 'old option', + }, + }, + }, + }, + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + executor: 'other-executor', + options: { + option1: 'option1', + }, + configurations: { + prod: { + command: 'echo dev', + }, + }, + }, + }, + }, + sourceMap, + ['dummy2', 'dummy2.ts'] + ); + assertCorrectKeysInSourceMap( + sourceMap, + 'libs/lib-a', + ['targets.build', 'dummy2'], + ['targets.build.executor', 'dummy2'], + ['targets.build.options', 'dummy2'], + ['targets.build.options.option1', 'dummy2'], + ['targets.build.configurations', 'dummy2'], + ['targets.build.configurations.prod', 'dummy2'], + ['targets.build.configurations.prod.command', 'dummy2'] + ); + + expect( + sourceMap['libs/lib-a']['targets.build.configurations.dev'] + ).toBeFalsy(); + expect(sourceMap['libs/lib-a']['targets.build.outputs']).toBeFalsy(); + expect( + sourceMap['libs/lib-a']['targets.build.options.command'] + ).toBeFalsy(); + }); + + it('should not merge top level properties for incompatible targets', () => { + const rootMap = new RootMapBuilder().getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': {}, + }; + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + executor: 'nx:run-commands', + inputs: ['input1'], + }, + }, + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + executor: 'other-executor', + outputs: ['output1'], + }, + }, + }, + sourceMap, + ['dummy2', 'dummy2.ts'] + ); + assertCorrectKeysInSourceMap( + sourceMap, + 'libs/lib-a', + ['targets.build', 'dummy2'], + ['targets.build.executor', 'dummy2'], + ['targets.build.outputs', 'dummy2'] + ); + + expect(sourceMap['libs/lib-a']['targets.build.inputs']).toBeFalsy(); + }); + + it('should merge generator property', () => { + const rootMap = new RootMapBuilder().getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': {}, + }; + mergeProjectConfigurationIntoRootMap( + rootMap, + { + name: 'lib-a', + root: 'libs/lib-a', + generators: { + '@nx/angular:component': { + option1: true, + option2: 'true', + }, + }, + }, + sourceMap, + ['dummy', 'dummy.ts'] + ); + mergeProjectConfigurationIntoRootMap( + rootMap, + { + name: 'lib-a', + root: 'libs/lib-a', + generators: { + '@nx/angular:component': { + option1: false, + option3: { + nested: 3, + }, + }, + }, + }, + sourceMap, + ['dummy2', 'dummy2.ts'] + ); + + assertCorrectKeysInSourceMap( + sourceMap, + 'libs/lib-a', + ['generators.@nx/angular:component.option1', 'dummy2'], + ['generators.@nx/angular:component.option2', 'dummy'], + ['generators.@nx/angular:component.option3', 'dummy2'] + ); + }); + }); +}); + +describe('readProjectsConfigurationsFromRootMap', () => { + it('should error if multiple roots point to the same project', () => { + const rootMap = new RootMapBuilder() + .addProject({ + name: 'lib', + root: 'apps/lib-a', + }) + .addProject({ + name: 'lib', + root: 'apps/lib-b', + }) + .getRootMap(); + + expect(() => { + readProjectConfigurationsFromRootMap(rootMap); + }).toThrowErrorMatchingInlineSnapshot(` + "The following projects are defined in multiple locations: + - lib: + - apps/lib-a + - apps/lib-b + + To fix this, set a unique name for each project in a project.json inside the project's root. If the project does not currently have a project.json, you can create one that contains only a name." + `); + }); + + it('should read root map into standard projects configurations form', () => { + const rootMap = new RootMapBuilder() + .addProject({ + name: 'lib-a', + root: 'libs/a', + }) + .addProject({ + name: 'lib-b', + root: 'libs/b', + }) + .addProject({ + name: 'lib-shared-b', + root: 'libs/shared/b', + }) + .getRootMap(); + expect(readProjectConfigurationsFromRootMap(rootMap)) + .toMatchInlineSnapshot(` + { + "lib-a": { + "name": "lib-a", + "root": "libs/a", + }, + "lib-b": { + "name": "lib-b", + "root": "libs/b", + }, + "lib-shared-b": { + "name": "lib-shared-b", + "root": "libs/shared/b", + }, + } + `); + }); +}); + +class RootMapBuilder { + private rootMap: Record = {}; + + addProject(p: ProjectConfiguration) { + this.rootMap[p.root] = p; + return this; + } + + getRootMap() { + return this.rootMap; + } +} + +function assertCorrectKeysInSourceMap( + sourceMaps: ConfigurationSourceMaps, + root: string, + ...tuples: [string, string][] +) { + const sourceMap = sourceMaps[root]; + tuples.forEach(([key, value]) => { + if (!sourceMap[key]) { + throw new Error(`Expected sourceMap to contain key ${key}`); + } + try { + expect(sourceMap[key][0]).toEqual(value); + } catch (error) { + // Enhancing the error message with the problematic key + throw new Error(`Assertion failed for key '${key}': \n ${error.message}`); + } + }); +} diff --git a/packages/nx/src/project-graph/utils/project-configuration/project-nodes-manager.ts b/packages/nx/src/project-graph/utils/project-configuration/project-nodes-manager.ts new file mode 100644 index 00000000000..09de1d28721 --- /dev/null +++ b/packages/nx/src/project-graph/utils/project-configuration/project-nodes-manager.ts @@ -0,0 +1,359 @@ +import { ProjectConfiguration } from '../../../config/workspace-json-project-json'; +import { + isProjectWithExistingNameError, + isProjectWithNoNameError, + MultipleProjectsWithSameNameError, + ProjectsWithNoNameError, +} from '../../error-types'; +import { + mergeMetadata, + mergeTargetConfigurations, + resolveCommandSyntacticSugar, +} from './target-merging'; +import { validateProject } from './target-normalization'; +import { ProjectNameInNodePropsManager } from './name-substitution-manager'; +import type { ConfigurationSourceMaps, SourceInformation } from './source-maps'; +import { targetSourceMapKey } from './source-maps'; + +import { minimatch } from 'minimatch'; +import { isGlobPattern } from '../../../utils/globs'; + +export { validateProject } from './target-normalization'; + +export function mergeProjectConfigurationIntoRootMap( + projectRootMap: Record, + project: ProjectConfiguration, + configurationSourceMaps?: ConfigurationSourceMaps, + sourceInformation?: SourceInformation, + // This function is used when reading project configuration + // in generators, where we don't want to do this. + skipTargetNormalization?: boolean +): { + nameChanged: boolean; +} { + project.root = project.root === '' ? '.' : project.root; + if (configurationSourceMaps && !configurationSourceMaps[project.root]) { + configurationSourceMaps[project.root] = {}; + } + const sourceMap = configurationSourceMaps?.[project.root]; + + let matchingProject = projectRootMap[project.root]; + + if (!matchingProject) { + projectRootMap[project.root] = { + root: project.root, + }; + matchingProject = projectRootMap[project.root]; + if (sourceMap) { + sourceMap[`root`] = sourceInformation; + } + } + + // This handles top level properties that are overwritten. + // e.g. `srcRoot`, `projectType`, or other fields that shouldn't be extended + // Note: `name` is set specifically here to keep it from changing. The name is + // always determined by the first inference plugin to ID a project, unless it has + // a project.json in which case it was already updated above. + const updatedProjectConfiguration = { + ...matchingProject, + }; + + for (const k in project) { + if ( + ![ + 'tags', + 'implicitDependencies', + 'generators', + 'targets', + 'metadata', + 'namedInputs', + ].includes(k) + ) { + updatedProjectConfiguration[k] = project[k]; + if (sourceMap) { + sourceMap[`${k}`] = sourceInformation; + } + } + } + + // The next blocks handle properties that should be themselves merged (e.g. targets, tags, and implicit dependencies) + if (project.tags) { + updatedProjectConfiguration.tags = Array.from( + new Set((matchingProject.tags ?? []).concat(project.tags)) + ); + + if (sourceMap) { + sourceMap['tags'] ??= sourceInformation; + project.tags.forEach((tag) => { + sourceMap[`tags.${tag}`] = sourceInformation; + }); + } + } + + if (project.implicitDependencies) { + updatedProjectConfiguration.implicitDependencies = ( + matchingProject.implicitDependencies ?? [] + ).concat(project.implicitDependencies); + + if (sourceMap) { + sourceMap['implicitDependencies'] ??= sourceInformation; + project.implicitDependencies.forEach((implicitDependency) => { + sourceMap[`implicitDependencies.${implicitDependency}`] = + sourceInformation; + }); + } + } + + if (project.generators) { + // Start with generators config in new project. + updatedProjectConfiguration.generators = { ...project.generators }; + + if (sourceMap) { + sourceMap['generators'] ??= sourceInformation; + for (const generator in project.generators) { + sourceMap[`generators.${generator}`] = sourceInformation; + for (const property in project.generators[generator]) { + sourceMap[`generators.${generator}.${property}`] = sourceInformation; + } + } + } + + if (matchingProject.generators) { + // For each generator that was already defined, shallow merge the options. + // Project contains the new info, so it has higher priority. + for (const generator in matchingProject.generators) { + updatedProjectConfiguration.generators[generator] = { + ...matchingProject.generators[generator], + ...project.generators[generator], + }; + } + } + } + + if (project.namedInputs) { + updatedProjectConfiguration.namedInputs = { + ...matchingProject.namedInputs, + ...project.namedInputs, + }; + + if (sourceMap) { + sourceMap['namedInputs'] ??= sourceInformation; + for (const namedInput in project.namedInputs) { + sourceMap[`namedInputs.${namedInput}`] = sourceInformation; + } + } + } + + if (project.metadata) { + updatedProjectConfiguration.metadata = mergeMetadata( + sourceMap, + sourceInformation, + 'metadata', + project.metadata, + matchingProject.metadata + ); + } + + if (project.targets) { + // We merge the targets with special handling, so clear this back to the + // targets as defined originally before merging. + updatedProjectConfiguration.targets = matchingProject?.targets ?? {}; + if (sourceMap) { + sourceMap['targets'] ??= sourceInformation; + } + + // For each target defined in the new config + for (const targetName in project.targets) { + // Always set source map info for the target, but don't overwrite info already there + // if augmenting an existing target. + + const target = project.targets?.[targetName]; + + if (sourceMap) { + sourceMap[targetSourceMapKey(targetName)] = sourceInformation; + } + + const normalizedTarget = skipTargetNormalization + ? target + : resolveCommandSyntacticSugar(target, project.root); + + let matchingTargets = []; + if (isGlobPattern(targetName)) { + // find all targets matching the glob pattern + // this will map atomized targets to the glob pattern same as it does for targetDefaults + matchingTargets = Object.keys( + updatedProjectConfiguration.targets + ).filter((key) => minimatch(key, targetName)); + } + // If no matching targets were found, we can assume that the target name is not (meant to be) a glob pattern + if (!matchingTargets.length) { + matchingTargets = [targetName]; + } + + for (const matchingTargetName of matchingTargets) { + updatedProjectConfiguration.targets[matchingTargetName] = + mergeTargetConfigurations( + normalizedTarget, + matchingProject.targets?.[matchingTargetName], + sourceMap, + sourceInformation, + `targets.${matchingTargetName}` + ); + } + } + } + + projectRootMap[updatedProjectConfiguration.root] = + updatedProjectConfiguration; + + const nameChanged = + !!updatedProjectConfiguration.name && + updatedProjectConfiguration.name !== matchingProject?.name; + + return { nameChanged }; +} + +export function readProjectConfigurationsFromRootMap( + projectRootMap: Record +) { + const projects: Record = {}; + // If there are projects that have the same name, that is an error. + // This object tracks name -> (all roots of projects with that name) + // to provide better error messaging. + const conflicts = new Map(); + const projectRootsWithNoName: string[] = []; + + for (const root in projectRootMap) { + const project = projectRootMap[root]; + // We're setting `// targets` as a comment `targets` is empty due to Project Crystal. + // Strip it before returning configuration for usage. + if (project['// targets']) delete project['// targets']; + + try { + validateProject(project, projects); + projects[project.name] = project; + } catch (e) { + if (isProjectWithNoNameError(e)) { + projectRootsWithNoName.push(e.projectRoot); + } else if (isProjectWithExistingNameError(e)) { + const rootErrors = conflicts.get(e.projectName) ?? [ + projects[e.projectName].root, + ]; + rootErrors.push(e.projectRoot); + conflicts.set(e.projectName, rootErrors); + } else { + throw e; + } + } + } + + if (conflicts.size > 0) { + throw new MultipleProjectsWithSameNameError(conflicts, projects); + } + if (projectRootsWithNoName.length > 0) { + throw new ProjectsWithNoNameError(projectRootsWithNoName, projects); + } + return projects; +} + +export function createRootMap( + projectRootMap: Record +) { + const map: Record = {}; + for (const projectRoot in projectRootMap) { + const projectName = projectRootMap[projectRoot].name; + map[projectRoot] = projectName; + } + return map; +} + +/** + * Owns the rootMap (root → ProjectConfiguration) and nameMap + * (name → ProjectConfiguration), coordinating merges with the + * {@link ProjectNameInNodePropsManager} for deferred name substitutions. + * + * The nameMap entries are the *same object references* as the rootMap + * entries, so when a merge adds targets to a rootMap entry the nameMap + * entry automatically has them too — no copying, no staleness. + */ +export class ProjectNodesManager { + // root → ProjectConfiguration (the merge target) + private rootMap: Record = {}; + // name → ProjectConfiguration (same object references as rootMap) + private nameMap: Record = {}; + private nameSubstitutionManager: ProjectNameInNodePropsManager; + + constructor() { + // Pass a lazy accessor so the substitution manager always sees + // the current nameMap without manual synchronization. + this.nameSubstitutionManager = new ProjectNameInNodePropsManager( + () => this.nameMap + ); + } + + getRootMap(): Record { + return this.rootMap; + } + + /** + * Merges a project into the rootMap, updates the nameMap, and notifies + * the substitution manager if the name changed at this root. + */ + mergeProjectNode( + project: ProjectConfiguration, + configurationSourceMaps?: ConfigurationSourceMaps, + sourceInformation?: SourceInformation + ): void { + const previousName = this.rootMap[project.root]?.name; + + mergeProjectConfigurationIntoRootMap( + this.rootMap, + project, + configurationSourceMaps, + sourceInformation + ); + + const merged = this.rootMap[project.root]; + const currentName = merged?.name; + + if (currentName) { + // Remove old nameMap entry on rename + if (previousName && previousName !== currentName) { + delete this.nameMap[previousName]; + } + // Point nameMap at the same object as rootMap + this.nameMap[currentName] = merged; + + // Notify substitution manager of name change + if (currentName !== previousName) { + this.nameSubstitutionManager.identifyProjectWithRoot( + project.root, + currentName + ); + } + } + } + + /** + * Registers substitutors for a plugin result's project references + * in `inputs` and `dependsOn`. + */ + registerSubstitutors( + pluginResultProjects?: Record< + string, + Omit & Partial + > + ): void { + this.nameSubstitutionManager.registerSubstitutorsForNodeResults( + pluginResultProjects + ); + } + + /** + * Applies all pending name substitutions. Call once after all plugin + * results have been merged. + */ + applySubstitutions(): void { + this.nameSubstitutionManager.applySubstitutions(this.rootMap); + } +} diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts b/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts new file mode 100644 index 00000000000..59f51e60217 --- /dev/null +++ b/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts @@ -0,0 +1,632 @@ +import { TargetConfiguration } from '../../../config/workspace-json-project-json'; +import { + isCompatibleTarget, + mergeTargetConfigurations, + mergeTargetDefaultWithTargetDefinition, + readTargetDefaultsForTarget, +} from './target-merging'; +import type { SourceInformation } from './source-maps'; + +describe('target merging', () => { + const targetDefaults = { + 'nx:run-commands': { + options: { + key: 'default-value-for-executor', + }, + }, + build: { + options: { + key: 'default-value-for-targetname', + }, + }, + 'e2e-ci--*': { + options: { + key: 'default-value-for-e2e-ci', + }, + }, + 'e2e-ci--file-*': { + options: { + key: 'default-value-for-e2e-ci-file', + }, + }, + }; + + it('should prefer executor key', () => { + expect( + readTargetDefaultsForTarget( + 'other-target', + targetDefaults, + 'nx:run-commands' + ).options['key'] + ).toEqual('default-value-for-executor'); + }); + + it('should fallback to target key', () => { + expect( + readTargetDefaultsForTarget('build', targetDefaults, 'other-executor') + .options['key'] + ).toEqual('default-value-for-targetname'); + }); + + it('should return undefined if not found', () => { + expect( + readTargetDefaultsForTarget( + 'other-target', + targetDefaults, + 'other-executor' + ) + ).toBeNull(); + }); + + it('should return longest matching target', () => { + expect( + // This matches both 'e2e-ci--*' and 'e2e-ci--file-*', we expect the first match to be returned. + readTargetDefaultsForTarget('e2e-ci--file-foo', targetDefaults, null) + .options['key'] + ).toEqual('default-value-for-e2e-ci-file'); + }); + + it('should return longest matching target even if executor is passed', () => { + expect( + // This uses an executor which does not have settings in target defaults + // thus the target name pattern target defaults are used + readTargetDefaultsForTarget( + 'e2e-ci--file-foo', + targetDefaults, + 'other-executor' + ).options['key'] + ).toEqual('default-value-for-e2e-ci-file'); + }); + + it('should not merge top level properties for incompatible targets', () => { + expect( + mergeTargetConfigurations( + { + executor: 'target2', + outputs: ['output1'], + }, + { + executor: 'target', + inputs: ['input1'], + } + ) + ).toEqual({ executor: 'target2', outputs: ['output1'] }); + }); + + describe('options', () => { + it('should merge if executor matches', () => { + expect( + mergeTargetConfigurations( + { + executor: 'target', + options: { + a: 'project-value-a', + }, + }, + { + executor: 'target', + options: { + a: 'default-value-a', + b: 'default-value-b', + }, + } + ).options + ).toEqual({ a: 'project-value-a', b: 'default-value-b' }); + }); + + it('should merge if executor is only provided on the project', () => { + expect( + mergeTargetConfigurations( + { + executor: 'target', + options: { + a: 'project-value', + }, + }, + { + options: { + a: 'default-value', + b: 'default-value', + }, + } + ).options + ).toEqual({ a: 'project-value', b: 'default-value' }); + }); + + it('should merge if executor is only provided in the defaults', () => { + expect( + mergeTargetConfigurations( + { + options: { + a: 'project-value', + }, + }, + { + executor: 'target', + options: { + a: 'default-value', + b: 'default-value', + }, + } + ).options + ).toEqual({ a: 'project-value', b: 'default-value' }); + }); + + it('should not merge if executor is different', () => { + expect( + mergeTargetConfigurations( + { + executor: 'other', + options: { + a: 'project-value', + }, + }, + { + executor: 'default-executor', + options: { + b: 'default-value', + }, + } + ).options + ).toEqual({ a: 'project-value' }); + }); + }); + + describe('configurations', () => { + const projectConfigurations: TargetConfiguration['configurations'] = { + dev: { + foo: 'project-value-foo', + }, + prod: { + bar: 'project-value-bar', + }, + }; + + const defaultConfigurations: TargetConfiguration['configurations'] = { + dev: { + foo: 'default-value-foo', + other: 'default-value-other', + }, + baz: { + x: 'default-value-x', + }, + }; + + const merged: TargetConfiguration['configurations'] = { + dev: { + foo: projectConfigurations.dev.foo, + other: defaultConfigurations.dev.other, + }, + prod: { bar: projectConfigurations.prod.bar }, + baz: { x: defaultConfigurations.baz.x }, + }; + + it('should merge configurations if executor matches', () => { + expect( + mergeTargetConfigurations( + { + executor: 'target', + configurations: projectConfigurations, + }, + { + executor: 'target', + configurations: defaultConfigurations, + } + ).configurations + ).toEqual(merged); + }); + + it('should merge if executor is only provided on the project', () => { + expect( + mergeTargetConfigurations( + { + executor: 'target', + configurations: projectConfigurations, + }, + { + configurations: defaultConfigurations, + } + ).configurations + ).toEqual(merged); + }); + + it('should merge if executor is only provided in the defaults', () => { + expect( + mergeTargetConfigurations( + { + configurations: projectConfigurations, + }, + { + executor: 'target', + configurations: defaultConfigurations, + } + ).configurations + ).toEqual(merged); + }); + + it('should not merge if executor doesnt match', () => { + expect( + mergeTargetConfigurations( + { + executor: 'other', + configurations: projectConfigurations, + }, + { + executor: 'target', + configurations: defaultConfigurations, + } + ).configurations + ).toEqual(projectConfigurations); + }); + }); + + describe('defaultConfiguration', () => { + const projectDefaultConfiguration: TargetConfiguration['defaultConfiguration'] = + 'dev'; + const defaultDefaultConfiguration: TargetConfiguration['defaultConfiguration'] = + 'prod'; + + const merged: TargetConfiguration['defaultConfiguration'] = + projectDefaultConfiguration; + + it('should merge defaultConfiguration if executor matches', () => { + expect( + mergeTargetConfigurations( + { + executor: 'target', + defaultConfiguration: projectDefaultConfiguration, + }, + { + executor: 'target', + defaultConfiguration: defaultDefaultConfiguration, + } + ).defaultConfiguration + ).toEqual(merged); + }); + + it('should merge if executor is only provided on the project', () => { + expect( + mergeTargetConfigurations( + { + executor: 'target', + defaultConfiguration: projectDefaultConfiguration, + }, + { + defaultConfiguration: defaultDefaultConfiguration, + } + ).defaultConfiguration + ).toEqual(merged); + }); + + it('should merge if executor is only provided in the defaults', () => { + expect( + mergeTargetConfigurations( + { + defaultConfiguration: projectDefaultConfiguration, + }, + { + executor: 'target', + defaultConfiguration: defaultDefaultConfiguration, + } + ).defaultConfiguration + ).toEqual(merged); + }); + + it('should not merge if executor doesnt match', () => { + expect( + mergeTargetConfigurations( + { + executor: 'other', + defaultConfiguration: projectDefaultConfiguration, + }, + { + executor: 'target', + defaultConfiguration: defaultDefaultConfiguration, + } + ).defaultConfiguration + ).toEqual(projectDefaultConfiguration); + }); + }); + + describe('run-commands', () => { + it('should merge two run-commands targets appropriately', () => { + const merged = mergeTargetConfigurations( + { + outputs: ['{projectRoot}/outputfile.json'], + options: { + command: 'eslint . -o outputfile.json', + }, + }, + { + cache: true, + inputs: [ + 'default', + '{workspaceRoot}/.eslintrc.json', + '{workspaceRoot}/apps/third-app/.eslintrc.json', + '{workspaceRoot}/tools/eslint-rules/**/*', + { externalDependencies: ['eslint'] }, + ], + options: { cwd: 'apps/third-app', command: 'eslint .' }, + executor: 'nx:run-commands', + configurations: {}, + } + ); + expect(merged).toMatchInlineSnapshot(` + { + "cache": true, + "configurations": {}, + "executor": "nx:run-commands", + "inputs": [ + "default", + "{workspaceRoot}/.eslintrc.json", + "{workspaceRoot}/apps/third-app/.eslintrc.json", + "{workspaceRoot}/tools/eslint-rules/**/*", + { + "externalDependencies": [ + "eslint", + ], + }, + ], + "options": { + "command": "eslint . -o outputfile.json", + "cwd": "apps/third-app", + }, + "outputs": [ + "{projectRoot}/outputfile.json", + ], + } + `); + }); + + it('should merge targets when the base uses command syntactic sugar', () => { + const merged = mergeTargetConfigurations( + { + outputs: ['{projectRoot}/outputfile.json'], + options: { + command: 'eslint . -o outputfile.json', + }, + }, + { + cache: true, + inputs: [ + 'default', + '{workspaceRoot}/.eslintrc.json', + '{workspaceRoot}/apps/third-app/.eslintrc.json', + '{workspaceRoot}/tools/eslint-rules/**/*', + { externalDependencies: ['eslint'] }, + ], + options: { cwd: 'apps/third-app' }, + configurations: {}, + command: 'eslint .', + } + ); + expect(merged).toMatchInlineSnapshot(` + { + "cache": true, + "command": "eslint .", + "configurations": {}, + "inputs": [ + "default", + "{workspaceRoot}/.eslintrc.json", + "{workspaceRoot}/apps/third-app/.eslintrc.json", + "{workspaceRoot}/tools/eslint-rules/**/*", + { + "externalDependencies": [ + "eslint", + ], + }, + ], + "options": { + "command": "eslint . -o outputfile.json", + "cwd": "apps/third-app", + }, + "outputs": [ + "{projectRoot}/outputfile.json", + ], + } + `); + }); + }); + + describe('cache', () => { + it('should not be merged for incompatible targets', () => { + const result = mergeTargetConfigurations( + { + executor: 'foo', + }, + { + executor: 'bar', + cache: true, + } + ); + expect(result.cache).not.toBeDefined(); + }); + }); +}); + +describe('isCompatibleTarget', () => { + it('should return true if only one target specifies an executor', () => { + expect( + isCompatibleTarget( + { + executor: 'nx:run-commands', + }, + {} + ) + ).toBe(true); + }); + + it('should return true if both targets specify the same executor', () => { + expect( + isCompatibleTarget( + { + executor: 'nx:run-commands', + }, + { + executor: 'nx:run-commands', + } + ) + ).toBe(true); + }); + + it('should return false if both targets specify different executors', () => { + expect( + isCompatibleTarget( + { + executor: 'nx:run-commands', + }, + { + executor: 'other-executor', + } + ) + ).toBe(false); + }); + + it('should return true if both targets specify the same command', () => { + expect( + isCompatibleTarget( + { + executor: 'nx:run-commands', + options: { + command: 'echo', + }, + }, + { + executor: 'nx:run-commands', + options: { + command: 'echo', + }, + } + ) + ).toBe(true); + }); + + it('should return false if both targets specify different commands', () => { + expect( + isCompatibleTarget( + { + executor: 'nx:run-commands', + options: { + command: 'echo', + }, + }, + { + executor: 'nx:run-commands', + options: { + command: 'echo2', + }, + } + ) + ).toBe(false); + }); + + it('should return false if one target specifies a command, and the other specifies commands', () => { + expect( + isCompatibleTarget( + { + executor: 'nx:run-commands', + options: { + command: 'echo', + }, + }, + { + executor: 'nx:run-commands', + options: { + commands: ['echo', 'other'], + }, + } + ) + ).toBe(false); + }); +}); + +describe('merge target default with target definition', () => { + it('should merge options', () => { + const sourceMap: Record = { + targets: ['dummy', 'dummy.ts'], + 'targets.build': ['dummy', 'dummy.ts'], + 'targets.build.options': ['dummy', 'dummy.ts'], + 'targets.build.options.command': ['dummy', 'dummy.ts'], + 'targets.build.options.cwd': ['project.json', 'nx/project-json'], + }; + const result = mergeTargetDefaultWithTargetDefinition( + 'build', + { + name: 'myapp', + root: 'apps/myapp', + targets: { + build: { + executor: 'nx:run-commands', + options: { + command: 'echo', + cwd: '{workspaceRoot}', + }, + }, + }, + }, + { + options: { + command: 'tsc', + cwd: 'apps/myapp', + }, + }, + sourceMap + ); + + // Command was defined by a non-core plugin so it should be + // overwritten. + expect(result.options.command).toEqual('tsc'); + expect(sourceMap['targets.build.options.command']).toEqual([ + 'nx.json', + 'nx/target-defaults', + ]); + // Cwd was defined by a core plugin so it should be left unchanged. + expect(result.options.cwd).toEqual('{workspaceRoot}'); + expect(sourceMap['targets.build.options.cwd']).toEqual([ + 'project.json', + 'nx/project-json', + ]); + // other source map entries should be left unchanged + expect(sourceMap['targets']).toEqual(['dummy', 'dummy.ts']); + }); + + it('should not overwrite dependsOn', () => { + const sourceMap: Record = { + targets: ['dummy', 'dummy.ts'], + 'targets.build': ['dummy', 'dummy.ts'], + 'targets.build.options': ['dummy', 'dummy.ts'], + 'targets.build.options.command': ['dummy', 'dummy.ts'], + 'targets.build.options.cwd': ['project.json', 'nx/project-json'], + 'targets.build.dependsOn': ['project.json', 'nx/project-json'], + }; + const result = mergeTargetDefaultWithTargetDefinition( + 'build', + { + name: 'myapp', + root: 'apps/myapp', + targets: { + build: { + executor: 'nx:run-commands', + options: { + command: 'echo', + cwd: '{workspaceRoot}', + }, + dependsOn: [], + }, + }, + }, + { + options: { + command: 'tsc', + cwd: 'apps/myapp', + }, + dependsOn: ['^build'], + }, + sourceMap + ); + + // Command was defined by a core plugin so it should + // not be replaced by target default + expect(result.dependsOn).toEqual([]); + }); +}); diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts b/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts new file mode 100644 index 00000000000..d51108307dd --- /dev/null +++ b/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts @@ -0,0 +1,494 @@ +import { NX_PREFIX } from '../../../utils/logger'; +import { isGlobPattern } from '../../../utils/globs'; +import { + ProjectConfiguration, + ProjectMetadata, + TargetConfiguration, + TargetMetadata, +} from '../../../config/workspace-json-project-json'; +import { TargetDefaults } from '../../../config/nx-json'; +import { + recordSourceMapKeysByIndex, + targetConfigurationsSourceMapKey, + targetOptionSourceMapKey, +} from './source-maps'; + +import type { SourceInformation } from './source-maps'; + +import { minimatch } from 'minimatch'; + +export function deepClone(obj: T): T { + return structuredClone(obj); +} + +export function resolveCommandSyntacticSugar( + target: TargetConfiguration, + key: string +): TargetConfiguration { + const { command, ...config } = target ?? {}; + + if (!command) { + return target; + } + + if (config.executor) { + throw new Error( + `${NX_PREFIX} Project at ${key} should not have executor and command both configured.` + ); + } else { + return { + ...config, + executor: 'nx:run-commands', + options: { + ...config.options, + command: command, + }, + }; + } +} + +export function mergeMetadata( + sourceMap: Record, + sourceInformation: [file: string, plugin: string], + baseSourceMapPath: string, + metadata: T, + matchingMetadata?: T +): T { + const result: T = { + ...(matchingMetadata ?? ({} as T)), + }; + for (const [metadataKey, value] of Object.entries(metadata)) { + const existingValue = matchingMetadata?.[metadataKey]; + + if (Array.isArray(value) && Array.isArray(existingValue)) { + const startIndex = result[metadataKey].length; + result[metadataKey].push(...value); + if (sourceMap) { + recordSourceMapKeysByIndex( + sourceMap, + `${baseSourceMapPath}.${metadataKey}`, + result[metadataKey], + sourceInformation, + startIndex + ); + } + } else if (Array.isArray(value) && existingValue === undefined) { + result[metadataKey] ??= value; + if (sourceMap) { + sourceMap[`${baseSourceMapPath}.${metadataKey}`] = sourceInformation; + recordSourceMapKeysByIndex( + sourceMap, + `${baseSourceMapPath}.${metadataKey}`, + value, + sourceInformation + ); + } + } else if (typeof value === 'object' && typeof existingValue === 'object') { + for (const key in value) { + const existingValue = matchingMetadata?.[metadataKey]?.[key]; + + if (Array.isArray(value[key]) && Array.isArray(existingValue)) { + const startIndex = result[metadataKey][key].length; + result[metadataKey][key].push(...value[key]); + if (sourceMap) { + recordSourceMapKeysByIndex( + sourceMap, + `${baseSourceMapPath}.${metadataKey}.${key}`, + result[metadataKey][key], + sourceInformation, + startIndex + ); + } + } else { + result[metadataKey][key] = value[key]; + if (sourceMap) { + sourceMap[`${baseSourceMapPath}.${metadataKey}`] = + sourceInformation; + } + } + } + } else { + result[metadataKey] = value; + if (sourceMap) { + sourceMap[`${baseSourceMapPath}.${metadataKey}`] = sourceInformation; + + if (typeof value === 'object') { + for (const k in value) { + sourceMap[`${baseSourceMapPath}.${metadataKey}.${k}`] = + sourceInformation; + if (Array.isArray(value[k])) { + recordSourceMapKeysByIndex( + sourceMap, + `${baseSourceMapPath}.${metadataKey}.${k}`, + value[k], + sourceInformation + ); + } + } + } + } + } + } + return result; +} + +function mergeOptions( + newOptions: Record | undefined, + baseOptions: Record | undefined, + projectConfigSourceMap?: Record, + sourceInformation?: SourceInformation, + targetIdentifier?: string +): Record | undefined { + const mergedOptions = { + ...(baseOptions ?? {}), + ...(newOptions ?? {}), + }; + + // record new options & option properties in source map + if (projectConfigSourceMap) { + for (const newOption in newOptions) { + projectConfigSourceMap[`${targetIdentifier}.options.${newOption}`] = + sourceInformation; + } + } + + return mergedOptions; +} + +function mergeConfigurations( + newConfigurations: Record | undefined, + baseConfigurations: Record | undefined, + projectConfigSourceMap?: Record, + sourceInformation?: SourceInformation, + targetIdentifier?: string +): Record | undefined { + const mergedConfigurations = {}; + + const configurations = new Set([ + ...Object.keys(baseConfigurations ?? {}), + ...Object.keys(newConfigurations ?? {}), + ]); + for (const configuration of configurations) { + mergedConfigurations[configuration] = { + ...(baseConfigurations?.[configuration] ?? {}), + ...(newConfigurations?.[configuration] ?? {}), + }; + } + + // record new configurations & configuration properties in source map + if (projectConfigSourceMap) { + for (const newConfiguration in newConfigurations) { + projectConfigSourceMap[ + `${targetIdentifier}.configurations.${newConfiguration}` + ] = sourceInformation; + for (const configurationProperty in newConfigurations[newConfiguration]) { + projectConfigSourceMap[ + `${targetIdentifier}.configurations.${newConfiguration}.${configurationProperty}` + ] = sourceInformation; + } + } + } + + return mergedConfigurations; +} + +/** + * Merges two targets. + * + * Most properties from `target` will overwrite any properties from `baseTarget`. + * Options and configurations are treated differently - they are merged together if the executor definition is compatible. + * + * @param target The target definition with higher priority + * @param baseTarget The target definition that should be overwritten. Can be undefined, in which case the target is returned as-is. + * @param projectConfigSourceMap The source map to be filled with metadata about where each property came from + * @param sourceInformation The metadata about where the new target was defined + * @param targetIdentifier The identifier for the target to merge, used for source map + * @returns A merged target configuration + */ +export function mergeTargetConfigurations( + target: TargetConfiguration, + baseTarget?: TargetConfiguration, + projectConfigSourceMap?: Record, + sourceInformation?: SourceInformation, + targetIdentifier?: string +): TargetConfiguration { + const { + configurations: defaultConfigurations, + options: defaultOptions, + ...baseTargetProperties + } = baseTarget ?? {}; + + // Target is "compatible", e.g. executor is defined only once or is the same + // in both places. This means that it is likely safe to merge + const isCompatible = isCompatibleTarget(baseTarget ?? {}, target); + + if (!isCompatible && projectConfigSourceMap) { + // if the target is not compatible, we will simply override the options + // we have to delete old entries from the source map + for (const key in projectConfigSourceMap) { + if (key.startsWith(`${targetIdentifier}`)) { + delete projectConfigSourceMap[key]; + } + } + } + + // merge top level properties if they're compatible + const result = { + ...(isCompatible ? baseTargetProperties : {}), + ...target, + }; + + // record top level properties in source map + if (projectConfigSourceMap) { + projectConfigSourceMap[targetIdentifier] = sourceInformation; + + // record root level target properties to source map + for (const targetProperty in target) { + const targetPropertyId = `${targetIdentifier}.${targetProperty}`; + projectConfigSourceMap[targetPropertyId] = sourceInformation; + } + } + + // merge options if there are any + // if the targets aren't compatible, we simply discard the old options during the merge + if (target.options || defaultOptions) { + result.options = mergeOptions( + target.options, + isCompatible ? defaultOptions : undefined, + projectConfigSourceMap, + sourceInformation, + targetIdentifier + ); + } + + // merge configurations if there are any + // if the targets aren't compatible, we simply discard the old configurations during the merge + if (target.configurations || defaultConfigurations) { + result.configurations = mergeConfigurations( + target.configurations, + isCompatible ? defaultConfigurations : undefined, + projectConfigSourceMap, + sourceInformation, + targetIdentifier + ); + } + + if (target.metadata) { + result.metadata = mergeMetadata( + projectConfigSourceMap, + sourceInformation, + `${targetIdentifier}.metadata`, + target.metadata, + baseTarget?.metadata + ); + } + + return result as TargetConfiguration; +} + +/** + * Checks if targets options are compatible - used when merging configurations + * to avoid merging options for @nx/js:tsc into something like @nx/webpack:webpack. + * + * If the executors are both specified and don't match, the options aren't considered + * "compatible" and shouldn't be merged. + */ +export function isCompatibleTarget( + a: TargetConfiguration, + b: TargetConfiguration +) { + const oneHasNoExecutor = !a.executor || !b.executor; + const bothHaveSameExecutor = a.executor === b.executor; + + if (oneHasNoExecutor) return true; + if (!bothHaveSameExecutor) return false; + + const isRunCommands = a.executor === 'nx:run-commands'; + if (isRunCommands) { + const aCommand = a.options?.command ?? a.options?.commands?.join(' && '); + const bCommand = b.options?.command ?? b.options?.commands?.join(' && '); + + const oneHasNoCommand = !aCommand || !bCommand; + const hasSameCommand = aCommand === bCommand; + + return oneHasNoCommand || hasSameCommand; + } + + const isRunScript = a.executor === 'nx:run-script'; + if (isRunScript) { + const aScript = a.options?.script; + const bScript = b.options?.script; + + const oneHasNoScript = !aScript || !bScript; + const hasSameScript = aScript === bScript; + + return oneHasNoScript || hasSameScript; + } + + return true; +} + +function targetDefaultShouldBeApplied( + key: string, + sourceMap: Record +) { + const sourceInfo = sourceMap[key]; + if (!sourceInfo) { + return true; + } + // The defined value of the target is from a plugin that + // isn't part of Nx's core plugins, so target defaults are + // applied on top of it. + const [, plugin] = sourceInfo; + return !plugin?.startsWith('nx/'); +} + +export function mergeTargetDefaultWithTargetDefinition( + targetName: string, + project: ProjectConfiguration, + targetDefault: Partial, + sourceMap: Record +): TargetConfiguration { + const targetDefinition = project.targets[targetName] ?? {}; + const result = deepClone(targetDefinition); + + for (const key in targetDefault) { + switch (key) { + case 'options': { + const normalizedDefaults = resolveNxTokensInOptions( + targetDefault.options, + project, + targetName + ); + for (const optionKey in normalizedDefaults) { + const sourceMapKey = targetOptionSourceMapKey(targetName, optionKey); + if ( + targetDefinition.options[optionKey] === undefined || + targetDefaultShouldBeApplied(sourceMapKey, sourceMap) + ) { + result.options[optionKey] = targetDefault.options[optionKey]; + sourceMap[sourceMapKey] = ['nx.json', 'nx/target-defaults']; + } + } + break; + } + case 'configurations': { + if (!result.configurations) { + result.configurations = {}; + sourceMap[targetConfigurationsSourceMapKey(targetName)] = [ + 'nx.json', + 'nx/target-defaults', + ]; + } + for (const configuration in targetDefault.configurations) { + if (!result.configurations[configuration]) { + result.configurations[configuration] = {}; + sourceMap[ + targetConfigurationsSourceMapKey(targetName, configuration) + ] = ['nx.json', 'nx/target-defaults']; + } + const normalizedConfigurationDefaults = resolveNxTokensInOptions( + targetDefault.configurations[configuration], + project, + targetName + ); + for (const configurationKey in normalizedConfigurationDefaults) { + const sourceMapKey = targetConfigurationsSourceMapKey( + targetName, + configuration, + configurationKey + ); + if ( + targetDefinition.configurations?.[configuration]?.[ + configurationKey + ] === undefined || + targetDefaultShouldBeApplied(sourceMapKey, sourceMap) + ) { + result.configurations[configuration][configurationKey] = + targetDefault.configurations[configuration][configurationKey]; + sourceMap[sourceMapKey] = ['nx.json', 'nx/target-defaults']; + } + } + } + break; + } + default: { + const sourceMapKey = `targets.${targetName}.${key}`; + if ( + targetDefinition[key] === undefined || + targetDefaultShouldBeApplied(sourceMapKey, sourceMap) + ) { + result[key] = targetDefault[key]; + sourceMap[sourceMapKey] = ['nx.json', 'nx/target-defaults']; + } + break; + } + } + } + return result; +} + +export function resolveNxTokensInOptions>( + object: T, + project: ProjectConfiguration, + key: string +): T { + const result: T = Array.isArray(object) ? ([...object] as T) : { ...object }; + for (let [opt, value] of Object.entries(object ?? {})) { + if (typeof value === 'string') { + const workspaceRootMatch = /^(\{workspaceRoot\}\/?)/.exec(value); + if (workspaceRootMatch?.length) { + value = value.replace(workspaceRootMatch[0], ''); + } + if (value.includes('{workspaceRoot}')) { + throw new Error( + `${NX_PREFIX} The {workspaceRoot} token is only valid at the beginning of an option. (${key})` + ); + } + value = value.replace(/\{projectRoot\}/g, project.root); + result[opt] = value.replace(/\{projectName\}/g, project.name); + } else if (typeof value === 'object' && value) { + result[opt] = resolveNxTokensInOptions( + value, + project, + [key, opt].join('.') + ); + } + } + return result; +} + +export function readTargetDefaultsForTarget( + targetName: string, + targetDefaults: TargetDefaults, + executor?: string +): TargetDefaults[string] { + if (executor && targetDefaults?.[executor]) { + // If an executor is defined in project.json, defaults should be read + // from the most specific key that matches that executor. + // e.g. If executor === run-commands, and the target is named build: + // Use, use nx:run-commands if it is present + // If not, use build if it is present. + return targetDefaults?.[executor]; + } else if (targetDefaults?.[targetName]) { + // If the executor is not defined, the only key we have is the target name. + return targetDefaults?.[targetName]; + } + + let matchingTargetDefaultKey: string | null = null; + for (const key in targetDefaults ?? {}) { + if (isGlobPattern(key) && minimatch(targetName, key)) { + if ( + !matchingTargetDefaultKey || + matchingTargetDefaultKey.length < key.length + ) { + matchingTargetDefaultKey = key; + } + } + } + if (matchingTargetDefaultKey) { + return targetDefaults[matchingTargetDefaultKey]; + } + + return null; +} diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-normalization.spec.ts b/packages/nx/src/project-graph/utils/project-configuration/target-normalization.spec.ts new file mode 100644 index 00000000000..1a69f6b451e --- /dev/null +++ b/packages/nx/src/project-graph/utils/project-configuration/target-normalization.spec.ts @@ -0,0 +1,60 @@ +import { workspaceRoot } from '../../../utils/workspace-root'; +import { normalizeTarget } from './target-normalization'; + +describe('normalizeTarget', () => { + it('should support {projectRoot}, {workspaceRoot}, and {projectName} tokens', () => { + const config = { + name: 'project', + root: 'libs/project', + targets: { + foo: { command: 'echo {projectRoot}' }, + }, + }; + expect(normalizeTarget(config.targets.foo, config, workspaceRoot, {}, '')) + .toMatchInlineSnapshot(` + { + "configurations": {}, + "executor": "nx:run-commands", + "options": { + "command": "echo libs/project", + }, + "parallelism": true, + } + `); + }); + it('should not mutate the target', () => { + const config = { + name: 'project', + root: 'libs/project', + targets: { + foo: { + executor: 'nx:noop', + options: { + config: '{projectRoot}/config.json', + }, + configurations: { + prod: { + config: '{projectRoot}/config.json', + }, + }, + }, + bar: { + command: 'echo {projectRoot}', + options: { + config: '{projectRoot}/config.json', + }, + configurations: { + prod: { + config: '{projectRoot}/config.json', + }, + }, + }, + }, + }; + const originalConfig = JSON.stringify(config, null, 2); + + normalizeTarget(config.targets.foo, config, workspaceRoot, {}, ''); + normalizeTarget(config.targets.bar, config, workspaceRoot, {}, ''); + expect(JSON.stringify(config, null, 2)).toEqual(originalConfig); + }); +}); diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-normalization.ts b/packages/nx/src/project-graph/utils/project-configuration/target-normalization.ts new file mode 100644 index 00000000000..9c58adaed73 --- /dev/null +++ b/packages/nx/src/project-graph/utils/project-configuration/target-normalization.ts @@ -0,0 +1,282 @@ +import { NxJsonConfiguration } from '../../../config/nx-json'; +import { + ProjectConfiguration, + TargetConfiguration, +} from '../../../config/workspace-json-project-json'; +import { + getExecutorInformation, + parseExecutor, +} from '../../../command-line/run/executor-utils'; +import { readJsonFile } from '../../../utils/fileutils'; +import { toProjectName } from '../../../config/to-project-name'; +import { + isProjectWithExistingNameError, + isProjectWithNoNameError, + MultipleProjectsWithSameNameError, + ProjectsWithNoNameError, + ProjectWithExistingNameError, + ProjectWithNoNameError, + WorkspaceValidityError, +} from '../../error-types'; +import { + resolveCommandSyntacticSugar, + resolveNxTokensInOptions, + readTargetDefaultsForTarget, + isCompatibleTarget, + mergeTargetDefaultWithTargetDefinition, + deepClone, +} from './target-merging'; + +import type { ConfigurationSourceMaps } from './source-maps'; + +import { existsSync } from 'node:fs'; +import { join } from 'path'; + +export function validateProject( + project: ProjectConfiguration, + // name -> project + knownProjects: Record +) { + if (!project.name) { + try { + const { name } = readJsonFile(join(project.root, 'package.json')); + if (!name) { + throw new Error(`Project at ${project.root} has no name provided.`); + } + project.name = name; + } catch { + throw new ProjectWithNoNameError(project.root); + } + } else if ( + knownProjects[project.name] && + knownProjects[project.name].root !== project.root + ) { + throw new ProjectWithExistingNameError(project.name, project.root); + } +} + +/** + * Expand's `command` syntactic sugar, replaces tokens in options, and adds information from executor schema. + * @param target The target to normalize + * @param project The project that the target belongs to + * @returns The normalized target configuration + */ +export function normalizeTarget( + target: TargetConfiguration, + project: ProjectConfiguration, + workspaceRoot: string, + projectsMap: Record, + errorMsgKey: string +) { + target = { + ...target, + configurations: { + ...target.configurations, + }, + }; + + target = resolveCommandSyntacticSugar(target, project.root); + + target.options = resolveNxTokensInOptions( + target.options, + project, + errorMsgKey + ); + + for (const configuration in target.configurations) { + target.configurations[configuration] = resolveNxTokensInOptions( + target.configurations[configuration], + project, + `${project.root}:${target}:${configuration}` + ); + } + + target.parallelism ??= true; + + if (target.executor && !('continuous' in target)) { + try { + const [executorNodeModule, executorName] = parseExecutor(target.executor); + + const { schema } = getExecutorInformation( + executorNodeModule, + executorName, + workspaceRoot, + projectsMap + ); + + if (schema.continuous) { + target.continuous ??= schema.continuous; + } + } catch (e) { + // If the executor is not found, we assume that it is not a valid executor. + // This means that we should not set the continuous property. + // We could throw an error here, but it would be better to just ignore it. + } + } + + return target; +} + +function normalizeTargets( + project: ProjectConfiguration, + sourceMaps: ConfigurationSourceMaps, + nxJsonConfiguration: NxJsonConfiguration, + workspaceRoot: string, + /** + * Project configurations keyed by project name + */ + projects: Record +) { + const targetErrorMessage: string[] = []; + + for (const targetName in project.targets) { + project.targets[targetName] = normalizeTarget( + project.targets[targetName], + project, + workspaceRoot, + projects, + [project.root, targetName].join(':') + ); + + const projectSourceMaps = sourceMaps[project.root]; + + const targetConfig = project.targets[targetName]; + const targetDefaults = deepClone( + readTargetDefaultsForTarget( + targetName, + nxJsonConfiguration.targetDefaults, + targetConfig.executor + ) + ); + + // We only apply defaults if they exist + if (targetDefaults && isCompatibleTarget(targetConfig, targetDefaults)) { + project.targets[targetName] = mergeTargetDefaultWithTargetDefinition( + targetName, + project, + normalizeTarget( + targetDefaults, + project, + workspaceRoot, + projects, + ['nx.json[targetDefaults]', targetName].join(':') + ), + projectSourceMaps + ); + } + + const target = project.targets[targetName]; + + if ( + // If the target has no executor or command, it doesn't do anything + !target.executor && + !target.command + ) { + // But it may have dependencies that do something + if (target.dependsOn && target.dependsOn.length > 0) { + target.executor = 'nx:noop'; + } else { + // If it does nothing, and has no depenencies, + // we can remove it. + delete project.targets[targetName]; + } + } + + if (target.cache && target.continuous) { + targetErrorMessage.push( + `- "${targetName}" has both "cache" and "continuous" set to true. Continuous targets cannot be cached. Please remove the "cache" property.` + ); + } + } + if (targetErrorMessage.length > 0) { + targetErrorMessage.unshift( + `Errors detected in targets of project "${project.name}":` + ); + throw new WorkspaceValidityError(targetErrorMessage.join('\n')); + } +} + +export function validateAndNormalizeProjectRootMap( + workspaceRoot: string, + projectRootMap: Record, + nxJsonConfiguration: NxJsonConfiguration, + sourceMaps: ConfigurationSourceMaps = {} +) { + // Name -> Project, used to validate that all projects have unique names + const projects: Record = {}; + // If there are projects that have the same name, that is an error. + // This object tracks name -> (all roots of projects with that name) + // to provide better error messaging. + const conflicts = new Map(); + const projectRootsWithNoName: string[] = []; + const validityErrors: WorkspaceValidityError[] = []; + + for (const root in projectRootMap) { + const project = projectRootMap[root]; + // We're setting `// targets` as a comment `targets` is empty due to Project Crystal. + // Strip it before returning configuration for usage. + if (project['// targets']) delete project['// targets']; + + // We initially did this in the project.json plugin, but + // that resulted in project.json files without names causing + // the resulting project to change names from earlier plugins... + if ( + !project.name && + existsSync(join(workspaceRoot, project.root, 'project.json')) + ) { + project.name = toProjectName(join(root, 'project.json')); + } + + try { + validateProject(project, projects); + projects[project.name] = project; + } catch (e) { + if (isProjectWithNoNameError(e)) { + projectRootsWithNoName.push(e.projectRoot); + } else if (isProjectWithExistingNameError(e)) { + const rootErrors = conflicts.get(e.projectName) ?? [ + projects[e.projectName].root, + ]; + rootErrors.push(e.projectRoot); + conflicts.set(e.projectName, rootErrors); + } else { + throw e; + } + } + } + + for (const root in projectRootMap) { + const project = projectRootMap[root]; + try { + normalizeTargets( + project, + sourceMaps, + nxJsonConfiguration, + workspaceRoot, + projects + ); + } catch (e) { + if (e instanceof WorkspaceValidityError) { + validityErrors.push(e); + } else { + throw e; + } + } + } + + const errors: Error[] = []; + + if (conflicts.size > 0) { + errors.push(new MultipleProjectsWithSameNameError(conflicts, projects)); + } + if (projectRootsWithNoName.length > 0) { + errors.push(new ProjectsWithNoNameError(projectRootsWithNoName, projects)); + } + if (validityErrors.length > 0) { + errors.push(...validityErrors); + } + if (errors.length > 0) { + throw new AggregateError(errors); + } + return projectRootMap; +} diff --git a/packages/nx/src/tasks-runner/utils.ts b/packages/nx/src/tasks-runner/utils.ts index 0b83a76e406..763bc6c4f65 100644 --- a/packages/nx/src/tasks-runner/utils.ts +++ b/packages/nx/src/tasks-runner/utils.ts @@ -22,7 +22,7 @@ import { findMatchingProjects } from '../utils/find-matching-projects'; import { isGlobPattern } from '../utils/globs'; import { joinPathFragments } from '../utils/path'; import { serializeOverridesIntoCommandLine } from '../utils/serialize-overrides-into-command-line'; -import { splitTarget } from '../utils/split-target'; +import { splitTargetFromNodes } from '../utils/split-target'; import { workspaceRoot } from '../utils/workspace-root'; import { isTuiEnabled } from './is-tui-enabled'; @@ -60,7 +60,7 @@ export function normalizeDependencyConfigDefinition( ): NormalizedTargetDependencyConfig[] { return expandWildcardTargetConfiguration( normalizeDependencyConfigProjects( - expandDependencyConfigSyntaxSugar(definition, graph), + expandDependencyConfigSyntaxSugar(definition, graph, currentProject), currentProject, graph ), @@ -89,7 +89,8 @@ export function normalizeDependencyConfigProjects( export function expandDependencyConfigSyntaxSugar( dependencyConfigString: string | TargetDependencyConfig, - graph: ProjectGraph + graph: ProjectGraph, + currentProject?: string ): TargetDependencyConfig { if (typeof dependencyConfigString !== 'string') { return dependencyConfigString; @@ -110,7 +111,8 @@ export function expandDependencyConfigSyntaxSugar( const { projects, target } = readProjectAndTargetFromTargetString( targetString, - graph.nodes + graph.nodes, + currentProject ); return projects ? { projects, target } : { target }; @@ -163,12 +165,15 @@ export function expandWildcardTargetConfiguration( export function readProjectAndTargetFromTargetString( targetString: string, - projects: Record + projects: Record, + currentProject?: string ): { projects?: string[]; target: string } { // Support for both `project:target` and `target:with:colons` syntax - const [maybeProject, ...segments] = splitTarget(targetString, { - nodes: projects, - } as ProjectGraph); + const [maybeProject, ...segments] = splitTargetFromNodes( + targetString, + projects, + { silent: true, currentProject } + ); if (!segments.length) { // if no additional segments are provided, then the string references diff --git a/packages/nx/src/utils/package-json.ts b/packages/nx/src/utils/package-json.ts index f0baa569783..dbb7b3e1a1c 100644 --- a/packages/nx/src/utils/package-json.ts +++ b/packages/nx/src/utils/package-json.ts @@ -13,7 +13,7 @@ import { } from '../config/workspace-json-project-json'; import type { Tree } from '../generators/tree'; import { readJson } from '../generators/utils/json'; -import { mergeTargetConfigurations } from '../project-graph/utils/project-configuration-utils'; +import { mergeTargetConfigurations } from '../project-graph/utils/project-configuration/target-merging'; import { getCatalogManager } from './catalog'; import { readJsonFile } from './fileutils'; import { getNxRequirePaths } from './installation-directory'; diff --git a/packages/nx/src/utils/split-target.spec.ts b/packages/nx/src/utils/split-target.spec.ts index 57b8c05ef23..9a9b24518e4 100644 --- a/packages/nx/src/utils/split-target.spec.ts +++ b/packages/nx/src/utils/split-target.spec.ts @@ -2,6 +2,14 @@ import { ProjectGraph } from '../config/project-graph'; import { ProjectGraphBuilder } from '../project-graph/project-graph-builder'; import { splitTarget } from './split-target'; +jest.mock('./output', () => ({ + output: { + warn: jest.fn(), + }, +})); + +const { output } = require('./output'); + let projectGraph: ProjectGraph; describe('splitTarget', () => { @@ -102,4 +110,318 @@ describe('splitTarget', () => { 'dev', ]); }); + + it('should support multi-colon project names when target is not in the graph', () => { + // :utils:common is in the graph but does NOT have a 'build' target. + // findAllMatchingSegments can't match, so the fallback path must + // correctly reconstruct the multi-colon project name. + expect(splitTarget(':utils:common:build', projectGraph)).toEqual([ + ':utils:common', + 'build', + ]); + }); + + it('should support triple-colon project names when target is not in the graph', () => { + // :utils:common:test is in the graph but does NOT have a 'build' target. + expect(splitTarget(':utils:common:test:build', projectGraph)).toEqual([ + ':utils:common:test', + 'build', + ]); + }); + + it('should support multi-colon project names with unknown target and configuration', () => { + // :utils:common is in the graph, 'build' is not a known target, + // 'prod' trails as configuration. + expect(splitTarget(':utils:common:build:prod', projectGraph)).toEqual([ + ':utils:common', + 'build', + 'prod', + ]); + }); +}); + +describe('ambiguous target resolution', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Scenario 1: a:b:c — project "a" has targets "b:c" and "b" (no configs) + // Only [a, b:c] is valid because "b" has no config "c". + it('should prefer colon-bearing target when config does not exist on shorter target', () => { + const builder = new ProjectGraphBuilder(); + builder.addNode({ + name: 'a', + data: { + root: 'libs/a', + targets: { + 'b:c': {}, + b: {}, + }, + }, + type: 'lib', + }); + const graph = builder.getUpdatedProjectGraph(); + + expect(splitTarget('a:b:c', graph)).toEqual(['a', 'b:c']); + expect(output.warn).not.toHaveBeenCalled(); + }); + + // Scenario 2: a:b:c — project "a" has targets "b:c" and "b" (with config "c") + // Both [a, b:c] and [a, b, c] are valid → ambiguous, warn + it('should warn when both colon-bearing target and target+config are valid', () => { + const builder = new ProjectGraphBuilder(); + builder.addNode({ + name: 'a', + data: { + root: 'libs/a', + targets: { + 'b:c': {}, + b: { + configurations: { + c: {}, + }, + }, + }, + }, + type: 'lib', + }); + const graph = builder.getUpdatedProjectGraph(); + + // Most specific target name wins: "b:c" > "b" + expect(splitTarget('a:b:c', graph)).toEqual(['a', 'b:c']); + expect(output.warn).toHaveBeenCalledTimes(1); + expect(output.warn).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Ambiguous target specifier "a:b:c"', + }) + ); + }); + + // Scenario 3: a:b:c — projects "a" (no targets) and "a:b" (target "c") + // Only [a:b, c] is valid. + it('should resolve to most specific project when only one interpretation is valid', () => { + const builder = new ProjectGraphBuilder(); + builder.addNode({ + name: 'a', + data: { + root: 'libs/a', + targets: {}, + }, + type: 'lib', + }); + builder.addNode({ + name: 'a:b', + data: { + root: 'libs/ab', + targets: { + c: {}, + }, + }, + type: 'lib', + }); + const graph = builder.getUpdatedProjectGraph(); + + expect(splitTarget('a:b:c', graph)).toEqual(['a:b', 'c']); + expect(output.warn).not.toHaveBeenCalled(); + }); + + // Scenario 4: a:b:c — projects "a" (target "b:c") and "a:b" (no targets) + // Only [a, b:c] is valid. + it('should resolve to shorter project when longer project has no matching target', () => { + const builder = new ProjectGraphBuilder(); + builder.addNode({ + name: 'a', + data: { + root: 'libs/a', + targets: { + 'b:c': {}, + }, + }, + type: 'lib', + }); + builder.addNode({ + name: 'a:b', + data: { + root: 'libs/ab', + targets: {}, + }, + type: 'lib', + }); + const graph = builder.getUpdatedProjectGraph(); + + expect(splitTarget('a:b:c', graph)).toEqual(['a', 'b:c']); + expect(output.warn).not.toHaveBeenCalled(); + }); + + // Scenario 5: a:b:c — projects "a" (target "b" with config "c") and "a:b" (target "c") + // Both [a, b, c] and [a:b, c] are valid → ambiguous, warn + // Most specific project wins: "a:b" > "a" + it('should warn and prefer most specific project when both interpretations are valid', () => { + const builder = new ProjectGraphBuilder(); + builder.addNode({ + name: 'a', + data: { + root: 'libs/a', + targets: { + b: { + configurations: { + c: {}, + }, + }, + }, + }, + type: 'lib', + }); + builder.addNode({ + name: 'a:b', + data: { + root: 'libs/ab', + targets: { + c: {}, + }, + }, + type: 'lib', + }); + const graph = builder.getUpdatedProjectGraph(); + + expect(splitTarget('a:b:c', graph)).toEqual(['a:b', 'c']); + expect(output.warn).toHaveBeenCalledTimes(1); + expect(output.warn).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Ambiguous target specifier "a:b:c"', + }) + ); + }); + + it('should suppress warning when silent option is true', () => { + const builder = new ProjectGraphBuilder(); + builder.addNode({ + name: 'a', + data: { + root: 'libs/a', + targets: { + 'b:c': {}, + b: { + configurations: { + c: {}, + }, + }, + }, + }, + type: 'lib', + }); + const graph = builder.getUpdatedProjectGraph(); + + expect(splitTarget('a:b:c', graph, { silent: true })).toEqual(['a', 'b:c']); + expect(output.warn).not.toHaveBeenCalled(); + }); + + it('should prefer bare target match when currentProject is provided', () => { + const builder = new ProjectGraphBuilder(); + builder.addNode({ + name: 'myapp', + data: { + root: 'apps/myapp', + targets: { + 'build:production': {}, + }, + }, + type: 'app', + }); + builder.addNode({ + name: 'build', + data: { + root: 'libs/build', + targets: { + production: {}, + }, + }, + type: 'lib', + }); + const graph = builder.getUpdatedProjectGraph(); + + // With currentProject, bare target "build:production" on myapp wins + expect( + splitTarget('build:production', graph, { currentProject: 'myapp' }) + ).toEqual(['myapp', 'build:production']); + expect(output.warn).toHaveBeenCalledTimes(1); + + output.warn.mockClear(); + + // Without currentProject, project "build" with target "production" is the only match + expect(splitTarget('build:production', graph)).toEqual([ + 'build', + 'production', + ]); + expect(output.warn).not.toHaveBeenCalled(); + }); + + it('should prefer bare target with config over project-based match', () => { + const builder = new ProjectGraphBuilder(); + builder.addNode({ + name: 'myapp', + data: { + root: 'apps/myapp', + targets: { + build: { + configurations: { + production: {}, + }, + }, + }, + }, + type: 'app', + }); + builder.addNode({ + name: 'build', + data: { + root: 'libs/build', + targets: { + production: {}, + }, + }, + type: 'lib', + }); + const graph = builder.getUpdatedProjectGraph(); + + // With currentProject, bare target "build" config "production" on myapp wins + expect( + splitTarget('build:production', graph, { currentProject: 'myapp' }) + ).toEqual(['myapp', 'build', 'production']); + expect(output.warn).toHaveBeenCalledTimes(1); + }); + + it('should handle leading-colon project names with ambiguity', () => { + const builder = new ProjectGraphBuilder(); + builder.addNode({ + name: ':a', + data: { + root: 'libs/a', + targets: { + b: { + configurations: { + c: {}, + }, + }, + }, + }, + type: 'lib', + }); + builder.addNode({ + name: ':a:b', + data: { + root: 'libs/ab', + targets: { + c: {}, + }, + }, + type: 'lib', + }); + const graph = builder.getUpdatedProjectGraph(); + + // Both [:a, b, c] and [:a:b, c] are valid → ambiguous + // Most specific project ":a:b" wins + expect(splitTarget(':a:b:c', graph)).toEqual([':a:b', 'c']); + expect(output.warn).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/nx/src/utils/split-target.ts b/packages/nx/src/utils/split-target.ts index 75079c7e888..bd76f178aa9 100644 --- a/packages/nx/src/utils/split-target.ts +++ b/packages/nx/src/utils/split-target.ts @@ -1,73 +1,277 @@ import { ProjectGraph, ProjectGraphProjectNode } from '../config/project-graph'; +import { ProjectConfiguration } from '../config/workspace-json-project-json'; +import { output } from '../utils/output'; -function findMatchingSegments( - s: string, +export interface SplitTargetOptions { + silent?: boolean; + currentProject?: string; +} + +type TargetTuple = [string, string?, string?]; + +// Internal: generic project lookup for segment matching. +// Abstracts the difference between ProjectGraphProjectNode records +// (used by the public graph API) and plain ProjectConfiguration records +// (used during the merge phase). +interface ProjectLookup { + has(name: string): boolean; + getTargets(name: string): Record | undefined; +} + +function nodeLookup( nodes: Record -): [string, string?, string?] | undefined { - const projectNames = Object.keys(nodes); - // return project if matching - if (projectNames.includes(s)) { - return [s]; - } - if (!s.includes(':')) { - return; +): ProjectLookup { + return { + has: (name) => !!nodes[name], + getTargets: (name) => nodes[name]?.data?.targets, + }; +} + +function configLookup( + configs: Record +): ProjectLookup { + return { + has: (name) => !!configs[name], + getTargets: (name) => configs[name]?.targets, + }; +} + +/** + * Collects all valid [project, target?, config?] interpretations of a + * colon-delimited string by iterating over the *segments* of the string + * (O(k²) where k = number of segments) rather than over every project in the + * graph. + * + * When `currentProject` is provided, bare-target interpretations (the string + * is `target` or `target:config` on that project) are also collected. + */ +function findAllMatchingSegments( + segments: string[], + lookup: ProjectLookup, + currentProject?: string +): TargetTuple[] { + const matches: TargetTuple[] = []; + + // --- Bare-target matches (currentProject context) --- + if (currentProject && lookup.has(currentProject)) { + const targets = lookup.getTargets(currentProject) || {}; + for (let j = 1; j <= segments.length; j++) { + const candidateTarget = segments.slice(0, j).join(':'); + if (!(candidateTarget in targets)) { + continue; + } + const configSegments = segments.slice(j); + if (configSegments.length === 0) { + matches.push([currentProject, candidateTarget]); + } else { + const candidateConfig = configSegments.join(':'); + const configurations = targets[candidateTarget]?.configurations; + if (configurations && candidateConfig in configurations) { + matches.push([currentProject, candidateTarget, candidateConfig]); + } + } + } } - for (const projectName of projectNames) { - for (const [targetName, targetConfig] of Object.entries( - nodes[projectName].data.targets || {} - )) { - if (s === `${projectName}:${targetName}`) { - return [projectName, targetName]; + + // --- Project-based matches --- + for (let i = 1; i <= segments.length; i++) { + const candidateProject = segments.slice(0, i).join(':'); + if (!lookup.has(candidateProject)) { + continue; + } + + const remaining = segments.slice(i); + if (remaining.length === 0) { + matches.push([candidateProject]); + continue; + } + + const targets = lookup.getTargets(candidateProject) || {}; + for (let j = 1; j <= remaining.length; j++) { + const candidateTarget = remaining.slice(0, j).join(':'); + if (!(candidateTarget in targets)) { + continue; } - if (targetConfig.configurations) { - for (const configurationName of Object.keys( - targetConfig.configurations - )) { - if (s === `${projectName}:${targetName}:${configurationName}`) { - return [projectName, targetName, configurationName]; - } + const configSegments = remaining.slice(j); + if (configSegments.length === 0) { + matches.push([candidateProject, candidateTarget]); + } else { + const candidateConfig = configSegments.join(':'); + const configurations = targets[candidateTarget]?.configurations; + if (configurations && candidateConfig in configurations) { + matches.push([candidateProject, candidateTarget, candidateConfig]); } } } } + + return matches; } -export function splitTargetFromNodes( +/** + * Returns whether `a` should be preferred over `b` using deterministic + * precedence rules: + * + * 1. Bare-target matches (currentProject) rank highest. + * 2. Longest (most-specific) project name. + * 3. Longest target name. + * 4. Longest configuration name. + */ +function isHigherPrecedence( + a: TargetTuple, + b: TargetTuple, + currentProject?: string +): boolean { + const aIsBare = currentProject && a[0] === currentProject ? 1 : 0; + const bIsBare = currentProject && b[0] === currentProject ? 1 : 0; + if (aIsBare !== bIsBare) return aIsBare > bIsBare; + + if (a[0].length !== b[0].length) return a[0].length > b[0].length; + + const aTarget = (a[1] ?? '').length; + const bTarget = (b[1] ?? '').length; + if (aTarget !== bTarget) return aTarget > bTarget; + + return (a[2] ?? '').length > (b[2] ?? '').length; +} + +/** + * Single-pass selection of the highest-precedence match. + */ +function bestMatch( + matches: TargetTuple[], + currentProject?: string +): TargetTuple { + let best = matches[0]; + for (let i = 1; i < matches.length; i++) { + if (isHigherPrecedence(matches[i], best, currentProject)) { + best = matches[i]; + } + } + return best; +} + +function formatMatch(match: TargetTuple): string { + return match.filter(Boolean).join(':'); +} + +/** + * Internal implementation shared by splitTargetFromNodes and + * splitTargetFromConfigurations. + */ +function splitTargetImpl( s: string, - nodes: Record + lookup: ProjectLookup, + options?: SplitTargetOptions ): [project: string, target?: string, configuration?: string] { - const matchingSegments = findMatchingSegments(s, nodes); - if (matchingSegments) { - return matchingSegments; + const silent = options?.silent ?? false; + const currentProject = options?.currentProject; + + const segments = splitByColons(s); + + const matches = findAllMatchingSegments(segments, lookup, currentProject); + + if (matches.length > 0) { + const best = bestMatch(matches, currentProject); + + if (matches.length > 1 && !silent) { + output.warn({ + title: `Ambiguous target specifier "${s}"`, + bodyLines: [ + `This string can be interpreted in multiple ways:`, + ...matches.map( + (m) => + ` ${m === best ? '→' : ' '} ${formatMatch(m)}${ + m === best ? ' (selected)' : '' + }` + ), + ``, + `The most specific match was selected. To avoid ambiguity, use a unique target specifier.`, + ], + }); + } + + return best; + } + + // --- Fallback: no exact match found in the graph --- + let colonIndex = s.indexOf(':'); + if (colonIndex === 0) { + // first colon can't be at the beginning of the string, try to find the next one + colonIndex = s.indexOf(':', 1); } - if (s.indexOf(':') > 0) { - let [project, ...segments] = splitByColons(s); + if (colonIndex > 0) { + let [project, ...remainingSegments] = segments; + // splitByColons splits on every ':', so a leading colon (e.g. ":pkg:build") + // produces an empty first element. Greedily absorb segments to reconstruct + // the longest known colon-prefixed project name (e.g. ":utils:common"). + if (project === '' && remainingSegments.length > 0) { + let absorbed = 1; // absorb at least one segment + for (let k = remainingSegments.length - 1; k >= 1; k--) { + const candidate = ':' + remainingSegments.slice(0, k).join(':'); + if (lookup.has(candidate)) { + absorbed = k; + break; + } + } + project = ':' + remainingSegments.slice(0, absorbed).join(':'); + remainingSegments = remainingSegments.slice(absorbed); + } // if only configuration cannot be matched, try to match project and target - const configuration = segments[segments.length - 1]; + const configuration = remainingSegments[remainingSegments.length - 1]; const rest = s.slice(0, -(configuration.length + 1)); - const matchingSegments = findMatchingSegments(rest, nodes); - if (matchingSegments && matchingSegments.length === 2) { - return [...(matchingSegments as [string, string]), configuration]; + const restSegments = splitByColons(rest); + const restMatches = findAllMatchingSegments( + restSegments, + lookup, + currentProject + ); + if (restMatches.length > 0) { + const best = bestMatch(restMatches, currentProject); + if (best.length === 2) { + return [...(best as [string, string]), configuration]; + } } // no project-target pair found, do the naive matching - const validTargets = nodes[project] ? nodes[project].data.targets : {}; + const validTargets = lookup.getTargets(project); const validTargetNames = new Set(Object.keys(validTargets ?? {})); - return [project, ...groupJointSegments(segments, validTargetNames)] as [ - string, - string?, - string?, - ]; + return [ + project, + ...groupJointSegments(remainingSegments, validTargetNames), + ] as [string, string?, string?]; } // we don't know what to do with the string, return as is return [s]; } +export function splitTargetFromNodes( + s: string, + nodes: Record, + options?: SplitTargetOptions +): [project: string, target?: string, configuration?: string] { + return splitTargetImpl(s, nodeLookup(nodes), options); +} + +/** + * Splits a colon-delimited target specifier using a name-keyed + * `Record` — the format used during + * the merge phase before the full project graph is available. + */ +export function splitTargetFromConfigurations( + s: string, + configs: Record, + options?: SplitTargetOptions +): [project: string, target?: string, configuration?: string] { + return splitTargetImpl(s, configLookup(configs), options); +} + export function splitTarget( s: string, - projectGraph: ProjectGraph + projectGraph: ProjectGraph, + options?: SplitTargetOptions ): [project: string, target?: string, configuration?: string] { - return splitTargetFromNodes(s, projectGraph.nodes); + return splitTargetFromNodes(s, projectGraph.nodes, options); } function groupJointSegments(segments: string[], validTargetNames: Set) { diff --git a/settings.gradle.kts b/settings.gradle.kts index 9e36b5059cf..88b95fb825d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,7 +25,7 @@ dependencyResolutionManagement { } } -rootProject.name = "nx" +rootProject.name = "nx-root-project" include("gradle-batch-runner") project(":gradle-batch-runner").projectDir = file("packages/gradle/batch-runner")