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
<!-- This is the behavior we have today -->

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
This commit is contained in:
Miroslav Jonaš 2025-04-11 11:26:13 +02:00 committed by GitHub
parent ff53b006e4
commit baf663729c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 116 additions and 8 deletions

View File

@ -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({

View File

@ -194,15 +194,30 @@ export function mergeProjectConfigurationIntoRootMap(
? 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?.[targetName],
matchingProject.targets?.[matchingTargetName],
sourceMap,
sourceInformation,
`targets.${targetName}`
`targets.${matchingTargetName}`
);
updatedProjectConfiguration.targets[targetName] = mergedTarget;
updatedProjectConfiguration.targets[matchingTargetName] = mergedTarget;
}
}
}