fix(core): update resolution of ts path mappings with patterns in target project locator (#30533)

## Current Behavior

The `TargetProjectLocator` logic for matching TS path mapping patterns
is incorrect and doesn't handle a few scenarios.

## Expected Behavior

The `TargetProjectLocator` logic for matching TS path mapping patterns
should match the TS resolution and handle all valid scenarios.

## Related Issue(s)

Fixes #30172
This commit is contained in:
Leosvel Pérez Espinosa 2025-04-02 10:08:56 +02:00 committed by GitHub
parent 9b84926d0b
commit b911ddbdac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 137 additions and 14 deletions

View File

@ -80,6 +80,9 @@ describe('TargetProjectLocator', () => {
'@proj/proj1234-child/*': ['libs/proj1234-child/*'],
'#hash-path': ['libs/hash-project/src/index.ts'],
'parent-path/*': ['libs/parent-path/*'],
'@proj/feature-*': ['libs/features/*'],
'@proj/*/utils': ['libs/scope/*/utils'],
'@proj/*-util': ['libs/utils/*'],
},
},
};
@ -227,6 +230,27 @@ describe('TargetProjectLocator', () => {
},
},
},
users: {
name: 'users',
type: 'lib',
data: {
root: 'libs/features/users',
},
},
admin: {
name: 'admin',
type: 'lib',
data: {
root: 'libs/scope/admin',
},
},
'file-system': {
name: 'file-system',
type: 'lib',
data: {
root: 'libs/utils/file-system',
},
},
};
npmProjects = {
'npm:@ng/core': {
@ -413,19 +437,33 @@ describe('TargetProjectLocator', () => {
});
it('should be able to resolve wildcard paths', () => {
const parentProject = targetProjectLocator.findProjectFromImport(
'parent-path',
'libs/proj1/index.ts'
);
expect(parentProject).toEqual('parent-project');
// 'parent-path/*': ['libs/parent-path/*'] => 'libs/parent-path/child-path'
const childProject = targetProjectLocator.findProjectFromImport(
'parent-path/child-path',
'libs/proj1/index.ts'
);
expect(childProject).toEqual('child-project');
// '@proj/feature-*': ['libs/features/*'] => 'libs/features/users'
const usersProject = targetProjectLocator.findProjectFromImport(
'@proj/feature-users',
'libs/proj1/index.ts'
);
expect(usersProject).toEqual('users');
// '@proj/*/utils': ['libs/scope/*/utils'] => 'libs/scope/admin/utils'
const adminProject = targetProjectLocator.findProjectFromImport(
'@proj/admin/utils',
'libs/proj1/index.ts'
);
expect(adminProject).toEqual('admin');
// '@proj/*-util': ['libs/utils/*'] => 'libs/utils/file-system'
const fileSystemProject = targetProjectLocator.findProjectFromImport(
'@proj/file-system-util',
'libs/proj1/index.ts'
);
expect(fileSystemProject).toEqual('file-system');
});
it('should be able to resolve paths that start with a #', () => {

View File

@ -30,6 +30,16 @@ import {
*/
type NpmResolutionCache = Map<string, string | null>;
type PathPattern = {
pattern: string;
prefix: string;
suffix: string;
};
type ParsedPatterns = {
matchableStrings: Set<string> | undefined;
patterns: PathPattern[] | undefined;
};
/**
* Use a shared cache to avoid repeated npm package resolution work within the TargetProjectLocator.
*/
@ -47,6 +57,7 @@ export class TargetProjectLocator {
private npmProjects: Record<string, ProjectGraphExternalNode | null>;
private tsConfig = this.getRootTsConfig();
private paths = this.tsConfig.config?.compilerOptions?.paths;
private parsedPathPatterns: ParsedPatterns | undefined;
private typescriptResolutionCache = new Map<string, string | null>();
private packagesMetadata: {
entryPointsToProjectMap: Record<string, ProjectGraphProjectNode>;
@ -81,6 +92,10 @@ export class TargetProjectLocator {
}
return acc;
}, {} as Record<string, ProjectGraphExternalNode>);
if (this.tsConfig.config?.compilerOptions?.paths) {
this.parsePaths(this.tsConfig.config.compilerOptions.paths);
}
}
/**
@ -97,14 +112,19 @@ export class TargetProjectLocator {
}
// find project using tsconfig paths
const results = this.findPaths(importExpr);
const results = this.findMatchingPaths(importExpr);
if (results) {
const [path, paths] = results;
const matchedStar =
typeof path === 'string'
? undefined
: importExpr.substring(
path.prefix.length,
importExpr.length - path.suffix.length
);
for (let p of paths) {
const r = p.endsWith('/*')
? join(dirname(p), relative(path.replace(/\*$/, ''), importExpr))
: p;
const maybeResolvedProject = this.findProjectOfResolvedModule(r);
const path = matchedStar ? p.replace('*', matchedStar) : p;
const maybeResolvedProject = this.findProjectOfResolvedModule(path);
if (maybeResolvedProject) {
return maybeResolvedProject;
}
@ -237,7 +257,7 @@ export class TargetProjectLocator {
/**
* Return file paths matching the import relative to the repo root
* @param normalizedImportExpr
* @returns
* @deprecated Use `findMatchingPaths` instead. It will be removed in Nx v22.
*/
findPaths(normalizedImportExpr: string): string[] | undefined {
if (!this.paths) {
@ -258,6 +278,37 @@ export class TargetProjectLocator {
return undefined;
}
findMatchingPaths(
importExpr: string
): [pattern: string | PathPattern, paths: string[]] | undefined {
if (!this.parsedPathPatterns) {
return undefined;
}
const { matchableStrings, patterns } = this.parsedPathPatterns;
if (matchableStrings.has(importExpr)) {
return [importExpr, this.paths[importExpr]];
}
// https://github.com/microsoft/TypeScript/blob/29e6d6689dfb422e4f1395546c1917d07e1f664d/src/compiler/core.ts#L2410
let matchedValue: PathPattern | undefined;
let longestMatchPrefixLength = -1;
for (let i = 0; i < patterns.length; i++) {
const pattern = patterns[i];
if (
pattern.prefix.length > longestMatchPrefixLength &&
this.isPatternMatch(pattern, importExpr)
) {
longestMatchPrefixLength = pattern.prefix.length;
matchedValue = pattern;
}
}
return matchedValue
? [matchedValue, this.paths[matchedValue.pattern]]
: undefined;
}
findImportInWorkspaceProjects(importPath: string): string | null {
this.packagesMetadata ??= getWorkspacePackagesMetadata(this.nodes);
@ -279,6 +330,40 @@ export class TargetProjectLocator {
return this.packagesMetadata.packageToProjectMap[dep]?.name;
}
private isPatternMatch(
{ prefix, suffix }: PathPattern,
candidate: string
): boolean {
return (
candidate.length >= prefix.length + suffix.length &&
candidate.startsWith(prefix) &&
candidate.endsWith(suffix)
);
}
private parsePaths(paths: Record<string, string>): void {
this.parsedPathPatterns = {
matchableStrings: new Set(),
patterns: [],
};
for (const key of Object.keys(paths)) {
const parts = key.split('*');
if (parts.length > 2) {
continue;
}
if (parts.length === 1) {
this.parsedPathPatterns.matchableStrings.add(key);
continue;
}
this.parsedPathPatterns.patterns.push({
pattern: key,
prefix: parts[0],
suffix: parts[1],
});
}
}
private resolveImportWithTypescript(
normalizedImportExpr: string,
filePath: string