feat(core): allow tasks to run with a substring of project name (#29552)

When projects use only `package.json` and not `project.json`, we need to
set the simple name in the `nx` property of `package.json`. This isn't
ideal because it's yet another Nx-specific thing that needs to be
configured when our goal is to reduce boilerplate.

This PR allows users to pass a substring that matches exactly one
project when running a task.

For example, if `@acme/foo` is the name in `package.json`, then running
`nx build foo` will match it.

If more than one projects match, then an error is thrown showing all the
matched projects, and the user has to be more specific or type in the
fully qualified name.

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
Users need to pass exact matches for project names when running tasks.

## Expected Behavior
User can pass a substring matching the project name.

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

Fixes #
This commit is contained in:
Jack Hsu 2025-01-09 13:39:24 -05:00 committed by GitHub
parent a77e3ef083
commit c6cb024a06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 204 additions and 19 deletions

View File

@ -46,6 +46,39 @@ describe('Nx Running Tests', () => {
});
});
it('should support running with simple names (i.e. matching on full segments)', () => {
const foo = uniq('foo');
const bar = uniq('bar');
const nested = uniq('nested');
runCLI(`generate @nx/js:lib libs/${foo}`);
runCLI(`generate @nx/js:lib libs/${bar}`);
runCLI(`generate @nx/js:lib libs/nested/${nested}`);
updateJson(`libs/${foo}/project.json`, (c) => {
c.name = `@acme/${foo}`;
c.targets['echo'] = { command: 'echo TEST' };
return c;
});
updateJson(`libs/${bar}/project.json`, (c) => {
c.name = `@acme/${bar}`;
c.targets['echo'] = { command: 'echo TEST' };
return c;
});
updateJson(`libs/nested/${nested}/project.json`, (c) => {
c.name = `@acme/nested/${bar}`; // The last segment is a duplicate
c.targets['echo'] = { command: 'echo TEST' };
return c;
});
// Full segments should match
expect(() => runCLI(`echo ${foo}`)).not.toThrow();
// Multiple matches should fail
expect(() => runCLI(`echo ${bar}`)).toThrow();
// Partial segments should not match (Note: project foo has numbers in the end that aren't matched fully)
expect(() => runCLI(`echo foo`)).toThrow();
});
it.each([
'--watch false',
'--watch=false',

View File

@ -9,7 +9,10 @@ import {
createProjectGraphAsync,
readProjectsConfigurationFromProjectGraph,
} from '../../project-graph/project-graph';
import { ProjectGraph } from '../../config/project-graph';
import {
ProjectGraph,
ProjectGraphProjectNode,
} from '../../config/project-graph';
import { NxJsonConfiguration } from '../../config/nx-json';
import { workspaceRoot } from '../../utils/workspace-root';
import { splitTarget } from '../../utils/split-target';
@ -18,6 +21,7 @@ import { TargetDependencyConfig } from '../../config/workspace-json-project-json
import { readNxJson } from '../../config/configuration';
import { calculateDefaultProjectName } from '../../config/calculate-default-project-name';
import { generateGraph } from '../graph/graph';
import { findMatchingProjects } from '../../utils/find-matching-projects';
export async function runOne(
cwd: string,
@ -60,7 +64,7 @@ export async function runOne(
await connectToNxCloudIfExplicitlyAsked(nxArgs);
const { projects } = getProjects(projectGraph, opts.project);
const { projects, projectName } = getProjects(projectGraph, opts.project);
if (nxArgs.graph) {
const projectNames = projects.map((t) => t.name);
@ -84,7 +88,7 @@ export async function runOne(
{ nxJson },
nxArgs,
overrides,
opts.project,
projectName,
extraTargetDependencies,
extraOptions
);
@ -92,19 +96,48 @@ export async function runOne(
}
}
function getProjects(projectGraph: ProjectGraph, project: string): any {
if (!projectGraph.nodes[project]) {
output.error({
title: `Cannot find project '${project}'`,
});
process.exit(1);
function getProjects(
projectGraph: ProjectGraph,
projectName: string
): {
projectName: string;
projects: ProjectGraphProjectNode[];
projectsMap: Record<string, ProjectGraphProjectNode>;
} {
if (projectGraph.nodes[projectName]) {
return {
projectName: projectName,
projects: [projectGraph.nodes[projectName]],
projectsMap: {
[projectName]: projectGraph.nodes[projectName],
},
};
} else {
const projects = findMatchingProjects([projectName], projectGraph.nodes);
if (projects.length === 1) {
const projectName = projects[0];
const project = projectGraph.nodes[projectName];
return {
projectName,
projects: [project],
projectsMap: {
[project.data.name]: project,
},
};
} else if (projects.length > 1) {
output.error({
title: `Multiple projects matched:`,
bodyLines:
projects.length > 100 ? [...projects.slice(0, 100), '...'] : projects,
});
process.exit(1);
}
}
let projects = [projectGraph.nodes[project]];
let projectsMap = {
[project]: projectGraph.nodes[project],
};
return { projects, projectsMap };
output.error({
title: `Cannot find project '${projectName}'`,
});
process.exit(1);
}
const targetAliases = {

View File

@ -2,6 +2,7 @@ import { output } from '../../utils/output';
import { createProjectGraphAsync } from '../../project-graph/project-graph';
import { ShowProjectOptions } from './command-object';
import { generateGraph } from '../graph/graph';
import { findMatchingProjects } from '../../utils/find-matching-projects';
export async function showProjectHandler(
args: ShowProjectOptions
@ -9,10 +10,29 @@ export async function showProjectHandler(
performance.mark('code-loading:end');
performance.measure('code-loading', 'init-local', 'code-loading:end');
const graph = await createProjectGraphAsync();
const node = graph.nodes[args.projectName];
let node = graph.nodes[args.projectName];
if (!node) {
console.log(`Could not find project ${args.projectName}`);
process.exit(1);
const projects = findMatchingProjects([args.projectName], graph.nodes);
if (projects.length === 1) {
const projectName = projects[0];
node = graph.nodes[projectName];
} else if (projects.length > 1) {
output.error({
title: `Multiple projects matched:`,
bodyLines:
projects.length > 100 ? [...projects.slice(0, 100), '...'] : projects,
});
console.log(
`Multiple projects matched:\n ${(projects.length > 100
? [...projects.slice(0, 100), '...']
: projects
).join(' \n')}`
);
process.exit(1);
} else {
console.log(`Could not find project ${args.projectName}`);
process.exit(1);
}
}
if (args.json) {
console.log(JSON.stringify(node.data));

View File

@ -47,6 +47,39 @@ describe('findMatchingProjects', () => {
tags: [],
},
},
'@acme/foo': {
name: '@acme/foo',
type: 'lib',
data: {
root: 'lib/foo',
tags: [],
},
},
'@acme/bar': {
name: '@acme/bar',
type: 'lib',
data: {
root: 'lib/bar',
tags: [],
},
},
foo_bar1: {
name: 'foo_bar11',
type: 'lib',
data: {
root: 'lib/foo_bar1',
tags: [],
},
},
// Technically, this isn't a valid npm package name, but we can handle it anyway just in case.
'@acme/nested/foo': {
name: '@acme/nested/foo',
type: 'lib',
data: {
root: 'lib/nested/foo',
tags: [],
},
},
};
it('should return no projects when passed no patterns', () => {
@ -68,6 +101,10 @@ describe('findMatchingProjects', () => {
'b',
'c',
'nested',
'@acme/foo',
'@acme/bar',
'foo_bar1',
'@acme/nested/foo',
]);
});
@ -77,6 +114,10 @@ describe('findMatchingProjects', () => {
'b',
'c',
'nested',
'@acme/foo',
'@acme/bar',
'foo_bar1',
'@acme/nested/foo',
]);
expect(findMatchingProjects(['a', '!*'], projectGraph)).toEqual([]);
});
@ -120,6 +161,10 @@ describe('findMatchingProjects', () => {
'b',
'c',
'nested',
'@acme/foo',
'@acme/bar',
'foo_bar1',
'@acme/nested/foo',
]);
});
@ -131,7 +176,7 @@ describe('findMatchingProjects', () => {
projectGraph
);
expect(matches).toEqual(expect.arrayContaining(['a', 'b', 'nested']));
expect(matches.length).toEqual(3);
expect(matches.length).toEqual(7);
});
it('should expand generic glob patterns for tags', () => {
@ -156,6 +201,9 @@ describe('findMatchingProjects', () => {
'test-project',
'a',
'b',
'@acme/foo',
'@acme/bar',
'foo_bar1',
]);
expect(findMatchingProjects(['apps/*'], projectGraph)).toEqual(['c']);
expect(findMatchingProjects(['**/nested'], projectGraph)).toEqual([
@ -169,14 +217,50 @@ describe('findMatchingProjects', () => {
'b',
'c',
'nested',
'@acme/foo',
'@acme/bar',
'foo_bar1',
'@acme/nested/foo',
]);
expect(findMatchingProjects(['!tag:api'], projectGraph)).toEqual([
'b',
'nested',
'@acme/foo',
'@acme/bar',
'foo_bar1',
'@acme/nested/foo',
]);
expect(
findMatchingProjects(['!tag:api', 'test-project'], projectGraph)
).toEqual(['b', 'nested', 'test-project']);
).toEqual([
'b',
'nested',
'@acme/foo',
'@acme/bar',
'foo_bar1',
'@acme/nested/foo',
'test-project',
]);
});
it('should match on name segments', () => {
expect(findMatchingProjects(['foo'], projectGraph)).toEqual([
'@acme/foo',
'foo_bar1',
'@acme/nested/foo',
]);
expect(findMatchingProjects(['bar'], projectGraph)).toEqual(['@acme/bar']);
// Case insensitive
expect(findMatchingProjects(['Bar1'], projectGraph)).toEqual(['foo_bar1']);
expect(findMatchingProjects(['foo_bar1'], projectGraph)).toEqual([
'foo_bar1',
]);
expect(findMatchingProjects(['nested/foo'], projectGraph)).toEqual([
'@acme/nested/foo',
]);
// Only full segments are matched
expect(findMatchingProjects(['fo'], projectGraph)).toEqual([]);
expect(findMatchingProjects(['nested/fo'], projectGraph)).toEqual([]);
});
});

View File

@ -167,6 +167,21 @@ function addMatchingProjectsByName(
}
if (!isGlobPattern(pattern.value)) {
// Custom regex that is basically \b without underscores, so "foo" pattern matches "foo_bar".
const regex = new RegExp(
`(?<![a-zA-Z0-9])${pattern.value}(?![a-zA-Z0-9])`,
'i'
);
const matchingProjects = Object.keys(projects).filter((name) =>
regex.test(name)
);
for (const projectName of matchingProjects) {
if (pattern.exclude) {
matchedProjects.delete(projectName);
} else {
matchedProjects.add(projectName);
}
}
return;
}