fix(core): resolve subpath patterns in package exports correctly when constructing graph (#30511)

## Current Behavior

When a package has a subpath pattern like the following:

```json
{
  "exports": {
    "./*": {
      "types": "./dist/lib/*/index.d.ts",
      "import": "./dist/lib/*/index.js",
      "default": "./dist/lib/*/index.js"
    }
  }
}
```

When constructing the graph the project is not picked as a dependency of
others projects that import from the package using a path that matches
that subpath pattern. This is currently happening because the current
resolution is wrongly using `minimatch` to match those patterns instead
of the [Node.js spec for resolving subpath
patterns](https://nodejs.org/docs/latest-v22.x/api/esm.html#resolution-algorithm-specification).

## Expected Behavior

Subpath patterns should be processed after the [Node.js
spec](https://nodejs.org/docs/latest-v22.x/api/esm.html#resolution-algorithm-specification)
and the graph should pick up dependencies when used.

## Related Issue(s)

Fixes #30342
This commit is contained in:
Leosvel Pérez Espinosa 2025-04-02 10:29:47 +02:00 committed by GitHub
parent 2dbff35de9
commit b3c6d2d417
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 74 additions and 9 deletions

View File

@ -8,7 +8,7 @@ A node describing a project in a workspace
- [data](../../devkit/documents/ProjectGraphProjectNode#data): ProjectConfiguration & Object
- [name](../../devkit/documents/ProjectGraphProjectNode#name): string
- [type](../../devkit/documents/ProjectGraphProjectNode#type): "app" | "e2e" | "lib"
- [type](../../devkit/documents/ProjectGraphProjectNode#type): "lib" | "app" | "e2e"
## Properties
@ -28,4 +28,4 @@ Additional metadata about a project
### type
**type**: `"app"` \| `"e2e"` \| `"lib"`
**type**: `"lib"` \| `"app"` \| `"e2e"`

View File

@ -154,6 +154,30 @@ describe('Graph - TS solution setup', () => {
},
},
});
// wildcard exports with trailing values
createPackage('pkg15', {
sourceFilePaths: ['src/features/some-file.ts'],
packageJsonEntryFields: {
exports: {
'./features/*.js': {
types: './dist/src/features/*.d.ts',
default: './dist/src/features/*.js',
},
},
},
});
// wildcard exports with no leading or trailing values
createPackage('pkg16', {
sourceFilePaths: ['src/features/subpath/extra-nested/index.ts'],
packageJsonEntryFields: {
exports: {
'./*': {
types: './dist/src/features/*/index.d.ts',
default: './dist/src/features/*/index.js',
},
},
},
});
// project outside of the package manager workspaces
createPackage('lib1', { root: 'libs/lib1' });
@ -173,6 +197,8 @@ describe('Graph - TS solution setup', () => {
json.dependencies['@proj/pkg10'] = 'workspace:*';
json.dependencies['@proj/pkg11'] = 'workspace:*';
json.dependencies['@proj/pkg13'] = 'workspace:*';
json.dependencies['@proj/pkg15'] = 'workspace:*';
json.dependencies['@proj/pkg16'] = 'workspace:*';
return json;
});
}
@ -207,6 +233,8 @@ describe('Graph - TS solution setup', () => {
{ path: '../pkg12' },
{ path: '../pkg13' },
{ path: '../pkg14' },
{ path: '../pkg15' },
{ path: '../pkg16' },
];
return json;
});
@ -226,13 +254,15 @@ describe('Graph - TS solution setup', () => {
import { util1 } from '@proj/pkg11/utils/util1';
import { pkg12 } from '@proj/pkg12/feature1';
import { pkg13 } from '@proj/pkg14';
import { some_file } from '@proj/pkg15/features/some-file.js';
import { pkg16 } from '@proj/pkg16/subpath/extra-nested';
// this is an invalid import that doesn't match any TS path alias and
// it's not included in the package manager workspaces, it should not
// be picked up as a dependency
import { lib1 } from '@proj/lib1';
// use the correct imports, leave out the invalid ones so it's easier to remove them later
export const pkgParent = pkg2 + pkg4 + pkg5 + pkg6 + pkg7 + pkg8 + pkg9 + pkg10 + util1 + pkg13;
export const pkgParent = pkg2 + pkg4 + pkg5 + pkg6 + pkg7 + pkg8 + pkg9 + pkg10 + util1 + pkg13 + some_file + pkg16;
`
);
@ -253,6 +283,8 @@ describe('Graph - TS solution setup', () => {
'@proj/pkg10',
'@proj/pkg11',
'@proj/pkg13',
'@proj/pkg15',
'@proj/pkg16',
]);
// assert build fails due to the invalid imports
@ -331,7 +363,7 @@ describe('Graph - TS solution setup', () => {
const sourceFilePaths = options?.sourceFilePaths ?? ['src/index.ts'];
for (const sourceFilePath of sourceFilePaths) {
const fileName = basename(sourceFilePath, '.ts');
const fileName = basename(sourceFilePath, '.ts').replace(/-/g, '_');
createFile(
`${root}/${sourceFilePath}`,
`export const ${

View File

@ -1129,6 +1129,10 @@ describe('TargetProjectLocator', () => {
${{ '.': 'dist/index.js' }} | ${'@org/pkg1'}
${{ './subpath': './dist/subpath.js' }} | ${'@org/pkg1/subpath'}
${{ './*': './dist/*.js' }} | ${'@org/pkg1/subpath'}
${{ './*': './dist/*.js' }} | ${'@org/pkg1/subpath/extra-path'}
${{ './*': './dist/foo/*/index.js' }} | ${'@org/pkg1/foo/subpath'}
${{ './*': './dist/foo/*/index.js' }} | ${'@org/pkg1/foo/subpath/extra-path'}
${{ './features/*.js': './dist/features/*.js' }} | ${'@org/pkg1/features/some-file.js'}
${{ import: './dist/index.js', default: './dist/index.js' }} | ${'@org/pkg1'}
`(
'should find "$importPath" as "pkg1" project when exports="$exports"',
@ -1168,6 +1172,10 @@ describe('TargetProjectLocator', () => {
${{ '.': 'dist/index.js' }} | ${'@org/pkg1'}
${{ './subpath': './dist/subpath.js' }} | ${'@org/pkg1/subpath'}
${{ './*': './dist/*.js' }} | ${'@org/pkg1/subpath'}
${{ './*': './dist/*.js' }} | ${'@org/pkg1/subpath/extra-path'}
${{ './*': './dist/foo/*/index.js' }} | ${'@org/pkg1/foo/subpath'}
${{ './*': './dist/foo/*/index.js' }} | ${'@org/pkg1/foo/subpath/extra-path'}
${{ './features/*.js': './dist/features/*.js' }} | ${'@org/pkg1/features/some-file.js'}
${{ import: './dist/index.js', default: './dist/index.js' }} | ${'@org/pkg1'}
`(
'should not find "$importPath" as "pkg1" project when exports="$exports" and isInPackageManagerWorkspaces is false',
@ -1206,8 +1214,8 @@ describe('TargetProjectLocator', () => {
${undefined} | ${'@org/pkg1'}
${{}} | ${'@org/pkg1'}
${{ '.': 'dist/index.js' }} | ${'@org/pkg1/subpath'}
${{ './subpath/*': 'dist/subpath/*.js' }} | ${'@org/pkg1/foo'}
${{ './subpath': './dist/subpath.js' }} | ${'@org/pkg1/subpath/extra-path'}
${{ './*': './dist/*.js' }} | ${'@org/pkg1/subpath/extra-path'}
${{ './feature': null }} | ${'@org/pkg1/feature'}
${{ import: './dist/index.js', default: './dist/index.js' }} | ${'@org/pkg1/subpath'}
`(

View File

@ -1,4 +1,3 @@
import { minimatch } from 'minimatch';
import { join } from 'node:path/posix';
import type { ProjectGraphProjectNode } from '../../../config/project-graph';
import type { ProjectConfiguration } from '../../../config/workspace-json-project-json';
@ -79,6 +78,8 @@ export function getWorkspacePackagesMetadata<
};
}
// adapted from PACKAGE_IMPORTS_EXPORTS_RESOLVE at
// https://nodejs.org/docs/latest-v22.x/api/esm.html#resolution-algorithm-specification
export function matchImportToWildcardEntryPointsToProjectMap<
T extends ProjectGraphProjectNode | ProjectConfiguration
>(
@ -89,9 +90,33 @@ export function matchImportToWildcardEntryPointsToProjectMap<
return null;
}
const matchingPair = Object.entries(wildcardEntryPointsToProjectMap).find(
([key]) => minimatch(importPath, key)
const entryPoint = Object.keys(wildcardEntryPointsToProjectMap).find(
(key) => {
const segments = key.split('*');
if (segments.length > 2) {
return false;
}
const patternBase = segments[0];
if (patternBase === importPath) {
return false;
}
if (!importPath.startsWith(patternBase)) {
return false;
}
const patternTrailer = segments[1];
if (
patternTrailer?.length > 0 &&
(!importPath.endsWith(patternTrailer) || importPath.length < key.length)
) {
return false;
}
return true;
}
);
return matchingPair?.[1];
return entryPoint ? wildcardEntryPointsToProjectMap[entryPoint] : null;
}