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 - [data](../../devkit/documents/ProjectGraphProjectNode#data): ProjectConfiguration & Object
- [name](../../devkit/documents/ProjectGraphProjectNode#name): string - [name](../../devkit/documents/ProjectGraphProjectNode#name): string
- [type](../../devkit/documents/ProjectGraphProjectNode#type): "app" | "e2e" | "lib" - [type](../../devkit/documents/ProjectGraphProjectNode#type): "lib" | "app" | "e2e"
## Properties ## Properties
@ -28,4 +28,4 @@ Additional metadata about a project
### type ### 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 // project outside of the package manager workspaces
createPackage('lib1', { root: 'libs/lib1' }); createPackage('lib1', { root: 'libs/lib1' });
@ -173,6 +197,8 @@ describe('Graph - TS solution setup', () => {
json.dependencies['@proj/pkg10'] = 'workspace:*'; json.dependencies['@proj/pkg10'] = 'workspace:*';
json.dependencies['@proj/pkg11'] = 'workspace:*'; json.dependencies['@proj/pkg11'] = 'workspace:*';
json.dependencies['@proj/pkg13'] = 'workspace:*'; json.dependencies['@proj/pkg13'] = 'workspace:*';
json.dependencies['@proj/pkg15'] = 'workspace:*';
json.dependencies['@proj/pkg16'] = 'workspace:*';
return json; return json;
}); });
} }
@ -207,6 +233,8 @@ describe('Graph - TS solution setup', () => {
{ path: '../pkg12' }, { path: '../pkg12' },
{ path: '../pkg13' }, { path: '../pkg13' },
{ path: '../pkg14' }, { path: '../pkg14' },
{ path: '../pkg15' },
{ path: '../pkg16' },
]; ];
return json; return json;
}); });
@ -226,13 +254,15 @@ describe('Graph - TS solution setup', () => {
import { util1 } from '@proj/pkg11/utils/util1'; import { util1 } from '@proj/pkg11/utils/util1';
import { pkg12 } from '@proj/pkg12/feature1'; import { pkg12 } from '@proj/pkg12/feature1';
import { pkg13 } from '@proj/pkg14'; 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 // 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 // it's not included in the package manager workspaces, it should not
// be picked up as a dependency // be picked up as a dependency
import { lib1 } from '@proj/lib1'; import { lib1 } from '@proj/lib1';
// use the correct imports, leave out the invalid ones so it's easier to remove them later // 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/pkg10',
'@proj/pkg11', '@proj/pkg11',
'@proj/pkg13', '@proj/pkg13',
'@proj/pkg15',
'@proj/pkg16',
]); ]);
// assert build fails due to the invalid imports // assert build fails due to the invalid imports
@ -331,7 +363,7 @@ describe('Graph - TS solution setup', () => {
const sourceFilePaths = options?.sourceFilePaths ?? ['src/index.ts']; const sourceFilePaths = options?.sourceFilePaths ?? ['src/index.ts'];
for (const sourceFilePath of sourceFilePaths) { for (const sourceFilePath of sourceFilePaths) {
const fileName = basename(sourceFilePath, '.ts'); const fileName = basename(sourceFilePath, '.ts').replace(/-/g, '_');
createFile( createFile(
`${root}/${sourceFilePath}`, `${root}/${sourceFilePath}`,
`export const ${ `export const ${

View File

@ -1129,6 +1129,10 @@ describe('TargetProjectLocator', () => {
${{ '.': 'dist/index.js' }} | ${'@org/pkg1'} ${{ '.': 'dist/index.js' }} | ${'@org/pkg1'}
${{ './subpath': './dist/subpath.js' }} | ${'@org/pkg1/subpath'} ${{ './subpath': './dist/subpath.js' }} | ${'@org/pkg1/subpath'}
${{ './*': './dist/*.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'} ${{ import: './dist/index.js', default: './dist/index.js' }} | ${'@org/pkg1'}
`( `(
'should find "$importPath" as "pkg1" project when exports="$exports"', 'should find "$importPath" as "pkg1" project when exports="$exports"',
@ -1168,6 +1172,10 @@ describe('TargetProjectLocator', () => {
${{ '.': 'dist/index.js' }} | ${'@org/pkg1'} ${{ '.': 'dist/index.js' }} | ${'@org/pkg1'}
${{ './subpath': './dist/subpath.js' }} | ${'@org/pkg1/subpath'} ${{ './subpath': './dist/subpath.js' }} | ${'@org/pkg1/subpath'}
${{ './*': './dist/*.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'} ${{ import: './dist/index.js', default: './dist/index.js' }} | ${'@org/pkg1'}
`( `(
'should not find "$importPath" as "pkg1" project when exports="$exports" and isInPackageManagerWorkspaces is false', 'should not find "$importPath" as "pkg1" project when exports="$exports" and isInPackageManagerWorkspaces is false',
@ -1206,8 +1214,8 @@ describe('TargetProjectLocator', () => {
${undefined} | ${'@org/pkg1'} ${undefined} | ${'@org/pkg1'}
${{}} | ${'@org/pkg1'} ${{}} | ${'@org/pkg1'}
${{ '.': 'dist/index.js' }} | ${'@org/pkg1/subpath'} ${{ '.': 'dist/index.js' }} | ${'@org/pkg1/subpath'}
${{ './subpath/*': 'dist/subpath/*.js' }} | ${'@org/pkg1/foo'}
${{ './subpath': './dist/subpath.js' }} | ${'@org/pkg1/subpath/extra-path'} ${{ './subpath': './dist/subpath.js' }} | ${'@org/pkg1/subpath/extra-path'}
${{ './*': './dist/*.js' }} | ${'@org/pkg1/subpath/extra-path'}
${{ './feature': null }} | ${'@org/pkg1/feature'} ${{ './feature': null }} | ${'@org/pkg1/feature'}
${{ import: './dist/index.js', default: './dist/index.js' }} | ${'@org/pkg1/subpath'} ${{ 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 { join } from 'node:path/posix';
import type { ProjectGraphProjectNode } from '../../../config/project-graph'; import type { ProjectGraphProjectNode } from '../../../config/project-graph';
import type { ProjectConfiguration } from '../../../config/workspace-json-project-json'; 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< export function matchImportToWildcardEntryPointsToProjectMap<
T extends ProjectGraphProjectNode | ProjectConfiguration T extends ProjectGraphProjectNode | ProjectConfiguration
>( >(
@ -89,9 +90,33 @@ export function matchImportToWildcardEntryPointsToProjectMap<
return null; return null;
} }
const matchingPair = Object.entries(wildcardEntryPointsToProjectMap).find( const entryPoint = Object.keys(wildcardEntryPointsToProjectMap).find(
([key]) => minimatch(importPath, key) (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;
} }