From baf663729ce683a5721938c37894261fb341c829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Jona=C5=A1?= Date: Fri, 11 Apr 2025 11:26:13 +0200 Subject: [PATCH] feat(core): allow globs in project config to extend atomized targets (#30630) ## TL;DR; This PR enabled mapping glob patterns in target names within the project config to match and extend atomized targets. ## Problem statement The project configuration (via project.json or package.json) enables us to override target defaults set via inferred plugins and/or targetDefaults. However, this is only possible for fixed targets. Overriding atomized/dynamic targets would require listing them all explicitly, which would create a lot of overhead and would break the dynamicity and automation of the atomized targets. ## Why not use targetDefaults? The targetDefaults already support globbing to match a range of targets like e2e-ci-**/* so we can use the same logic on the project level. We often need to make modifications on project level, rather than the entire monorepo. E.g. all atomized targets running on a feature lib checkout should have an implicit dependency on build target of dependency as stated by "dependsOn": ["^build"] but they also should have a dependency on app:build - the application which e2e tests are serving, although there is no direct or indirect dependency between checkout-e2e and app. This is significant to retain the lean affected graph. ## Solution We can use the same globbing logic from targetDefaults and will apply in mergeProjectConfigurationIntoRootMap function to match glob patterns to range of targets instead of searching for the explicit target name only. When the glob pattern doesn't match any targets, the fallback is still the existing functionality. This is possible given the fact that plugins' targets get applied before we start parsing the project configurations, as they have priority over generic plugin definitions and `targetDefaults.` Override would look like this: ```jsonc { "name": "users-e2e", "implicitDependencies": ["users"], "targets": { "e2e": { "dependsOn": ["^build", { "target": "build", "projects": "app" }] }, "e2e-ci--**/*": { "dependsOn": ["^build", { "target": "build", "projects": "app" }] } } } ``` ## Current Behavior ## Expected Behavior ## Related Issue(s) Fixes # --- .../utils/project-configuration-utils.spec.ts | 93 +++++++++++++++++++ .../utils/project-configuration-utils.ts | 31 +++++-- 2 files changed, 116 insertions(+), 8 deletions(-) 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 f382d4ba75..f51d82501d 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 @@ -699,6 +699,99 @@ describe('project-configuration-utils', () => { 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({ 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 7b259671cf..ee7f731fdc 100644 --- a/packages/nx/src/project-graph/utils/project-configuration-utils.ts +++ b/packages/nx/src/project-graph/utils/project-configuration-utils.ts @@ -194,15 +194,30 @@ export function mergeProjectConfigurationIntoRootMap( ? target : resolveCommandSyntacticSugar(target, project.root); - const mergedTarget = mergeTargetConfigurations( - normalizedTarget, - matchingProject.targets?.[targetName], - sourceMap, - sourceInformation, - `targets.${targetName}` - ); + 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]; + } - updatedProjectConfiguration.targets[targetName] = mergedTarget; + for (const matchingTargetName of matchingTargets) { + const mergedTarget = mergeTargetConfigurations( + normalizedTarget, + matchingProject.targets?.[matchingTargetName], + sourceMap, + sourceInformation, + `targets.${matchingTargetName}` + ); + + updatedProjectConfiguration.targets[matchingTargetName] = mergedTarget; + } } }