616 lines
20 KiB
TypeScript
616 lines
20 KiB
TypeScript
import {
|
|
joinPathFragments,
|
|
normalizePath,
|
|
ProjectGraphExternalNode,
|
|
ProjectGraphProjectNode,
|
|
workspaceRoot,
|
|
} from '@nrwl/devkit';
|
|
import { isRelativePath } from 'nx/src/utils/fileutils';
|
|
import {
|
|
checkCircularPath,
|
|
findFilesInCircularPath,
|
|
} from '../utils/graph-utils';
|
|
import {
|
|
DepConstraint,
|
|
findConstraintsFor,
|
|
findDependenciesWithTags,
|
|
findProjectUsingImport,
|
|
findProject,
|
|
findTransitiveExternalDependencies,
|
|
getSourceFilePath,
|
|
getTargetProjectBasedOnRelativeImport,
|
|
groupImports,
|
|
hasBannedDependencies,
|
|
hasBannedImport,
|
|
hasBuildExecutor,
|
|
hasNoneOfTheseTags,
|
|
isAbsoluteImportIntoAnotherProject,
|
|
isAngularSecondaryEntrypoint,
|
|
isDirectDependency,
|
|
matchImportWithWildcard,
|
|
onlyLoadChildren,
|
|
stringifyTags,
|
|
} from '../utils/runtime-lint-utils';
|
|
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
|
|
import { TargetProjectLocator } from 'nx/src/utils/target-project-locator';
|
|
import { basename, dirname, relative } from 'path';
|
|
import {
|
|
getBarrelEntryPointByImportScope,
|
|
getBarrelEntryPointProjectNode,
|
|
getRelativeImportPath,
|
|
} from '../utils/ast-utils';
|
|
import { createESLintRule } from '../utils/create-eslint-rule';
|
|
import { readProjectGraph } from '../utils/project-graph-utils';
|
|
|
|
type Options = [
|
|
{
|
|
allow: string[];
|
|
depConstraints: DepConstraint[];
|
|
enforceBuildableLibDependency: boolean;
|
|
allowCircularSelfDependency: boolean;
|
|
banTransitiveDependencies: boolean;
|
|
checkNestedExternalImports: boolean;
|
|
}
|
|
];
|
|
export type MessageIds =
|
|
| 'noRelativeOrAbsoluteImportsAcrossLibraries'
|
|
| 'noSelfCircularDependencies'
|
|
| 'noCircularDependencies'
|
|
| 'noImportsOfApps'
|
|
| 'noImportsOfE2e'
|
|
| 'noImportOfNonBuildableLibraries'
|
|
| 'noImportsOfLazyLoadedLibraries'
|
|
| 'projectWithoutTagsCannotHaveDependencies'
|
|
| 'bannedExternalImportsViolation'
|
|
| 'nestedBannedExternalImportsViolation'
|
|
| 'noTransitiveDependencies'
|
|
| 'onlyTagsConstraintViolation'
|
|
| 'emptyOnlyTagsConstraintViolation'
|
|
| 'notTagsConstraintViolation';
|
|
export const RULE_NAME = 'enforce-module-boundaries';
|
|
|
|
export default createESLintRule<Options, MessageIds>({
|
|
name: RULE_NAME,
|
|
meta: {
|
|
type: 'suggestion',
|
|
docs: {
|
|
description: `Ensure that module boundaries are respected within the monorepo`,
|
|
recommended: 'error',
|
|
},
|
|
fixable: 'code',
|
|
schema: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
enforceBuildableLibDependency: { type: 'boolean' },
|
|
allowCircularSelfDependency: { type: 'boolean' },
|
|
banTransitiveDependencies: { type: 'boolean' },
|
|
checkNestedExternalImports: { type: 'boolean' },
|
|
allow: [{ type: 'string' }],
|
|
depConstraints: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
sourceTag: { type: 'string' },
|
|
onlyDependOnLibsWithTags: [{ type: 'string' }],
|
|
bannedExternalImports: [{ type: 'string' }],
|
|
notDependOnLibsWithTags: [{ type: 'string' }],
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
],
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
],
|
|
messages: {
|
|
noRelativeOrAbsoluteImportsAcrossLibraries: `Projects cannot be imported by a relative or absolute path, and must begin with a npm scope`,
|
|
noCircularDependencies: `Circular dependency between "{{sourceProjectName}}" and "{{targetProjectName}}" detected: {{path}}\n\nCircular file chain:\n{{filePaths}}`,
|
|
noSelfCircularDependencies: `Projects should use relative imports to import from other files within the same project. Use "./path/to/file" instead of import from "{{imp}}"`,
|
|
noImportsOfApps: 'Imports of apps are forbidden',
|
|
noImportsOfE2e: 'Imports of e2e projects are forbidden',
|
|
noImportOfNonBuildableLibraries:
|
|
'Buildable libraries cannot import or export from non-buildable libraries',
|
|
noImportsOfLazyLoadedLibraries: `Imports of lazy-loaded libraries are forbidden`,
|
|
projectWithoutTagsCannotHaveDependencies: `A project without tags matching at least one constraint cannot depend on any libraries`,
|
|
bannedExternalImportsViolation: `A project tagged with "{{sourceTag}}" is not allowed to import the "{{package}}" package`,
|
|
nestedBannedExternalImportsViolation: `A project tagged with "{{sourceTag}}" is not allowed to import the "{{package}}" package. Nested import found at {{childProjectName}}`,
|
|
noTransitiveDependencies: `Transitive dependencies are not allowed. Only packages defined in the "package.json" can be imported`,
|
|
onlyTagsConstraintViolation: `A project tagged with "{{sourceTag}}" can only depend on libs tagged with {{tags}}`,
|
|
emptyOnlyTagsConstraintViolation:
|
|
'A project tagged with "{{sourceTag}}" cannot depend on any libs with tags',
|
|
notTagsConstraintViolation: `A project tagged with "{{sourceTag}}" can not depend on libs tagged with {{tags}}\n\nViolation detected in:\n{{projects}}`,
|
|
},
|
|
},
|
|
defaultOptions: [
|
|
{
|
|
allow: [],
|
|
depConstraints: [],
|
|
enforceBuildableLibDependency: false,
|
|
allowCircularSelfDependency: false,
|
|
banTransitiveDependencies: false,
|
|
checkNestedExternalImports: false,
|
|
},
|
|
],
|
|
create(
|
|
context,
|
|
[
|
|
{
|
|
allow,
|
|
depConstraints,
|
|
enforceBuildableLibDependency,
|
|
allowCircularSelfDependency,
|
|
banTransitiveDependencies,
|
|
checkNestedExternalImports,
|
|
},
|
|
]
|
|
) {
|
|
/**
|
|
* Globally cached info about workspace
|
|
*/
|
|
const projectPath = normalizePath(
|
|
(global as any).projectPath || workspaceRoot
|
|
);
|
|
const fileName = normalizePath(context.getFilename());
|
|
|
|
const { projectGraph, projectRootMappings } = readProjectGraph(RULE_NAME);
|
|
|
|
if (!projectGraph) {
|
|
return {};
|
|
}
|
|
|
|
const workspaceLayout = (global as any).workspaceLayout;
|
|
|
|
if (!(global as any).targetProjectLocator) {
|
|
(global as any).targetProjectLocator = new TargetProjectLocator(
|
|
projectGraph.nodes,
|
|
projectGraph.externalNodes
|
|
);
|
|
}
|
|
const targetProjectLocator = (global as any)
|
|
.targetProjectLocator as TargetProjectLocator;
|
|
|
|
function run(
|
|
node:
|
|
| TSESTree.ImportDeclaration
|
|
| TSESTree.ImportExpression
|
|
| TSESTree.ExportAllDeclaration
|
|
| TSESTree.ExportNamedDeclaration
|
|
) {
|
|
// Ignoring ExportNamedDeclarations like:
|
|
// export class Foo {}
|
|
if (!node.source) {
|
|
return;
|
|
}
|
|
|
|
// accept only literals because template literals have no value
|
|
if (node.source.type !== AST_NODE_TYPES.Literal) {
|
|
return;
|
|
}
|
|
|
|
const imp = node.source.value as string;
|
|
|
|
// whitelisted import
|
|
if (allow.some((a) => matchImportWithWildcard(a, imp))) {
|
|
return;
|
|
}
|
|
|
|
const sourceFilePath = getSourceFilePath(fileName, projectPath);
|
|
|
|
const sourceProject = findProject(
|
|
projectGraph,
|
|
projectRootMappings,
|
|
sourceFilePath
|
|
);
|
|
// If source is not part of an nx workspace, return.
|
|
if (!sourceProject) {
|
|
return;
|
|
}
|
|
|
|
// check for relative and absolute imports
|
|
const isAbsoluteImportIntoAnotherProj =
|
|
isAbsoluteImportIntoAnotherProject(imp, workspaceLayout);
|
|
let targetProject: ProjectGraphProjectNode | ProjectGraphExternalNode;
|
|
|
|
if (isAbsoluteImportIntoAnotherProj) {
|
|
targetProject = findProject(projectGraph, projectRootMappings, imp);
|
|
} else {
|
|
targetProject = getTargetProjectBasedOnRelativeImport(
|
|
imp,
|
|
projectPath,
|
|
projectGraph,
|
|
projectRootMappings,
|
|
sourceFilePath
|
|
);
|
|
}
|
|
|
|
if (
|
|
(targetProject && sourceProject !== targetProject) ||
|
|
isAbsoluteImportIntoAnotherProj
|
|
) {
|
|
context.report({
|
|
node,
|
|
messageId: 'noRelativeOrAbsoluteImportsAcrossLibraries',
|
|
fix(fixer) {
|
|
if (targetProject) {
|
|
const indexTsPaths = getBarrelEntryPointProjectNode(
|
|
targetProject as ProjectGraphProjectNode
|
|
);
|
|
|
|
if (indexTsPaths && indexTsPaths.length > 0) {
|
|
const specifiers = (node as any).specifiers;
|
|
if (!specifiers || specifiers.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const imports = specifiers
|
|
.filter((s) => s.type === 'ImportSpecifier')
|
|
.map((s) => s.imported.name);
|
|
|
|
// process each potential entry point and try to find the imports
|
|
const importsToRemap = [];
|
|
|
|
for (const entryPointPath of indexTsPaths) {
|
|
for (const importMember of imports) {
|
|
const importPath = getRelativeImportPath(
|
|
importMember,
|
|
joinPathFragments(workspaceRoot, entryPointPath.path),
|
|
sourceProject.data.sourceRoot
|
|
);
|
|
// we cannot remap, so leave it as is
|
|
if (importPath) {
|
|
importsToRemap.push({
|
|
member: importMember,
|
|
importPath: entryPointPath.importScope,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const adjustedRelativeImports = groupImports(importsToRemap);
|
|
|
|
if (adjustedRelativeImports !== '') {
|
|
return fixer.replaceTextRange(
|
|
node.range,
|
|
adjustedRelativeImports
|
|
);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
targetProject =
|
|
targetProject ||
|
|
findProjectUsingImport(
|
|
projectGraph,
|
|
targetProjectLocator,
|
|
sourceFilePath,
|
|
imp
|
|
);
|
|
|
|
// If target is not part of an nx workspace, return.
|
|
if (!targetProject) {
|
|
return;
|
|
}
|
|
|
|
// we only allow relative paths within the same project
|
|
// and if it's not a secondary entrypoint in an angular lib
|
|
if (sourceProject === targetProject) {
|
|
if (
|
|
!allowCircularSelfDependency &&
|
|
!isRelativePath(imp) &&
|
|
!isAngularSecondaryEntrypoint(targetProjectLocator, imp)
|
|
) {
|
|
context.report({
|
|
node,
|
|
messageId: 'noSelfCircularDependencies',
|
|
data: {
|
|
imp,
|
|
},
|
|
fix(fixer) {
|
|
// imp has form of @myorg/someproject/some/path
|
|
const indexTsPaths = getBarrelEntryPointByImportScope(imp);
|
|
if (indexTsPaths && indexTsPaths.length > 0) {
|
|
const specifiers = (node as any).specifiers;
|
|
if (!specifiers || specifiers.length === 0) {
|
|
return;
|
|
}
|
|
// imported JS functions to remap
|
|
const imports = specifiers
|
|
.filter((s) => s.type === 'ImportSpecifier')
|
|
.map((s) => s.imported.name);
|
|
|
|
// process each potential entry point and try to find the imports
|
|
const importsToRemap = [];
|
|
|
|
for (const entryPointPath of indexTsPaths) {
|
|
for (const importMember of imports) {
|
|
const importPath = getRelativeImportPath(
|
|
importMember,
|
|
joinPathFragments(workspaceRoot, entryPointPath),
|
|
sourceProject.data.sourceRoot
|
|
);
|
|
if (importPath) {
|
|
// resolve the import path
|
|
const relativePath = relative(
|
|
dirname(fileName),
|
|
dirname(importPath)
|
|
);
|
|
|
|
// if the string is empty, it's the current file
|
|
const importPathResolved =
|
|
relativePath === ''
|
|
? `./${basename(importPath)}`
|
|
: joinPathFragments(
|
|
relativePath,
|
|
basename(importPath)
|
|
);
|
|
|
|
importsToRemap.push({
|
|
member: importMember,
|
|
importPath: importPathResolved.replace('.ts', ''),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const adjustedRelativeImports = groupImports(importsToRemap);
|
|
if (adjustedRelativeImports !== '') {
|
|
return fixer.replaceTextRange(
|
|
node.range,
|
|
adjustedRelativeImports
|
|
);
|
|
}
|
|
}
|
|
},
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// project => npm package
|
|
if (targetProject.type === 'npm') {
|
|
if (banTransitiveDependencies && !isDirectDependency(targetProject)) {
|
|
context.report({
|
|
node,
|
|
messageId: 'noTransitiveDependencies',
|
|
});
|
|
}
|
|
const constraint = hasBannedImport(
|
|
sourceProject,
|
|
targetProject,
|
|
depConstraints
|
|
);
|
|
if (constraint) {
|
|
context.report({
|
|
node,
|
|
messageId: 'bannedExternalImportsViolation',
|
|
data: {
|
|
sourceTag: constraint.sourceTag,
|
|
package: targetProject.data.packageName,
|
|
},
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// check constraints between libs and apps
|
|
// check for circular dependency
|
|
const circularPath = checkCircularPath(
|
|
projectGraph,
|
|
sourceProject,
|
|
targetProject
|
|
);
|
|
if (circularPath.length !== 0) {
|
|
const circularFilePath = findFilesInCircularPath(circularPath);
|
|
|
|
// spacer text used for indirect dependencies when printing one line per file.
|
|
// without this, we can end up with a very long line that does not display well in the terminal.
|
|
const spacer = ' ';
|
|
|
|
context.report({
|
|
node,
|
|
messageId: 'noCircularDependencies',
|
|
data: {
|
|
sourceProjectName: sourceProject.name,
|
|
targetProjectName: targetProject.name,
|
|
path: circularPath.reduce(
|
|
(acc, v) => `${acc} -> ${v.name}`,
|
|
sourceProject.name
|
|
),
|
|
filePaths: circularFilePath
|
|
.map((files) =>
|
|
files.length > 1
|
|
? `[${files
|
|
.map((f) => `\n${spacer}${spacer}${f}`)
|
|
.join(',')}\n${spacer}]`
|
|
: files[0]
|
|
)
|
|
.reduce(
|
|
(acc, files) => `${acc}\n- ${files}`,
|
|
`- ${sourceFilePath}`
|
|
),
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
// cannot import apps
|
|
if (targetProject.type === 'app') {
|
|
context.report({
|
|
node,
|
|
messageId: 'noImportsOfApps',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// cannot import e2e projects
|
|
if (targetProject.type === 'e2e') {
|
|
context.report({
|
|
node,
|
|
messageId: 'noImportsOfE2e',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// buildable-lib is not allowed to import non-buildable-lib
|
|
if (
|
|
enforceBuildableLibDependency === true &&
|
|
sourceProject.type === 'lib' &&
|
|
targetProject.type === 'lib'
|
|
) {
|
|
if (
|
|
hasBuildExecutor(sourceProject) &&
|
|
!hasBuildExecutor(targetProject)
|
|
) {
|
|
context.report({
|
|
node,
|
|
messageId: 'noImportOfNonBuildableLibraries',
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// if we import a library using loadChildren, we should not import it using es6imports
|
|
if (
|
|
node.type === AST_NODE_TYPES.ImportDeclaration &&
|
|
node.importKind !== 'type' &&
|
|
onlyLoadChildren(
|
|
projectGraph,
|
|
sourceProject.name,
|
|
targetProject.name,
|
|
[]
|
|
)
|
|
) {
|
|
context.report({
|
|
node,
|
|
messageId: 'noImportsOfLazyLoadedLibraries',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// check that dependency constraints are satisfied
|
|
if (depConstraints.length > 0) {
|
|
const constraints = findConstraintsFor(depConstraints, sourceProject);
|
|
// when no constrains found => error. Force the user to provision them.
|
|
if (constraints.length === 0) {
|
|
context.report({
|
|
node,
|
|
messageId: 'projectWithoutTagsCannotHaveDependencies',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const transitiveExternalDeps = checkNestedExternalImports
|
|
? findTransitiveExternalDependencies(projectGraph, targetProject)
|
|
: [];
|
|
|
|
for (let constraint of constraints) {
|
|
if (
|
|
constraint.onlyDependOnLibsWithTags &&
|
|
constraint.onlyDependOnLibsWithTags.length &&
|
|
hasNoneOfTheseTags(
|
|
targetProject,
|
|
constraint.onlyDependOnLibsWithTags
|
|
)
|
|
) {
|
|
context.report({
|
|
node,
|
|
messageId: 'onlyTagsConstraintViolation',
|
|
data: {
|
|
sourceTag: constraint.sourceTag,
|
|
tags: stringifyTags(constraint.onlyDependOnLibsWithTags),
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (
|
|
constraint.onlyDependOnLibsWithTags &&
|
|
constraint.onlyDependOnLibsWithTags.length === 0 &&
|
|
targetProject.data.tags.length !== 0
|
|
) {
|
|
context.report({
|
|
node,
|
|
messageId: 'emptyOnlyTagsConstraintViolation',
|
|
data: {
|
|
sourceTag: constraint.sourceTag,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (
|
|
constraint.notDependOnLibsWithTags &&
|
|
constraint.notDependOnLibsWithTags.length
|
|
) {
|
|
const projectPaths = findDependenciesWithTags(
|
|
targetProject,
|
|
constraint.notDependOnLibsWithTags,
|
|
projectGraph
|
|
);
|
|
if (projectPaths.length > 0) {
|
|
context.report({
|
|
node,
|
|
messageId: 'notTagsConstraintViolation',
|
|
data: {
|
|
sourceTag: constraint.sourceTag,
|
|
tags: stringifyTags(constraint.notDependOnLibsWithTags),
|
|
projects: projectPaths
|
|
.map(
|
|
(projectPath) =>
|
|
`- ${projectPath.map((p) => p.name).join(' -> ')}`
|
|
)
|
|
.join('\n'),
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
if (
|
|
checkNestedExternalImports &&
|
|
constraint.bannedExternalImports &&
|
|
constraint.bannedExternalImports.length
|
|
) {
|
|
const matches = hasBannedDependencies(
|
|
transitiveExternalDeps,
|
|
projectGraph,
|
|
constraint
|
|
);
|
|
if (matches.length > 0) {
|
|
matches.forEach(([target, violatingSource, constraint]) => {
|
|
context.report({
|
|
node,
|
|
messageId: 'bannedExternalImportsViolation',
|
|
data: {
|
|
sourceTag: constraint.sourceTag,
|
|
childProjectName: violatingSource.name,
|
|
package: target.data.packageName,
|
|
},
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
ImportDeclaration(node: TSESTree.ImportDeclaration) {
|
|
run(node);
|
|
},
|
|
ImportExpression(node: TSESTree.ImportExpression) {
|
|
run(node);
|
|
},
|
|
ExportAllDeclaration(node: TSESTree.ExportAllDeclaration) {
|
|
run(node);
|
|
},
|
|
ExportNamedDeclaration(node: TSESTree.ExportNamedDeclaration) {
|
|
run(node);
|
|
},
|
|
};
|
|
},
|
|
});
|