feat(linter): add support for package based secondary entry points (#30809)

This PR adds support for package.json based secondary entry points and
implements fix for situation when package imports base entry point as
dynamic dependency and secondary entry point as static dependency.

## Current Behavior
When the package is imported from itself, check for a secondary entry
point checks only Angular-style secondary entry points.

When package is importing from the same library as dynamic import from
root and static import from secondary entry point we still get linter
errror.

## Expected Behavior
Check for secondary entry points should also support standard
package.json-based entry points.

Importing from the same library as dynamic import from root and static
import from secondary entry point should be allowed.

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #18552
This commit is contained in:
Miroslav Jonaš 2025-04-25 14:31:33 +02:00 committed by GitHub
parent dda740fd2d
commit cd55dfcb3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 334 additions and 35 deletions

View File

@ -47,6 +47,7 @@ const tsconfig = {
'@mycompany/other': ['libs/other/src/index.ts'], '@mycompany/other': ['libs/other/src/index.ts'],
'@mycompany/other/a/b': ['libs/other/src/a/b.ts'], '@mycompany/other/a/b': ['libs/other/src/a/b.ts'],
'@mycompany/other/a': ['libs/other/src/a/index.ts'], '@mycompany/other/a': ['libs/other/src/a/index.ts'],
'@mycompany/other/secondary': ['libs/other/src/secondary.ts'],
'@mycompany/another/a/b': ['libs/another/a/b.ts'], '@mycompany/another/a/b': ['libs/another/a/b.ts'],
'@mycompany/myapp': ['apps/myapp/src/index.ts'], '@mycompany/myapp': ['apps/myapp/src/index.ts'],
'@mycompany/myapp-e2e': ['apps/myapp-e2e/src/index.ts'], '@mycompany/myapp-e2e': ['apps/myapp-e2e/src/index.ts'],
@ -92,6 +93,13 @@ const fileSys = {
'./libs/other/src/index.ts': '', './libs/other/src/index.ts': '',
'./libs/other/src/a/b.ts': '', './libs/other/src/a/b.ts': '',
'./libs/other/src/a/index.ts': '', './libs/other/src/a/index.ts': '',
'./libs/other/src/secondary.ts': '',
'./libs/other/package.json': JSON.stringify({
exports: {
'./secondary': './src/secondary.ts',
'.': './src/index.ts',
},
}),
'./libs/another/a/b.ts': '', './libs/another/a/b.ts': '',
'./apps/myapp/src/index.ts': '', './apps/myapp/src/index.ts': '',
'./libs/mylib/src/index.ts': '', './libs/mylib/src/index.ts': '',
@ -439,10 +447,6 @@ describe('Enforce Module Boundaries (eslint)', () => {
], ],
}; };
beforeEach(() => {
vol.fromJSON(fileSys, '/root');
});
it('should error when the target library does not have the right tag', () => { it('should error when the target library does not have the right tag', () => {
const failures = runRule( const failures = runRule(
depConstraints, depConstraints,
@ -1391,6 +1395,126 @@ Violation detected in:
} }
); );
it('should not error when statically importing dynamic dependencies if it belongs to different entry point', () => {
const failures = runRule(
{},
`${process.cwd()}/proj/libs/mylib/src/main.ts`,
'import { someValue } from "@mycompany/other/secondary";',
{
nodes: {
mylibName: {
name: 'mylibName',
type: 'lib',
data: {
root: 'libs/mylib',
tags: [],
implicitDependencies: [],
targets: {},
},
},
otherName: {
name: 'otherName',
type: 'lib',
data: {
root: 'libs/other',
tags: [],
implicitDependencies: [],
targets: {},
},
},
},
dependencies: {
mylibName: [
{
source: 'mylibName',
target: 'otherName',
type: DependencyType.dynamic,
},
{
source: 'mylibName',
target: 'otherName',
type: DependencyType.static,
},
],
},
},
{
mylibName: [
createFile(`libs/mylib/src/main.ts`, [
['otherName', DependencyType.static],
['otherName', DependencyType.dynamic],
]),
],
otherName: [
createFile(`libs/other/src/index.ts`),
createFile(`libs/other/src/secondary.ts`),
createFile(`libs/other/package.json`),
],
}
);
expect(failures.length).toEqual(0);
});
it('should error when statically importing dynamic dependencies if it belongs to root entry point', () => {
const failures = runRule(
{},
`${process.cwd()}/proj/libs/mylib/src/main.ts`,
'import { someValue } from "@mycompany/other";',
{
nodes: {
mylibName: {
name: 'mylibName',
type: 'lib',
data: {
root: 'libs/mylib',
tags: [],
implicitDependencies: [],
targets: {},
},
},
otherName: {
name: 'otherName',
type: 'lib',
data: {
root: 'libs/other',
tags: [],
implicitDependencies: [],
targets: {},
},
},
},
dependencies: {
mylibName: [
{
source: 'mylibName',
target: 'otherName',
type: DependencyType.dynamic,
},
{
source: 'mylibName',
target: 'otherName',
type: DependencyType.static,
},
],
},
},
{
mylibName: [
createFile(`libs/mylib/src/main.ts`, [
['otherName', DependencyType.static],
['otherName', DependencyType.dynamic],
]),
],
otherName: [
createFile(`libs/other/src/index.ts`),
createFile(`libs/other/src/secondary.ts`),
createFile(`libs/other/package.json`),
],
}
);
expect(failures.length).toEqual(1);
});
it('should error on importing an app', () => { it('should error on importing an app', () => {
const failures = runRule( const failures = runRule(
{}, {},

View File

@ -29,7 +29,7 @@ import {
import { readProjectGraph } from '../utils/project-graph-utils'; import { readProjectGraph } from '../utils/project-graph-utils';
import { import {
appIsMFERemote, appIsMFERemote,
belongsToDifferentNgEntryPoint, belongsToDifferentEntryPoint,
DepConstraint, DepConstraint,
findConstraintsFor, findConstraintsFor,
findDependenciesWithTags, findDependenciesWithTags,
@ -412,7 +412,7 @@ export default ESLintUtils.RuleCreator(
if ( if (
!allowCircularSelfDependency && !allowCircularSelfDependency &&
!isRelativePath(imp) && !isRelativePath(imp) &&
!belongsToDifferentNgEntryPoint( !belongsToDifferentEntryPoint(
imp, imp,
sourceFilePath, sourceFilePath,
sourceProject.data.root sourceProject.data.root
@ -623,7 +623,9 @@ export default ESLintUtils.RuleCreator(
node, node,
projectGraph, projectGraph,
sourceProject.name, sourceProject.name,
targetProject.name targetProject.name,
imp,
sourceFilePath
) )
) { ) {
const filesWithLazyImports = findFilesWithDynamicImports( const filesWithLazyImports = findFilesWithDynamicImports(

View File

@ -14,8 +14,9 @@ import {
hasBannedDependencies, hasBannedDependencies,
hasBannedImport, hasBannedImport,
hasNoneOfTheseTags, hasNoneOfTheseTags,
belongsToDifferentNgEntryPoint, belongsToDifferentEntryPoint,
isTerminalRun, isTerminalRun,
parseExports,
} from './runtime-lint-utils'; } from './runtime-lint-utils';
import { vol } from 'memfs'; import { vol } from 'memfs';
@ -486,14 +487,14 @@ describe('isAngularSecondaryEntrypoint', () => {
it('should return false if they belong to same entrypoints', () => { it('should return false if they belong to same entrypoints', () => {
// main // main
expect( expect(
belongsToDifferentNgEntryPoint( belongsToDifferentEntryPoint(
'@project/standard', '@project/standard',
'libs/standard/src/subfolder/index.ts', 'libs/standard/src/subfolder/index.ts',
'libs/standard' 'libs/standard'
) )
).toBe(false); ).toBe(false);
expect( expect(
belongsToDifferentNgEntryPoint( belongsToDifferentEntryPoint(
'@project/features', '@project/features',
'libs/features/src/subfolder/index.ts', 'libs/features/src/subfolder/index.ts',
'libs/features' 'libs/features'
@ -501,14 +502,14 @@ describe('isAngularSecondaryEntrypoint', () => {
).toBe(false); ).toBe(false);
// secondary // secondary
expect( expect(
belongsToDifferentNgEntryPoint( belongsToDifferentEntryPoint(
'@project/standard/secondary', '@project/standard/secondary',
'libs/standard/secondary/src/subfolder/index.ts', 'libs/standard/secondary/src/subfolder/index.ts',
'libs/standard' 'libs/standard'
) )
).toBe(false); ).toBe(false);
expect( expect(
belongsToDifferentNgEntryPoint( belongsToDifferentEntryPoint(
'@project/features/secondary', '@project/features/secondary',
'libs/features/secondary/random/folder/src/index.ts', 'libs/features/secondary/random/folder/src/index.ts',
'libs/features' 'libs/features'
@ -519,14 +520,14 @@ describe('isAngularSecondaryEntrypoint', () => {
it('should return true if they belong to different entrypoints', () => { it('should return true if they belong to different entrypoints', () => {
// main // main
expect( expect(
belongsToDifferentNgEntryPoint( belongsToDifferentEntryPoint(
'@project/standard', '@project/standard',
'libs/standard/secondary/src/subfolder/index.ts', 'libs/standard/secondary/src/subfolder/index.ts',
'libs/standard' 'libs/standard'
) )
).toBe(true); ).toBe(true);
expect( expect(
belongsToDifferentNgEntryPoint( belongsToDifferentEntryPoint(
'@project/features', '@project/features',
'libs/features/secondary/random/folder/src/index.ts', 'libs/features/secondary/random/folder/src/index.ts',
'libs/features' 'libs/features'
@ -534,14 +535,14 @@ describe('isAngularSecondaryEntrypoint', () => {
).toBe(true); ).toBe(true);
// secondary // secondary
expect( expect(
belongsToDifferentNgEntryPoint( belongsToDifferentEntryPoint(
'@project/standard/secondary', '@project/standard/secondary',
'libs/standard/src/subfolder/index.ts', 'libs/standard/src/subfolder/index.ts',
'libs/standard' 'libs/standard'
) )
).toBe(true); ).toBe(true);
expect( expect(
belongsToDifferentNgEntryPoint( belongsToDifferentEntryPoint(
'@project/features/secondary', '@project/features/secondary',
'libs/features/src/subfolder/index.ts', 'libs/features/src/subfolder/index.ts',
'libs/features' 'libs/features'
@ -657,3 +658,88 @@ describe('appIsMFERemote', () => {
expect(appIsMFERemote(targetNone)).toBe(false); expect(appIsMFERemote(targetNone)).toBe(false);
}); });
}); });
describe('parseExports', () => {
it('should return empty array if exports is a string', () => {
const result = [];
parseExports('index.js', '/root', result);
expect(result).toEqual([]);
});
it('should return empty array if only default conditional exports', () => {
const result = [];
parseExports({ default: 'index.js', import: 'index.mjs' }, '/root', result);
expect(result).toEqual([]);
});
it('should return empty array if only default require exports', () => {
const result = [];
parseExports({ require: 'index.cjs' }, '/root', result);
expect(result).toEqual([]);
});
it('should return empty array if only default import exports', () => {
const result = [];
parseExports({ import: 'index.mjs' }, '/root', result);
expect(result).toEqual([]);
});
it('should return empty array if only default import exports', () => {
const result = [];
parseExports({ '.': 'index.js' }, '/root', result);
expect(result).toEqual([]);
});
it('should return secondary entry point if exists', () => {
const result = [];
parseExports(
{ '.': './src/index.js', './secondary': './src/secondary.js' },
'/root',
result
);
expect(result).toEqual([
{ file: '/root/src/secondary.js', path: '/root/secondary' },
]);
});
it('should return nested secondary entry point with default export', () => {
const result = [];
parseExports(
{ '.': './src/index.js', './secondary': './src/secondary.js' },
'/root',
result
);
expect(result).toEqual([
{ file: '/root/src/secondary.js', path: '/root/secondary' },
]);
});
it('should return nested conditionalsecondary entry point with default export', () => {
const result = [];
parseExports(
{
'.': './src/index.js',
'./secondary': {
default: './src/secondary.js',
import: './src/secondary.mjs',
require: './src/secondary.cjs',
},
},
'/root',
result
);
expect(result).toEqual([
{ file: '/root/src/secondary.js', path: '/root/secondary' },
]);
});
it('should ignore root and null exports', () => {
const result = [];
parseExports(
{
'.': './src/index.js',
'./secondary': './src/secondary.js',
'./tertiary': './src/tertiary.js',
'./tertiary/private': null,
},
'/root',
result
);
expect(result).toEqual([
{ file: '/root/src/secondary.js', path: '/root/secondary' },
{ file: '/root/src/tertiary.js', path: '/root/tertiary' },
]);
});
});

View File

@ -210,7 +210,9 @@ export function hasStaticImportOfDynamicResource(
| TSESTree.ExportNamedDeclaration, | TSESTree.ExportNamedDeclaration,
graph: ProjectGraph, graph: ProjectGraph,
sourceProjectName: string, sourceProjectName: string,
targetProjectName: string targetProjectName: string,
importExpr: string,
filePath: string
): boolean { ): boolean {
if ( if (
node.type !== AST_NODE_TYPES.ImportDeclaration || node.type !== AST_NODE_TYPES.ImportDeclaration ||
@ -218,10 +220,17 @@ export function hasStaticImportOfDynamicResource(
) { ) {
return false; return false;
} }
return onlyLoadChildren(graph, sourceProjectName, targetProjectName, []); return (
hasDynamicImport(graph, sourceProjectName, targetProjectName, []) &&
!getSecondaryEntryPointPath(
importExpr,
filePath,
graph.nodes[targetProjectName].data.root
)
);
} }
function onlyLoadChildren( function hasDynamicImport(
graph: ProjectGraph, graph: ProjectGraph,
sourceProjectName: string, sourceProjectName: string,
targetProjectName: string, targetProjectName: string,
@ -238,7 +247,7 @@ function onlyLoadChildren(
if (d.target === targetProjectName) { if (d.target === targetProjectName) {
return true; return true;
} }
return onlyLoadChildren(graph, d.target, targetProjectName, [ return hasDynamicImport(graph, d.target, targetProjectName, [
...visited, ...visited,
sourceProjectName, sourceProjectName,
]); ]);
@ -490,35 +499,57 @@ export function groupImports(
/** /**
* Checks if source file belongs to a secondary entry point different than the import one * Checks if source file belongs to a secondary entry point different than the import one
*/ */
export function belongsToDifferentNgEntryPoint( export function belongsToDifferentEntryPoint(
importExpr: string, importExpr: string,
filePath: string, filePath: string,
projectRoot: string projectRoot: string
): boolean { ): boolean {
const resolvedImportFile = resolveModuleByImport( const importEntryPoint = getSecondaryEntryPointPath(
importExpr, importExpr,
filePath, // not strictly necessary, but speeds up resolution filePath,
path.join(workspaceRoot, getRootTsConfigFileName())
);
if (!resolvedImportFile) {
return false;
}
const importEntryPoint = getAngularEntryPoint(
resolvedImportFile,
projectRoot projectRoot
); );
const srcEntryPoint = getAngularEntryPoint(filePath, projectRoot); const srcEntryPoint = getEntryPoint(filePath, projectRoot);
// check if the entry point of import expression is different than the source file's entry point // check if the entry point of import expression is different than the source file's entry point
return importEntryPoint !== srcEntryPoint; return importEntryPoint !== srcEntryPoint;
} }
function getAngularEntryPoint(file: string, projectRoot: string): string { export function getSecondaryEntryPointPath(
importExpr: string,
filePath: string,
projectRoot: string
): string | undefined {
const resolvedImportFile = resolveModuleByImport(
importExpr,
filePath, // not strictly necessary, but speeds up resolution
path.join(workspaceRoot, getRootTsConfigFileName())
);
if (!resolvedImportFile) {
return undefined;
}
const entryPoint = getEntryPoint(resolvedImportFile, projectRoot);
return entryPoint;
}
function getEntryPoint(file: string, projectRoot: string): string {
const packageEntryPoints = getPackageEntryPoints(projectRoot);
const fileEntryPoint = packageEntryPoints.find(
(entry) => entry.file === file
);
if (fileEntryPoint) {
return fileEntryPoint.file;
}
let parent = joinPathFragments(file, '../'); let parent = joinPathFragments(file, '../');
while (parent !== `${projectRoot}/`) { while (parent !== `${projectRoot}/`) {
// we need to find closest existing ng-package.json const entryPoint = packageEntryPoints.find(
(entry) => entry.path === parent
);
if (entryPoint) {
return entryPoint.file;
}
// for Angular we need to find closest existing ng-package.json
// in order to determine if the file matches the secondary entry point // in order to determine if the file matches the secondary entry point
const ngPackageContent = readFileIfExisting( const ngPackageContent = readFileIfExisting(
path.join(workspaceRoot, parent, 'ng-package.json') path.join(workspaceRoot, parent, 'ng-package.json')
@ -533,6 +564,62 @@ function getAngularEntryPoint(file: string, projectRoot: string): string {
return undefined; return undefined;
} }
function getPackageEntryPoints(
projectRoot: string
): Array<{ path: string; file: string }> {
const packageContent = readFileIfExisting(
path.join(workspaceRoot, projectRoot, 'package.json')
);
if (!packageContent) {
return [];
}
const exports = parseJson(packageContent).exports;
if (!exports) {
return [];
}
const entryPaths: Array<{ path: string; file: string }> = [];
parseExports(exports, projectRoot, entryPaths);
return entryPaths;
}
export function parseExports(
exports: string | null | Record<string, any>,
projectRoot: string,
entryPaths: Array<{ path: string; file: string }>,
basePath: string = '.'
): Array<{ path: string; file: string }> {
if (exports === null) {
return;
}
if (typeof exports === 'string') {
if (basePath === '.') {
return;
} else {
entryPaths.push({
path: joinPathFragments(projectRoot, basePath),
file: joinPathFragments(projectRoot, exports),
});
return;
}
}
// parse conditional exports
if (exports.import || exports.require || exports.default || exports.node) {
parseExports(
exports.default || exports.import || exports.require || exports.node,
projectRoot,
entryPaths,
basePath
);
return;
}
// parse general nested exports
for (const [key, value] of Object.entries(exports)) {
parseExports(value, projectRoot, entryPaths, key);
}
}
/** /**
* Returns true if the given project contains MFE config with "exposes:" section * Returns true if the given project contains MFE config with "exposes:" section
*/ */