Skip to content

Commit d3e2d61

Browse files
authored
feat(linter): add allowedExternalImports option to boundaries rule (#13891)
1 parent 82fbb98 commit d3e2d61

File tree

4 files changed

+159
-8
lines changed

4 files changed

+159
-8
lines changed

docs/shared/recipes/ban-external-imports.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,43 @@ Another common example is ensuring that util libraries stay framework-free by ba
5959
// ... more ESLint config here
6060
}
6161
```
62+
63+
## Whitelisting external imports with `allowedExternalImports`
64+
65+
If you need a more restrictive approach, you can use the `allowedExternalImports` option to ensure that a project only imports from a specific set of packages.
66+
This is useful if you want to enforce separation of concerns _(e.g. keeping your domain logic clean from infrastructure concerns, or ui libraries clean from data access concerns)_ or keep some parts of your codebase framework-free or library-free.
67+
68+
```jsonc {% fileName=".eslintrc.json" %}
69+
{
70+
// ... more ESLint config here
71+
72+
// nx-enforce-module-boundaries should already exist at the top-level of your config
73+
"nx-enforce-module-boundaries": [
74+
"error",
75+
{
76+
"allow": [],
77+
// update depConstraints based on your tags
78+
"depConstraints": [
79+
// limiting the dependencies of util libraries to the bare minimum
80+
// projects tagged with "type:util" can only import from "date-fns"
81+
{
82+
"sourceTag": "type:util",
83+
"allowedExternalImports": ["date-fns"]
84+
},
85+
// ui libraries clean from data access concerns
86+
// projects tagged with "type:ui" can only import pacages matching "@angular/*" except "@angular/common/http"
87+
{
88+
"sourceTag": "type:ui",
89+
"allowedExternalImports": ["@angular/*"],
90+
"bannedExternalImports": ["@angular/common/http"]
91+
},
92+
// keeping the domain logic clean from infrastructure concerns
93+
// projects tagged with "type:core" can't import any external packages.
94+
{
95+
"sourceTag": "type:core",
96+
"allowedExternalImports": []
97+
}
98+
]
99+
}
100+
]
101+
```

packages/eslint-plugin-nx/src/rules/enforce-module-boundaries.spec.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,106 @@ describe('Enforce Module Boundaries (eslint)', () => {
463463
expect(failures[1].message).toEqual(message);
464464
});
465465

466+
it('should not error when importing npm packages matching allowed external imports', () => {
467+
const failures = runRule(
468+
{
469+
depConstraints: [
470+
{ sourceTag: 'api', allowedExternalImports: ['npm-package'] },
471+
],
472+
},
473+
`${process.cwd()}/proj/libs/api/src/index.ts`,
474+
`
475+
import 'npm-package';
476+
import('npm-package');
477+
`,
478+
graph
479+
);
480+
481+
expect(failures.length).toEqual(0);
482+
});
483+
484+
it('should error when importing npm packages not matching allowed external imports', () => {
485+
const failures = runRule(
486+
{
487+
depConstraints: [
488+
{ sourceTag: 'api', allowedExternalImports: ['npm-package'] },
489+
],
490+
},
491+
`${process.cwd()}/proj/libs/api/src/index.ts`,
492+
`
493+
import 'npm-awesome-package';
494+
import('npm-awesome-package');
495+
`,
496+
graph
497+
);
498+
499+
const message =
500+
'A project tagged with "api" is not allowed to import the "npm-awesome-package" package';
501+
expect(failures.length).toEqual(2);
502+
expect(failures[0].message).toEqual(message);
503+
expect(failures[1].message).toEqual(message);
504+
});
505+
506+
it('should not error when importing npm packages matching allowed glob pattern', () => {
507+
const failures = runRule(
508+
{
509+
depConstraints: [
510+
{ sourceTag: 'api', allowedExternalImports: ['npm-awesome-*'] },
511+
],
512+
},
513+
`${process.cwd()}/proj/libs/api/src/index.ts`,
514+
`
515+
import 'npm-awesome-package';
516+
import('npm-awesome-package');
517+
`,
518+
graph
519+
);
520+
521+
expect(failures.length).toEqual(0);
522+
});
523+
524+
it('should error when importing npm packages not matching allowed glob pattern', () => {
525+
const failures = runRule(
526+
{
527+
depConstraints: [
528+
{ sourceTag: 'api', allowedExternalImports: ['npm-awesome-*'] },
529+
],
530+
},
531+
`${process.cwd()}/proj/libs/api/src/index.ts`,
532+
`
533+
import 'npm-package';
534+
import('npm-package');
535+
`,
536+
graph
537+
);
538+
539+
const message =
540+
'A project tagged with "api" is not allowed to import the "npm-package" package';
541+
expect(failures.length).toEqual(2);
542+
expect(failures[0].message).toEqual(message);
543+
expect(failures[1].message).toEqual(message);
544+
});
545+
546+
it('should error when importing any npm package if none is allowed', () => {
547+
const failures = runRule(
548+
{
549+
depConstraints: [{ sourceTag: 'api', allowedExternalImports: [] }],
550+
},
551+
`${process.cwd()}/proj/libs/api/src/index.ts`,
552+
`
553+
import 'npm-package';
554+
import('npm-package');
555+
`,
556+
graph
557+
);
558+
559+
const message =
560+
'A project tagged with "api" is not allowed to import the "npm-package" package';
561+
expect(failures.length).toEqual(2);
562+
expect(failures[0].message).toEqual(message);
563+
expect(failures[1].message).toEqual(message);
564+
});
565+
466566
it('should error when importing transitive npm packages', () => {
467567
const failures = runRule(
468568
{

packages/eslint-plugin-nx/src/rules/enforce-module-boundaries.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export default createESLintRule<Options, MessageIds>({
103103
},
104104
],
105105
onlyDependOnLibsWithTags: [{ type: 'string' }],
106+
allowedExternalImports: [{ type: 'string' }],
106107
bannedExternalImports: [{ type: 'string' }],
107108
notDependOnLibsWithTags: [{ type: 'string' }],
108109
},

packages/eslint-plugin-nx/src/utils/runtime-lint-utils.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@ type SingleSourceTagConstraint = {
2525
sourceTag: string;
2626
onlyDependOnLibsWithTags?: string[];
2727
notDependOnLibsWithTags?: string[];
28+
allowedExternalImports?: string[];
2829
bannedExternalImports?: string[];
2930
};
3031
type ComboSourceTagConstraint = {
3132
allSourceTags: string[];
3233
onlyDependOnLibsWithTags?: string[];
3334
notDependOnLibsWithTags?: string[];
35+
allowedExternalImports?: string[];
3436
bannedExternalImports?: string[];
3537
};
3638
export type DepConstraint =
@@ -209,10 +211,22 @@ function isConstraintBanningProject(
209211
externalProject: ProjectGraphExternalNode,
210212
constraint: DepConstraint
211213
): boolean {
212-
return constraint.bannedExternalImports.some((importDefinition) =>
213-
parseImportWildcards(importDefinition).test(
214-
externalProject.data.packageName
214+
const { allowedExternalImports, bannedExternalImports } = constraint;
215+
const { packageName } = externalProject.data;
216+
217+
/* Check if import is banned... */
218+
if (
219+
bannedExternalImports?.some((importDefinition) =>
220+
parseImportWildcards(importDefinition).test(packageName)
215221
)
222+
) {
223+
return true;
224+
}
225+
226+
/* ... then check if there is a whitelist and if there is a match in the whitelist. */
227+
return allowedExternalImports?.every(
228+
(importDefinition) =>
229+
!parseImportWildcards(importDefinition).test(packageName)
216230
);
217231
}
218232

@@ -230,11 +244,7 @@ export function hasBannedImport(
230244
tags = [c.sourceTag];
231245
}
232246

233-
return (
234-
c.bannedExternalImports &&
235-
c.bannedExternalImports.length &&
236-
tags.every((t) => (source.data.tags || []).includes(t))
237-
);
247+
return tags.every((t) => (source.data.tags || []).includes(t));
238248
});
239249
return depConstraints.find((constraint) =>
240250
isConstraintBanningProject(target, constraint)

0 commit comments

Comments
 (0)