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/*'], '@proj/proj1234-child/*': ['libs/proj1234-child/*'],
'#hash-path': ['libs/hash-project/src/index.ts'], '#hash-path': ['libs/hash-project/src/index.ts'],
'parent-path/*': ['libs/parent-path/*'], '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 = { npmProjects = {
'npm:@ng/core': { 'npm:@ng/core': {
@ -413,19 +437,33 @@ describe('TargetProjectLocator', () => {
}); });
it('should be able to resolve wildcard paths', () => { it('should be able to resolve wildcard paths', () => {
const parentProject = targetProjectLocator.findProjectFromImport( // 'parent-path/*': ['libs/parent-path/*'] => 'libs/parent-path/child-path'
'parent-path',
'libs/proj1/index.ts'
);
expect(parentProject).toEqual('parent-project');
const childProject = targetProjectLocator.findProjectFromImport( const childProject = targetProjectLocator.findProjectFromImport(
'parent-path/child-path', 'parent-path/child-path',
'libs/proj1/index.ts' 'libs/proj1/index.ts'
); );
expect(childProject).toEqual('child-project'); 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 #', () => { 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 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. * 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 npmProjects: Record<string, ProjectGraphExternalNode | null>;
private tsConfig = this.getRootTsConfig(); private tsConfig = this.getRootTsConfig();
private paths = this.tsConfig.config?.compilerOptions?.paths; private paths = this.tsConfig.config?.compilerOptions?.paths;
private parsedPathPatterns: ParsedPatterns | undefined;
private typescriptResolutionCache = new Map<string, string | null>(); private typescriptResolutionCache = new Map<string, string | null>();
private packagesMetadata: { private packagesMetadata: {
entryPointsToProjectMap: Record<string, ProjectGraphProjectNode>; entryPointsToProjectMap: Record<string, ProjectGraphProjectNode>;
@ -81,6 +92,10 @@ export class TargetProjectLocator {
} }
return acc; return acc;
}, {} as Record<string, ProjectGraphExternalNode>); }, {} 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 // find project using tsconfig paths
const results = this.findPaths(importExpr); const results = this.findMatchingPaths(importExpr);
if (results) { if (results) {
const [path, paths] = 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) { for (let p of paths) {
const r = p.endsWith('/*') const path = matchedStar ? p.replace('*', matchedStar) : p;
? join(dirname(p), relative(path.replace(/\*$/, ''), importExpr)) const maybeResolvedProject = this.findProjectOfResolvedModule(path);
: p;
const maybeResolvedProject = this.findProjectOfResolvedModule(r);
if (maybeResolvedProject) { if (maybeResolvedProject) {
return maybeResolvedProject; return maybeResolvedProject;
} }
@ -237,7 +257,7 @@ export class TargetProjectLocator {
/** /**
* Return file paths matching the import relative to the repo root * Return file paths matching the import relative to the repo root
* @param normalizedImportExpr * @param normalizedImportExpr
* @returns * @deprecated Use `findMatchingPaths` instead. It will be removed in Nx v22.
*/ */
findPaths(normalizedImportExpr: string): string[] | undefined { findPaths(normalizedImportExpr: string): string[] | undefined {
if (!this.paths) { if (!this.paths) {
@ -258,6 +278,37 @@ export class TargetProjectLocator {
return undefined; 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 { findImportInWorkspaceProjects(importPath: string): string | null {
this.packagesMetadata ??= getWorkspacePackagesMetadata(this.nodes); this.packagesMetadata ??= getWorkspacePackagesMetadata(this.nodes);
@ -279,6 +330,40 @@ export class TargetProjectLocator {
return this.packagesMetadata.packageToProjectMap[dep]?.name; 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( private resolveImportWithTypescript(
normalizedImportExpr: string, normalizedImportExpr: string,
filePath: string filePath: string