fix(core): handle projects inside dependsOn correctly (#26392)

This commit is contained in:
Craigory Coppola 2024-06-07 10:38:14 -04:00 committed by GitHub
parent fea232ee32
commit 356479b332
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 236 additions and 68 deletions

View File

@ -1,8 +1,5 @@
import { ProjectGraphProjectNode } from '../../config/project-graph';
import {
normalizeImplicitDependencies,
normalizeProjectTargets,
} from './normalize-project-nodes';
import { normalizeImplicitDependencies } from './normalize-project-nodes';
describe('workspace-projects', () => {
let projectGraph: Record<string, ProjectGraphProjectNode> = {
@ -73,9 +70,41 @@ describe('workspace-projects', () => {
},
},
};
expect(
normalizeImplicitDependencies('test-project', ['b*'], projectGraphMod)
).toEqual(['b', 'b-1', 'b-2']);
const results = normalizeImplicitDependencies(
'test-project',
['b*'],
projectGraphMod
);
expect(results).toEqual(expect.arrayContaining(['b', 'b-1', 'b-2']));
expect(results).not.toContain('a');
});
it('should handle negative projects correctly', () => {
const results = normalizeImplicitDependencies(
'test-project',
['*', '!a'],
projectGraph
);
// a is excluded
expect(results).not.toContain('a');
// b and c are included by wildcard
expect(results).toEqual(expect.arrayContaining(['b', 'c']));
// !a should remain in the list, to remove deps on a if they exist
expect(results).toContain('!a');
});
it('should handle negative patterns', () => {
const results = normalizeImplicitDependencies(
'test-project',
['!tag:api'],
projectGraph
);
// No projects are included by provided patterns
expect(results.filter((x) => !x.startsWith('!'))).toHaveLength(0);
// tag:api was expanded, and results included for later processing.
expect(results).toEqual(
expect.arrayContaining(['!a', '!c', '!test-project'])
);
});
});
});

View File

@ -5,10 +5,6 @@ import {
TargetConfiguration,
} from '../../config/workspace-json-project-json';
import { findMatchingProjects } from '../../utils/find-matching-projects';
import {
readProjectConfigurationsFromRootMap,
resolveNxTokensInOptions,
} from '../utils/project-configuration-utils';
import { CreateDependenciesContext } from '../plugins';
export async function normalizeProjectNodes(
@ -111,12 +107,38 @@ export function normalizeImplicitDependencies(
if (!implicitDependencies?.length) {
return implicitDependencies ?? [];
}
const matches = findMatchingProjects(implicitDependencies, projects);
return (
matches
.filter((x) => x !== source)
// implicit dependencies that start with ! should hang around, to be processed by
// implicit-project-dependencies.ts after explicit deps are added to graph.
.concat(implicitDependencies.filter((x) => x.startsWith('!')))
);
// Implicit dependencies handle negatives in a different
// way from most other `projects` fields. This is because
// they are used for multiple purposes.
const positivePatterns: string[] = [];
const negativePatterns: string[] = [];
for (const dep of implicitDependencies) {
if (dep.startsWith('!')) {
negativePatterns.push(dep);
} else {
positivePatterns.push(dep);
}
}
// Finds all projects that match a positive pattern and are not excluded by a negative pattern
const deps = positivePatterns.length
? findMatchingProjects(
positivePatterns.concat(negativePatterns),
projects
).filter((x) => x !== source)
: [];
// Expands negative patterns to equal project names
const alwaysIgnoredDeps = findMatchingProjects(
negativePatterns.map((x) => x.slice(1)),
projects
);
// We return the matching deps, but keep the negative patterns in the list
// so that they can be processed later by implicit-project-dependencies.ts
// This is what allows using a negative implicit dep to remove a dependency
// detected by createDependencies.
return deps.concat(alwaysIgnoredDeps.map((x) => '!' + x)) as string[];
}

View File

@ -1553,4 +1553,86 @@ describe('createTaskGraph', () => {
'lib2:build',
]);
});
it('should handle negative patterns in dependsOn', () => {
const graph: ProjectGraph = {
nodes: {
app1: {
name: 'app1',
type: 'app',
data: {
root: 'app1-root',
targets: {
build: {
executor: 'nx:run-commands',
dependsOn: [{ target: 'build', projects: '!lib1' }],
},
},
},
},
lib1: {
name: 'lib1',
type: 'lib',
data: {
root: 'lib1-root',
targets: {
build: {
executor: 'nx:run-commands',
},
},
},
},
lib2: {
name: 'lib2',
type: 'lib',
data: {
root: 'lib2-root',
targets: {
build: {
executor: 'nx:run-commands',
},
foo: {
executor: 'nx:noop',
dependsOn: [
{
target: 'build',
projects: ['lib*', '!lib1'],
},
],
},
},
},
},
lib3: {
name: 'lib3',
type: 'lib',
data: {
root: 'lib3-root',
targets: {
build: {
executor: 'nx:run-commands',
},
},
},
},
},
dependencies: {
app1: [],
},
};
const taskGraph = createTaskGraph(graph, {}, ['app1'], ['build'], null, {});
expect(taskGraph.tasks).toHaveProperty('app1:build');
expect(taskGraph.tasks).not.toHaveProperty('lib1:build');
expect(taskGraph.tasks).toHaveProperty('lib2:build');
expect(taskGraph.dependencies['app1:build']).toEqual([
'lib2:build',
'lib3:build',
]);
const taskGraph2 = createTaskGraph(graph, {}, ['lib2'], ['foo'], null, {});
expect(taskGraph2.tasks).toHaveProperty('lib2:foo');
expect(taskGraph2.dependencies['lib2:foo']).toEqual([
'lib2:build',
'lib3:build',
]);
});
});

View File

@ -108,9 +108,41 @@ export class ProcessTasks {
? overrides
: { __overrides_unparsed__: [] };
if (dependencyConfig.projects) {
/** LERNA SUPPORT START - Remove in v20 */
// Lerna uses `dependencies` in `prepNxOptions`, so we need to maintain
// support for it until lerna can be updated to use the syntax.
//
// This should have been removed in v17, but the updates to lerna had not
// been made yet.
//
// TODO(@agentender): Remove this part in v20
if (typeof dependencyConfig.projects === 'string') {
if (dependencyConfig.projects === 'self') {
this.processTasksForSingleProject(
task,
task.target.project,
dependencyConfig,
configuration,
taskOverrides,
overrides
);
continue;
} else if (dependencyConfig.projects === 'dependencies') {
this.processTasksForDependencies(
projectUsedToDeriveDependencies,
dependencyConfig,
configuration,
task,
taskOverrides,
overrides
);
continue;
}
}
/** LERNA SUPPORT END - Remove in v17 */
this.processTasksForMatchingProjects(
dependencyConfig,
projectUsedToDeriveDependencies,
configuration,
task,
taskOverrides,
@ -140,7 +172,6 @@ export class ProcessTasks {
private processTasksForMatchingProjects(
dependencyConfig: TargetDependencyConfig,
projectUsedToDeriveDependencies: string,
configuration: string,
task: Task,
taskOverrides: Object | { __overrides_unparsed__: any[] },
@ -150,43 +181,19 @@ export class ProcessTasks {
typeof dependencyConfig.projects === 'string'
? [dependencyConfig.projects]
: dependencyConfig.projects;
for (const projectSpecifier of targetProjectSpecifiers) {
// Lerna uses `dependencies` in `prepNxOptions`, so we need to maintain
// support for it until lerna can be updated to use the syntax.
// TODO(@agentender): Remove this part in v17
if (
projectSpecifier === 'dependencies' &&
!this.projectGraph.nodes[projectSpecifier]
) {
this.processTasksForDependencies(
projectUsedToDeriveDependencies,
dependencyConfig,
configuration,
task,
taskOverrides,
overrides
const matchingProjects = findMatchingProjects(
targetProjectSpecifiers,
this.projectGraph.nodes
);
} else {
// Since we need to maintain support for dependencies, it is more coherent
// that we also support self.
// TODO(@agentender): Remove this part in v17
const matchingProjects =
/** LERNA SUPPORT START - Remove in v17 */
projectSpecifier === 'self' &&
!this.projectGraph.nodes[projectSpecifier]
? [task.target.project]
: /** LERNA SUPPORT END */
findMatchingProjects([projectSpecifier], this.projectGraph.nodes);
if (matchingProjects.length === 0) {
output.warn({
title: `\`dependsOn\` is misconfigured for ${task.target.project}:${task.target.target}`,
bodyLines: [
`Project pattern "${projectSpecifier}" does not match any projects.`,
`Project patterns "${targetProjectSpecifiers}" does not match any projects.`,
],
});
}
for (const projectName of matchingProjects) {
this.processTasksForSingleProject(
task,
@ -198,8 +205,6 @@ export class ProcessTasks {
);
}
}
}
}
private processTasksForSingleProject(
task: Task,

View File

@ -162,6 +162,22 @@ describe('findMatchingProjects', () => {
'nested',
]);
});
it('should support "all except" style patterns', () => {
expect(findMatchingProjects(['!a'], projectGraph)).toEqual([
'test-project',
'b',
'c',
'nested',
]);
expect(findMatchingProjects(['!tag:api'], projectGraph)).toEqual([
'b',
'nested',
]);
expect(
findMatchingProjects(['!tag:api', 'test-project'], projectGraph)
).toEqual(['b', 'nested', 'test-project']);
});
});
const projects = [

View File

@ -39,6 +39,16 @@ export function findMatchingProjects(
const matchedProjects: Set<string> = new Set();
// If the first pattern is an exclude pattern,
// we add a wildcard pattern at the first to select
// all projects, except the ones that match the exclude pattern.
// e.g. ['!tag:someTag', 'project2'] will match all projects except
// the ones with the tag 'someTag', and also match the project 'project2',
// regardless of its tags.
if (isExcludePattern(patterns[0])) {
patterns.unshift('*');
}
for (const stringPattern of patterns) {
if (!stringPattern.length) {
continue;
@ -205,11 +215,15 @@ function addMatchingProjectsByTag(
}
}
function isExcludePattern(pattern: string): boolean {
return pattern.startsWith('!');
}
function parseStringPattern(
pattern: string,
projects: Record<string, ProjectGraphProjectNode>
): ProjectPattern {
const isExclude = pattern.startsWith('!');
const isExclude = isExcludePattern(pattern);
// Support for things like: `!{type}:value`
if (isExclude) {