fix(misc): update project directory validation to only account for the derived name (#30532)

## Current Behavior

The helper to normalize the project name and directory for project
generators validates the provided full directory with a regex pattern
intended to only validate the name.

## Expected Behavior

The helper to normalize the project name and directory for project
generators should only validate the provided name or the name portion of
the provided directory.

## Related Issue(s)

Fixes #28801
This commit is contained in:
Leosvel Pérez Espinosa 2025-04-02 10:09:11 +02:00 committed by GitHub
parent b911ddbdac
commit 8dceb6c17d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 98 additions and 49 deletions

View File

@ -188,6 +188,54 @@ describe('determineProjectNameAndRootOptions', () => {
}
});
it('should support a directory outside of the cwd', async () => {
// simulate running in a subdirectory
const originalInitCwd = process.env.INIT_CWD;
process.env.INIT_CWD = join(workspaceRoot, 'some/path');
const result = await determineProjectNameAndRootOptions(tree, {
directory: '../../libs/lib-name',
projectType: 'library',
});
expect(result).toEqual({
projectName: 'lib-name',
names: {
projectSimpleName: 'lib-name',
projectFileName: 'lib-name',
},
importPath: '@proj/lib-name',
projectRoot: 'libs/lib-name',
});
// restore original cwd
if (originalInitCwd === undefined) {
delete process.env.INIT_CWD;
} else {
process.env.INIT_CWD = originalInitCwd;
}
});
it('should throw when the resolved directory is outside of the workspace root', async () => {
// simulate running in a subdirectory
const originalInitCwd = process.env.INIT_CWD;
process.env.INIT_CWD = join(workspaceRoot, 'some/path');
await expect(
determineProjectNameAndRootOptions(tree, {
directory: '../../../libs/lib-name',
projectType: 'library',
})
).rejects.toThrow(/is outside of the workspace root/);
// restore original cwd
if (originalInitCwd === undefined) {
delete process.env.INIT_CWD;
} else {
process.env.INIT_CWD = originalInitCwd;
}
});
it('should return the project name and directory as provided for root projects', async () => {
updateJson(tree, 'package.json', (json) => {
json.name = 'lib-name';
@ -212,10 +260,19 @@ describe('determineProjectNameAndRootOptions', () => {
});
});
it('should throw when an invalid directory is provided', async () => {
it('should throw when a name is not provided for a root project', async () => {
await expect(
determineProjectNameAndRootOptions(tree, {
directory: '!scope/lib-name',
directory: '.',
projectType: 'library',
})
).rejects.toThrow(/you must also specify the name option/);
});
it('should throw when a directory is provided where the derived name is invalid', async () => {
await expect(
determineProjectNameAndRootOptions(tree, {
directory: '@scope/lib-name/invalid-extra-segment',
projectType: 'library',
})
).rejects.toThrow(/directory should match/);
@ -231,23 +288,6 @@ describe('determineProjectNameAndRootOptions', () => {
).rejects.toThrow(/name should match/);
});
it('should handle providing a path including "@" with multiple segments as the project name', async () => {
const result = await determineProjectNameAndRootOptions(tree, {
directory: 'shared/@scope/lib-name/testing',
projectType: 'library',
});
expect(result).toEqual({
projectName: '@scope/lib-name/testing',
names: {
projectSimpleName: 'testing',
projectFileName: 'lib-name-testing',
},
importPath: '@scope/lib-name/testing',
projectRoot: 'shared/@scope/lib-name/testing',
});
});
it('should handle providing a path including multiple "@" as the project name', async () => {
const result = await determineProjectNameAndRootOptions(tree, {
directory: 'shared/@foo/@scope/libName',

View File

@ -49,7 +49,12 @@ export async function determineProjectNameAndRootOptions(
tree: Tree,
options: ProjectGenerationOptions
): Promise<ProjectNameAndRootOptions> {
validateOptions(options);
// root projects must provide name option
if (options.directory === '.' && !options.name) {
throw new Error(
`When generating a root project, you must also specify the name option.`
);
}
const directory = normalizePath(options.directory);
const name =
@ -57,6 +62,8 @@ export async function determineProjectNameAndRootOptions(
directory.match(/(@[^@/]+(\/[^@/]+)+)/)?.[1] ??
directory.substring(directory.lastIndexOf('/') + 1);
validateOptions(options.name, name, options.directory);
let projectSimpleName: string;
let projectFileName: string;
if (name.startsWith('@')) {
@ -88,6 +95,12 @@ export async function determineProjectNameAndRootOptions(
}
}
if (projectRoot.startsWith('..')) {
throw new Error(
`The resolved project root "${projectRoot}" is outside of the workspace root "${workspaceRoot}".`
);
}
const importPath =
options.importPath ?? resolveImportPath(tree, name, projectRoot);
@ -136,38 +149,34 @@ export async function ensureRootProjectName(
}
}
function validateOptions(options: ProjectGenerationOptions): void {
if (options.directory === '.') {
/**
* Root projects must provide name option
*/
if (!options.name) {
throw new Error(`Root projects must also specify name option.`);
}
} else {
/**
* Both directory and name (if present) must match one of two cases:
*
* 1. Valid npm package names (e.g., '@scope/name' or 'name').
* 2. Names starting with a letter and can contain any character except whitespace and ':'.
*
* The second case is to support the legacy behavior (^[a-zA-Z].*$) with the difference
* that it doesn't allow the ":" character. It was wrong to allow it because it would
* conflict with the notation for tasks.
*/
const pattern =
'(?:^@[a-zA-Z0-9-*~][a-zA-Z0-9-*._~]*\\/[a-zA-Z0-9-~][a-zA-Z0-9-._~]*|^[a-zA-Z][^:]*)$';
const validationRegex = new RegExp(pattern);
if (options.name && !validationRegex.test(options.name)) {
function validateOptions(
providedName: string,
derivedName: string,
directory: string
): void {
/**
* The provided name and the derived name from the provided directory must match one of two cases:
*
* 1. Valid npm package names (e.g., '@scope/name' or 'name').
* 2. Names starting with a letter and can contain any character except whitespace and ':'.
*
* The second case is to support the legacy behavior (^[a-zA-Z].*$) with the difference
* that it doesn't allow the ":" character. It was wrong to allow it because it would
* conflict with the notation for tasks.
*/
const pattern =
'(?:^@[a-zA-Z0-9-*~][a-zA-Z0-9-*._~]*\\/[a-zA-Z0-9-~][a-zA-Z0-9-._~]*|^[a-zA-Z][^:]*)$';
const validationRegex = new RegExp(pattern);
if (providedName) {
if (!validationRegex.test(providedName)) {
throw new Error(
`The name should match the pattern "${pattern}". The provided value "${options.name}" does not match.`
);
}
if (!validationRegex.test(options.directory)) {
throw new Error(
`The directory should match the pattern "${pattern}". The provided value "${options.directory}" does not match.`
`The name should match the pattern "${pattern}". The provided value "${providedName}" does not match.`
);
}
} else if (!validationRegex.test(derivedName)) {
throw new Error(
`The derived name from the provided directory should match the pattern "${pattern}". The derived name "${derivedName}" from the provided value "${directory}" does not match.`
);
}
}