|
| 1 | +import { ProjectType } from '@nrwl/workspace/src/command-line/affected-apps'; |
| 2 | +import { readDependencies } from '@nrwl/workspace/src/command-line/deps-calculator'; |
| 3 | +import { |
| 4 | + getProjectNodes, |
| 5 | + normalizedProjectRoot, |
| 6 | + readNxJson, |
| 7 | + readWorkspaceJson |
| 8 | +} from '@nrwl/workspace/src/command-line/shared'; |
| 9 | +import { appRootPath } from '@nrwl/workspace/src/utils/app-root'; |
| 10 | +import { |
| 11 | + DepConstraint, |
| 12 | + findConstraintsFor, |
| 13 | + findProjectUsingImport, |
| 14 | + findSourceProject, |
| 15 | + getSourceFilePath, |
| 16 | + hasNoneOfTheseTags, |
| 17 | + isAbsoluteImportIntoAnotherProject, |
| 18 | + isCircular, |
| 19 | + isRelativeImportIntoAnotherProject, |
| 20 | + matchImportWithWildcard, |
| 21 | + onlyLoadChildren |
| 22 | +} from '@nrwl/workspace/src/utils/runtime-lint-utils'; |
| 23 | +import { TSESTree } from '@typescript-eslint/experimental-utils'; |
| 24 | +import { createESLintRule } from '../utils/create-eslint-rule'; |
| 25 | + |
| 26 | +type Options = [ |
| 27 | + { |
| 28 | + allow: string[]; |
| 29 | + depConstraints: DepConstraint[]; |
| 30 | + } |
| 31 | +]; |
| 32 | +export type MessageIds = |
| 33 | + | 'noRelativeOrAbsoluteImportsAcrossLibraries' |
| 34 | + | 'noCircularDependencies' |
| 35 | + | 'noImportsOfApps' |
| 36 | + | 'noDeepImportsIntoLibraries' |
| 37 | + | 'noImportsOfLazyLoadedLibraries' |
| 38 | + | 'projectWithoutTagsCannotHaveDependencies' |
| 39 | + | 'tagConstraintViolation'; |
| 40 | +export const RULE_NAME = 'enforce-module-boundaries'; |
| 41 | + |
| 42 | +export default createESLintRule<Options, MessageIds>({ |
| 43 | + name: RULE_NAME, |
| 44 | + meta: { |
| 45 | + type: 'suggestion', |
| 46 | + docs: { |
| 47 | + description: `Ensure that module boundaries are respected within the monorepo`, |
| 48 | + category: 'Best Practices', |
| 49 | + recommended: 'error' |
| 50 | + }, |
| 51 | + fixable: 'code', |
| 52 | + schema: [ |
| 53 | + { |
| 54 | + type: 'object', |
| 55 | + properties: { |
| 56 | + allow: [{ type: 'string' }], |
| 57 | + depConstraints: [ |
| 58 | + { |
| 59 | + type: 'object', |
| 60 | + properties: { |
| 61 | + sourceTag: { type: 'string' }, |
| 62 | + onlyDependOnLibsWithTags: [{ type: 'string' }] |
| 63 | + }, |
| 64 | + additionalProperties: false |
| 65 | + } |
| 66 | + ] |
| 67 | + }, |
| 68 | + additionalProperties: false |
| 69 | + } |
| 70 | + ], |
| 71 | + messages: { |
| 72 | + noRelativeOrAbsoluteImportsAcrossLibraries: `Library imports must start with @{{npmScope}}/`, |
| 73 | + noCircularDependencies: `Circular dependency between "{{sourceProjectName}}" and "{{targetProjectName}}" detected`, |
| 74 | + noImportsOfApps: 'Imports of apps are forbidden', |
| 75 | + noDeepImportsIntoLibraries: 'Deep imports into libraries are forbidden', |
| 76 | + noImportsOfLazyLoadedLibraries: `Imports of lazy-loaded libraries are forbidden`, |
| 77 | + projectWithoutTagsCannotHaveDependencies: `A project without tags cannot depend on any libraries`, |
| 78 | + tagConstraintViolation: `A project tagged with "{{sourceTag}}" can only depend on libs tagged with {{allowedTags}}` |
| 79 | + } |
| 80 | + }, |
| 81 | + defaultOptions: [ |
| 82 | + { |
| 83 | + allow: [], |
| 84 | + depConstraints: [] |
| 85 | + } |
| 86 | + ], |
| 87 | + create(context, [{ allow, depConstraints }]) { |
| 88 | + /** |
| 89 | + * Globally cached info about workspace |
| 90 | + */ |
| 91 | + const projectPath = (global as any).projectPath || appRootPath; |
| 92 | + if (!(global as any).projectNodes) { |
| 93 | + const workspaceJson = readWorkspaceJson(); |
| 94 | + const nxJson = readNxJson(); |
| 95 | + (global as any).npmScope = nxJson.npmScope; |
| 96 | + (global as any).projectNodes = getProjectNodes(workspaceJson, nxJson); |
| 97 | + (global as any).deps = readDependencies( |
| 98 | + (global as any).npmScope, |
| 99 | + (global as any).projectNodes |
| 100 | + ); |
| 101 | + } |
| 102 | + const npmScope = (global as any).npmScope; |
| 103 | + const projectNodes = (global as any).projectNodes; |
| 104 | + const deps = (global as any).deps; |
| 105 | + |
| 106 | + projectNodes.sort((a, b) => { |
| 107 | + if (!a.root) return -1; |
| 108 | + if (!b.root) return -1; |
| 109 | + return a.root.length > b.root.length ? -1 : 1; |
| 110 | + }); |
| 111 | + |
| 112 | + return { |
| 113 | + ImportDeclaration(node: TSESTree.ImportDeclaration) { |
| 114 | + const imp = (node.source as TSESTree.Literal).value as string; |
| 115 | + |
| 116 | + const sourceFilePath = getSourceFilePath( |
| 117 | + context.getFilename(), |
| 118 | + projectPath |
| 119 | + ); |
| 120 | + |
| 121 | + // whitelisted import |
| 122 | + if (allow.some(a => matchImportWithWildcard(a, imp))) { |
| 123 | + return; |
| 124 | + } |
| 125 | + |
| 126 | + // check for relative and absolute imports |
| 127 | + if ( |
| 128 | + isRelativeImportIntoAnotherProject( |
| 129 | + imp, |
| 130 | + projectPath, |
| 131 | + projectNodes, |
| 132 | + sourceFilePath |
| 133 | + ) || |
| 134 | + isAbsoluteImportIntoAnotherProject(imp) |
| 135 | + ) { |
| 136 | + context.report({ |
| 137 | + node, |
| 138 | + messageId: 'noRelativeOrAbsoluteImportsAcrossLibraries', |
| 139 | + data: { |
| 140 | + npmScope |
| 141 | + } |
| 142 | + }); |
| 143 | + return; |
| 144 | + } |
| 145 | + |
| 146 | + // check constraints between libs and apps |
| 147 | + if (imp.startsWith(`@${npmScope}/`)) { |
| 148 | + // we should find the name |
| 149 | + const sourceProject = findSourceProject(projectNodes, sourceFilePath); |
| 150 | + // findProjectUsingImport to take care of same prefix |
| 151 | + const targetProject = findProjectUsingImport( |
| 152 | + projectNodes, |
| 153 | + npmScope, |
| 154 | + imp |
| 155 | + ); |
| 156 | + |
| 157 | + // something went wrong => return. |
| 158 | + if (!sourceProject || !targetProject) { |
| 159 | + return; |
| 160 | + } |
| 161 | + |
| 162 | + // check for circular dependency |
| 163 | + if (isCircular(deps, sourceProject, targetProject)) { |
| 164 | + context.report({ |
| 165 | + node, |
| 166 | + messageId: 'noCircularDependencies', |
| 167 | + data: { |
| 168 | + sourceProjectName: sourceProject.name, |
| 169 | + targetProjectName: targetProject.name |
| 170 | + } |
| 171 | + }); |
| 172 | + return; |
| 173 | + } |
| 174 | + |
| 175 | + // same project => allow |
| 176 | + if (sourceProject === targetProject) { |
| 177 | + return; |
| 178 | + } |
| 179 | + |
| 180 | + // cannot import apps |
| 181 | + if (targetProject.type !== ProjectType.lib) { |
| 182 | + context.report({ |
| 183 | + node, |
| 184 | + messageId: 'noImportsOfApps' |
| 185 | + }); |
| 186 | + return; |
| 187 | + } |
| 188 | + |
| 189 | + // deep imports aren't allowed |
| 190 | + if (imp !== `@${npmScope}/${normalizedProjectRoot(targetProject)}`) { |
| 191 | + context.report({ |
| 192 | + node, |
| 193 | + messageId: 'noDeepImportsIntoLibraries' |
| 194 | + }); |
| 195 | + return; |
| 196 | + } |
| 197 | + |
| 198 | + // if we import a library using loadChildren, we should not import it using es6imports |
| 199 | + if ( |
| 200 | + onlyLoadChildren(deps, sourceProject.name, targetProject.name, []) |
| 201 | + ) { |
| 202 | + context.report({ |
| 203 | + node, |
| 204 | + messageId: 'noImportsOfLazyLoadedLibraries' |
| 205 | + }); |
| 206 | + return; |
| 207 | + } |
| 208 | + |
| 209 | + // check that dependency constraints are satisfied |
| 210 | + if (depConstraints.length > 0) { |
| 211 | + const constraints = findConstraintsFor( |
| 212 | + depConstraints, |
| 213 | + sourceProject |
| 214 | + ); |
| 215 | + // when no constrains found => error. Force the user to provision them. |
| 216 | + if (constraints.length === 0) { |
| 217 | + context.report({ |
| 218 | + node, |
| 219 | + messageId: 'projectWithoutTagsCannotHaveDependencies' |
| 220 | + }); |
| 221 | + return; |
| 222 | + } |
| 223 | + |
| 224 | + for (let constraint of constraints) { |
| 225 | + if ( |
| 226 | + hasNoneOfTheseTags( |
| 227 | + targetProject, |
| 228 | + constraint.onlyDependOnLibsWithTags || [] |
| 229 | + ) |
| 230 | + ) { |
| 231 | + const allowedTags = constraint.onlyDependOnLibsWithTags |
| 232 | + .map(s => `"${s}"`) |
| 233 | + .join(', '); |
| 234 | + context.report({ |
| 235 | + node, |
| 236 | + messageId: 'tagConstraintViolation', |
| 237 | + data: { |
| 238 | + sourceTag: constraint.sourceTag, |
| 239 | + allowedTags |
| 240 | + } |
| 241 | + }); |
| 242 | + return; |
| 243 | + } |
| 244 | + } |
| 245 | + } |
| 246 | + } |
| 247 | + } |
| 248 | + }; |
| 249 | + } |
| 250 | +}); |
0 commit comments