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/a/b': ['libs/other/src/a/b.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/myapp': ['apps/myapp/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/a/b.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': '',
'./apps/myapp/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', () => {
const failures = runRule(
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', () => {
const failures = runRule(
{},

View File

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

View File

@ -14,8 +14,9 @@ import {
hasBannedDependencies,
hasBannedImport,
hasNoneOfTheseTags,
belongsToDifferentNgEntryPoint,
belongsToDifferentEntryPoint,
isTerminalRun,
parseExports,
} from './runtime-lint-utils';
import { vol } from 'memfs';
@ -486,14 +487,14 @@ describe('isAngularSecondaryEntrypoint', () => {
it('should return false if they belong to same entrypoints', () => {
// main
expect(
belongsToDifferentNgEntryPoint(
belongsToDifferentEntryPoint(
'@project/standard',
'libs/standard/src/subfolder/index.ts',
'libs/standard'
)
).toBe(false);
expect(
belongsToDifferentNgEntryPoint(
belongsToDifferentEntryPoint(
'@project/features',
'libs/features/src/subfolder/index.ts',
'libs/features'
@ -501,14 +502,14 @@ describe('isAngularSecondaryEntrypoint', () => {
).toBe(false);
// secondary
expect(
belongsToDifferentNgEntryPoint(
belongsToDifferentEntryPoint(
'@project/standard/secondary',
'libs/standard/secondary/src/subfolder/index.ts',
'libs/standard'
)
).toBe(false);
expect(
belongsToDifferentNgEntryPoint(
belongsToDifferentEntryPoint(
'@project/features/secondary',
'libs/features/secondary/random/folder/src/index.ts',
'libs/features'
@ -519,14 +520,14 @@ describe('isAngularSecondaryEntrypoint', () => {
it('should return true if they belong to different entrypoints', () => {
// main
expect(
belongsToDifferentNgEntryPoint(
belongsToDifferentEntryPoint(
'@project/standard',
'libs/standard/secondary/src/subfolder/index.ts',
'libs/standard'
)
).toBe(true);
expect(
belongsToDifferentNgEntryPoint(
belongsToDifferentEntryPoint(
'@project/features',
'libs/features/secondary/random/folder/src/index.ts',
'libs/features'
@ -534,14 +535,14 @@ describe('isAngularSecondaryEntrypoint', () => {
).toBe(true);
// secondary
expect(
belongsToDifferentNgEntryPoint(
belongsToDifferentEntryPoint(
'@project/standard/secondary',
'libs/standard/src/subfolder/index.ts',
'libs/standard'
)
).toBe(true);
expect(
belongsToDifferentNgEntryPoint(
belongsToDifferentEntryPoint(
'@project/features/secondary',
'libs/features/src/subfolder/index.ts',
'libs/features'
@ -657,3 +658,88 @@ describe('appIsMFERemote', () => {
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,
graph: ProjectGraph,
sourceProjectName: string,
targetProjectName: string
targetProjectName: string,
importExpr: string,
filePath: string
): boolean {
if (
node.type !== AST_NODE_TYPES.ImportDeclaration ||
@ -218,10 +220,17 @@ export function hasStaticImportOfDynamicResource(
) {
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,
sourceProjectName: string,
targetProjectName: string,
@ -238,7 +247,7 @@ function onlyLoadChildren(
if (d.target === targetProjectName) {
return true;
}
return onlyLoadChildren(graph, d.target, targetProjectName, [
return hasDynamicImport(graph, d.target, targetProjectName, [
...visited,
sourceProjectName,
]);
@ -490,35 +499,57 @@ export function groupImports(
/**
* Checks if source file belongs to a secondary entry point different than the import one
*/
export function belongsToDifferentNgEntryPoint(
export function belongsToDifferentEntryPoint(
importExpr: string,
filePath: string,
projectRoot: string
): boolean {
const resolvedImportFile = resolveModuleByImport(
const importEntryPoint = getSecondaryEntryPointPath(
importExpr,
filePath, // not strictly necessary, but speeds up resolution
path.join(workspaceRoot, getRootTsConfigFileName())
);
if (!resolvedImportFile) {
return false;
}
const importEntryPoint = getAngularEntryPoint(
resolvedImportFile,
filePath,
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
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, '../');
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
const ngPackageContent = readFileIfExisting(
path.join(workspaceRoot, parent, 'ng-package.json')
@ -533,6 +564,62 @@ function getAngularEntryPoint(file: string, projectRoot: string): string {
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
*/