From c4c8b0150d827657632596656d92040108a338aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Jona=C5=A1?= Date: Tue, 3 Sep 2024 20:33:41 +0200 Subject: [PATCH] feat(linter): add option to exclude projects from circular deps check (#27504) ## Current Behavior ## Expected Behavior ## Related Issue(s) Fixes # --------- Co-authored-by: FrozenPandaz --- .../documents/enforce-module-boundaries.md | 26 +- .../eslint/enforce-module-boundaries.md | 26 +- .../rules/enforce-module-boundaries.spec.ts | 338 ++++++++++++++++++ .../src/rules/enforce-module-boundaries.ts | 33 +- .../src/utils/graph-utils.spec.ts | 313 ++++++++-------- .../eslint-plugin/src/utils/graph-utils.ts | 56 +++ 6 files changed, 627 insertions(+), 165 deletions(-) diff --git a/docs/generated/packages/eslint-plugin/documents/enforce-module-boundaries.md b/docs/generated/packages/eslint-plugin/documents/enforce-module-boundaries.md index 0c2d127ad7383..24a302fc448f7 100644 --- a/docs/generated/packages/eslint-plugin/documents/enforce-module-boundaries.md +++ b/docs/generated/packages/eslint-plugin/documents/enforce-module-boundaries.md @@ -1,6 +1,7 @@ # Enforce module boundaries rule -The `@nx/enforce-module-boundaries` ESLint rule enables you to define strict rules for accessing resources between different projects in the repository. Enforcing strict boundaries helps to prevent unplanned cross-dependencies. +The `@nx/enforce-module-boundaries` ESLint rule enables you to define strict rules for accessing resources between +different projects in the repository. Enforcing strict boundaries helps to prevent unplanned cross-dependencies. ## Usage @@ -28,19 +29,22 @@ You can use the `enforce-module-boundaries` rule by adding it to your ESLint rul ## Options -| Property | Type | Default | Description | -| ---------------------------------- | --------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| allow | _Array_ | _[]_ | List of imports that should be allowed without any checks | -| allowCircularSelfDependency | _boolean_ | _false_ | Disable check for self circular dependency when project imports from itself via alias path | -| banTransitiveDependencies | _boolean_ | _false_ | Ban import of dependencies that were not specified in the root or project's `package.json` | -| checkDynamicDependenciesExceptions | _Array_ | _[]_ | List of imports that should be skipped for `Imports of lazy-loaded libraries forbidden` checks. E.g. `['@myorg/lazy-project/component/*', '@myorg/other-project']` | -| checkNestedExternalImports | _boolean_ | _false_ | Enable to enforce the check for banned external imports in the nested packages. Check [Dependency constraits](#dependency-constraits) for more information | -| enforceBuildableLibDependency | _boolean_ | _false_ | Enable to restrict the buildable libs from importing non-buildable libraries | -| depConstraints | _Array_ | _[]_ | List of dependency constraints between projects | +| Property | Type | Default | Description | +| ---------------------------------- | ------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| allow | _Array_ | _[]_ | List of imports that should be allowed without any checks | +| allowCircularSelfDependency | _boolean_ | _false_ | Disable check for self circular dependency when project imports from itself via alias path | +| banTransitiveDependencies | _boolean_ | _false_ | Ban import of dependencies that were not specified in the root or project's `package.json` | +| ignoredCircularDependencies | _Array<[string, string]>_ | _[]_ | List of project pairs that should be skipped from `Circular dependencies` checks, including the self-circular dependency check. E.g. `['feature-project-a', 'myapp']`. Project name can be replaced by catch all `*` for more generic matches. | +| checkDynamicDependenciesExceptions | _Array_ | _[]_ | List of imports that should be skipped for `Imports of lazy-loaded libraries forbidden` checks. E.g. `['@myorg/lazy-project/component/*', '@myorg/other-project']` | +| checkNestedExternalImports | _boolean_ | _false_ | Enable to enforce the check for banned external imports in the nested packages. Check [Dependency constraits](#dependency-constraits) for more information | +| enforceBuildableLibDependency | _boolean_ | _false_ | Enable to restrict the buildable libs from importing non-buildable libraries | +| depConstraints | _Array_ | _[]_ | List of dependency constraints between projects | ### Dependency constraints -The `depConstraints` is an array of objects representing the constraints defined between source and target projects. A constraint must include `sourceTag` or `allSourceTags`. The constraints are applied with **AND** logical operation - for given `source` project the resulting constraints would be **all** that match its tags. +The `depConstraints` is an array of objects representing the constraints defined between source and target projects. A +constraint must include `sourceTag` or `allSourceTags`. The constraints are applied with **AND** logical operation - for +a given `source` project the resulting constraints would be **all** that match its tags. | Property | Type | Description | | ------------------------ | --------------- | ---------------------------------------------------------------------------------- | diff --git a/docs/shared/packages/eslint/enforce-module-boundaries.md b/docs/shared/packages/eslint/enforce-module-boundaries.md index 0c2d127ad7383..24a302fc448f7 100644 --- a/docs/shared/packages/eslint/enforce-module-boundaries.md +++ b/docs/shared/packages/eslint/enforce-module-boundaries.md @@ -1,6 +1,7 @@ # Enforce module boundaries rule -The `@nx/enforce-module-boundaries` ESLint rule enables you to define strict rules for accessing resources between different projects in the repository. Enforcing strict boundaries helps to prevent unplanned cross-dependencies. +The `@nx/enforce-module-boundaries` ESLint rule enables you to define strict rules for accessing resources between +different projects in the repository. Enforcing strict boundaries helps to prevent unplanned cross-dependencies. ## Usage @@ -28,19 +29,22 @@ You can use the `enforce-module-boundaries` rule by adding it to your ESLint rul ## Options -| Property | Type | Default | Description | -| ---------------------------------- | --------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| allow | _Array_ | _[]_ | List of imports that should be allowed without any checks | -| allowCircularSelfDependency | _boolean_ | _false_ | Disable check for self circular dependency when project imports from itself via alias path | -| banTransitiveDependencies | _boolean_ | _false_ | Ban import of dependencies that were not specified in the root or project's `package.json` | -| checkDynamicDependenciesExceptions | _Array_ | _[]_ | List of imports that should be skipped for `Imports of lazy-loaded libraries forbidden` checks. E.g. `['@myorg/lazy-project/component/*', '@myorg/other-project']` | -| checkNestedExternalImports | _boolean_ | _false_ | Enable to enforce the check for banned external imports in the nested packages. Check [Dependency constraits](#dependency-constraits) for more information | -| enforceBuildableLibDependency | _boolean_ | _false_ | Enable to restrict the buildable libs from importing non-buildable libraries | -| depConstraints | _Array_ | _[]_ | List of dependency constraints between projects | +| Property | Type | Default | Description | +| ---------------------------------- | ------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| allow | _Array_ | _[]_ | List of imports that should be allowed without any checks | +| allowCircularSelfDependency | _boolean_ | _false_ | Disable check for self circular dependency when project imports from itself via alias path | +| banTransitiveDependencies | _boolean_ | _false_ | Ban import of dependencies that were not specified in the root or project's `package.json` | +| ignoredCircularDependencies | _Array<[string, string]>_ | _[]_ | List of project pairs that should be skipped from `Circular dependencies` checks, including the self-circular dependency check. E.g. `['feature-project-a', 'myapp']`. Project name can be replaced by catch all `*` for more generic matches. | +| checkDynamicDependenciesExceptions | _Array_ | _[]_ | List of imports that should be skipped for `Imports of lazy-loaded libraries forbidden` checks. E.g. `['@myorg/lazy-project/component/*', '@myorg/other-project']` | +| checkNestedExternalImports | _boolean_ | _false_ | Enable to enforce the check for banned external imports in the nested packages. Check [Dependency constraits](#dependency-constraits) for more information | +| enforceBuildableLibDependency | _boolean_ | _false_ | Enable to restrict the buildable libs from importing non-buildable libraries | +| depConstraints | _Array_ | _[]_ | List of dependency constraints between projects | ### Dependency constraints -The `depConstraints` is an array of objects representing the constraints defined between source and target projects. A constraint must include `sourceTag` or `allSourceTags`. The constraints are applied with **AND** logical operation - for given `source` project the resulting constraints would be **all** that match its tags. +The `depConstraints` is an array of objects representing the constraints defined between source and target projects. A +constraint must include `sourceTag` or `allSourceTags`. The constraints are applied with **AND** logical operation - for +a given `source` project the resulting constraints would be **all** that match its tags. | Property | Type | Description | | ------------------------ | --------------- | ---------------------------------------------------------------------------------- | diff --git a/packages/eslint-plugin/src/rules/enforce-module-boundaries.spec.ts b/packages/eslint-plugin/src/rules/enforce-module-boundaries.spec.ts index 3d83b543bc991..721be4b1fdc11 100644 --- a/packages/eslint-plugin/src/rules/enforce-module-boundaries.spec.ts +++ b/packages/eslint-plugin/src/rules/enforce-module-boundaries.spec.ts @@ -1609,6 +1609,68 @@ Violation detected in: expect(failures.length).toBe(0); }); + it('should ignore detected absolute path within project if ignoredCircularDependencies matches the project', () => { + const failures = runRule( + { + ignoredCircularDependencies: [['mylibName', 'mylibName']], + }, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + ` + import '@mycompany/mylib'; + import('@mycompany/mylib'); + `, + { + nodes: { + mylibName: { + name: 'mylibName', + type: 'lib', + data: { + root: 'libs/mylib', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + anotherlibName: { + name: 'anotherlibName', + type: 'lib', + data: { + root: 'libs/anotherlib', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + myappName: { + name: 'myappName', + type: 'app', + data: { + root: 'apps/myapp', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + }, + dependencies: { + mylibName: [ + { + source: 'mylibName', + target: 'anotherlibName', + type: DependencyType.static, + }, + ], + }, + }, + { + mylibName: [createFile(`libs/mylib/src/main.ts`)], + anotherlibName: [createFile(`libs/anotherlib/src/main.ts`)], + myappName: [createFile(`apps/myapp/src/index.ts`)], + } + ); + expect(failures.length).toBe(0); + }); + it('should error when circular dependency detected', () => { const failures = runRule( {}, @@ -1781,6 +1843,282 @@ Circular file chain: expect(failures[1].message).toEqual(message); }); + it('should not error when circular dependency detected (indirect) if ignoredCircularDependencies matches link in chain', () => { + const failures = runRule( + { + ignoredCircularDependencies: [['anotherlibName', 'mylibName']], + }, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + ` + import '@mycompany/badcirclelib'; + import('@mycompany/badcirclelib'); + `, + { + nodes: { + mylibName: { + name: 'mylibName', + type: 'lib', + data: { + root: 'libs/mylib', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + anotherlibName: { + name: 'anotherlibName', + type: 'lib', + data: { + root: 'libs/anotherlib', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + badcirclelibName: { + name: 'badcirclelibName', + type: 'lib', + data: { + root: 'libs/badcirclelib', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + myappName: { + name: 'myappName', + type: 'app', + data: { + root: 'apps/myapp', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + }, + dependencies: { + mylibName: [ + { + source: 'mylibName', + target: 'badcirclelibName', + type: DependencyType.static, + }, + ], + badcirclelibName: [ + { + source: 'badcirclelibName', + target: 'anotherlibName', + type: DependencyType.static, + }, + ], + anotherlibName: [ + { + source: 'anotherlibName', + target: 'mylibName', + type: DependencyType.static, + }, + ], + }, + }, + { + mylibName: [createFile(`libs/mylib/src/main.ts`, ['mylibName'])], + anotherlibName: [ + createFile(`libs/anotherlib/src/main.ts`, ['mylibName']), + createFile(`libs/anotherlib/src/index.ts`, ['mylibName']), + ], + badcirclelibName: [ + createFile(`libs/badcirclelib/src/main.ts`, ['anotherlibName']), + ], + myappName: [createFile(`apps/myapp/index.ts`)], + } + ); + expect(failures.length).toEqual(0); + }); + + it('should not error when circular dependency detected (indirect) if ignoredCircularDependencies matches link in chain (second member catch all)', () => { + const failures = runRule( + { + ignoredCircularDependencies: [['anotherlibName', '*']], + }, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + ` + import '@mycompany/badcirclelib'; + import('@mycompany/badcirclelib'); + `, + { + nodes: { + mylibName: { + name: 'mylibName', + type: 'lib', + data: { + root: 'libs/mylib', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + anotherlibName: { + name: 'anotherlibName', + type: 'lib', + data: { + root: 'libs/anotherlib', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + badcirclelibName: { + name: 'badcirclelibName', + type: 'lib', + data: { + root: 'libs/badcirclelib', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + myappName: { + name: 'myappName', + type: 'app', + data: { + root: 'apps/myapp', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + }, + dependencies: { + mylibName: [ + { + source: 'mylibName', + target: 'badcirclelibName', + type: DependencyType.static, + }, + ], + badcirclelibName: [ + { + source: 'badcirclelibName', + target: 'anotherlibName', + type: DependencyType.static, + }, + ], + anotherlibName: [ + { + source: 'anotherlibName', + target: 'mylibName', + type: DependencyType.static, + }, + ], + }, + }, + { + mylibName: [createFile(`libs/mylib/src/main.ts`, ['mylibName'])], + anotherlibName: [ + createFile(`libs/anotherlib/src/main.ts`, ['mylibName']), + createFile(`libs/anotherlib/src/index.ts`, ['mylibName']), + ], + badcirclelibName: [ + createFile(`libs/badcirclelib/src/main.ts`, ['anotherlibName']), + ], + myappName: [createFile(`apps/myapp/index.ts`)], + } + ); + expect(failures.length).toEqual(0); + }); + + it('should not error when circular dependency detected (indirect) if ignoredCircularDependencies matches link in chain (first member catch all)', () => { + const failures = runRule( + { + ignoredCircularDependencies: [['*', 'mylibName']], + }, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + ` + import '@mycompany/badcirclelib'; + import('@mycompany/badcirclelib'); + `, + { + nodes: { + mylibName: { + name: 'mylibName', + type: 'lib', + data: { + root: 'libs/mylib', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + anotherlibName: { + name: 'anotherlibName', + type: 'lib', + data: { + root: 'libs/anotherlib', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + badcirclelibName: { + name: 'badcirclelibName', + type: 'lib', + data: { + root: 'libs/badcirclelib', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + myappName: { + name: 'myappName', + type: 'app', + data: { + root: 'apps/myapp', + tags: [], + implicitDependencies: [], + targets: {}, + }, + }, + }, + dependencies: { + mylibName: [ + { + source: 'mylibName', + target: 'badcirclelibName', + type: DependencyType.static, + }, + ], + badcirclelibName: [ + { + source: 'badcirclelibName', + target: 'anotherlibName', + type: DependencyType.static, + }, + ], + anotherlibName: [ + { + source: 'anotherlibName', + target: 'mylibName', + type: DependencyType.static, + }, + ], + }, + }, + { + mylibName: [createFile(`libs/mylib/src/main.ts`, ['mylibName'])], + anotherlibName: [ + createFile(`libs/anotherlib/src/main.ts`, ['mylibName']), + createFile(`libs/anotherlib/src/index.ts`, ['mylibName']), + ], + badcirclelibName: [ + createFile(`libs/badcirclelib/src/main.ts`, ['anotherlibName']), + ], + myappName: [createFile(`apps/myapp/index.ts`)], + } + ); + expect(failures.length).toEqual(0); + }); + describe('buildable library imports', () => { it('should ignore the buildable library verification if the enforceBuildableLibDependency is set to false', () => { const failures = runRule( diff --git a/packages/eslint-plugin/src/rules/enforce-module-boundaries.ts b/packages/eslint-plugin/src/rules/enforce-module-boundaries.ts index 846aa4d4cebab..b1323b1538aae 100644 --- a/packages/eslint-plugin/src/rules/enforce-module-boundaries.ts +++ b/packages/eslint-plugin/src/rules/enforce-module-boundaries.ts @@ -21,6 +21,8 @@ import { } from '../utils/ast-utils'; import { checkCircularPath, + circularPathHasPair, + expandIgnoredCircularDependencies, findFilesInCircularPath, findFilesWithDynamicImports, } from '../utils/graph-utils'; @@ -56,6 +58,7 @@ type Options = [ depConstraints: DepConstraint[]; enforceBuildableLibDependency: boolean; allowCircularSelfDependency: boolean; + ignoredCircularDependencies: Array<[string, string]>; checkDynamicDependenciesExceptions: string[]; banTransitiveDependencies: boolean; checkNestedExternalImports: boolean; @@ -101,6 +104,15 @@ export default ESLintUtils.RuleCreator( type: 'array', items: { type: 'string' }, }, + ignoredCircularDependencies: { + type: 'array', + items: { + type: 'array', + items: { type: 'string' }, + minItems: 2, + maxItems: 2, + }, + }, banTransitiveDependencies: { type: 'boolean' }, checkNestedExternalImports: { type: 'boolean' }, allow: { type: 'array', items: { type: 'string' } }, @@ -194,6 +206,7 @@ export default ESLintUtils.RuleCreator( enforceBuildableLibDependency: false, allowCircularSelfDependency: false, checkDynamicDependenciesExceptions: [], + ignoredCircularDependencies: [], banTransitiveDependencies: false, checkNestedExternalImports: false, }, @@ -208,6 +221,7 @@ export default ESLintUtils.RuleCreator( enforceBuildableLibDependency, allowCircularSelfDependency, checkDynamicDependenciesExceptions, + ignoredCircularDependencies, banTransitiveDependencies, checkNestedExternalImports, }, @@ -234,6 +248,12 @@ export default ESLintUtils.RuleCreator( const workspaceLayout = (global as any).workspaceLayout; + const expandedIgnoreCircularDependencies = + expandIgnoredCircularDependencies( + ignoredCircularDependencies, + projectGraph + ); + function run( node: | TSESTree.ImportDeclaration @@ -383,7 +403,13 @@ export default ESLintUtils.RuleCreator( // 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 ( + sourceProject === targetProject && + !circularPathHasPair( + [sourceProject, targetProject], + expandedIgnoreCircularDependencies + ) + ) { if ( !allowCircularSelfDependency && !isRelativePath(imp) && @@ -509,7 +535,10 @@ export default ESLintUtils.RuleCreator( sourceProject, targetProject ); - if (circularPath.length !== 0) { + if ( + circularPath.length !== 0 && + !circularPathHasPair(circularPath, expandedIgnoreCircularDependencies) + ) { const circularFilePath = findFilesInCircularPath( projectFileMap, circularPath diff --git a/packages/eslint-plugin/src/utils/graph-utils.spec.ts b/packages/eslint-plugin/src/utils/graph-utils.spec.ts index 0d49eab2c33d9..19450e30fef4c 100644 --- a/packages/eslint-plugin/src/utils/graph-utils.spec.ts +++ b/packages/eslint-plugin/src/utils/graph-utils.spec.ts @@ -1,158 +1,189 @@ import type { ProjectGraph } from '@nx/devkit'; -import { checkCircularPath } from './graph-utils'; +import { + checkCircularPath, + expandIgnoredCircularDependencies, +} from './graph-utils'; -describe('should find the path between nodes', () => { - it('should return empty path when when there are no connecting edges', () => { - /* +describe('graph utils', () => { + describe('should find the path between nodes', () => { + it('should return empty path when when there are no connecting edges', () => { + /* - A -> B -> C - > E + A -> B -> C - > E - */ + */ - const graph = { - nodes: ['A', 'B'], - dependencies: { - A: ['B'], - }, - }; + const graph = { + nodes: ['A', 'B'], + dependencies: { + A: ['B'], + }, + }; - const g = transformGraph(graph); - const path = getPath(g, { from: 'B', to: 'A' }); + const g = transformGraph(graph); + const path = getPath(g, { from: 'B', to: 'A' }); - expect(path).toEqual([]); - }); + expect(path).toEqual([]); + }); - it('should find direct path', () => { - /* + it('should find direct path', () => { + /* - A -> B + A -> B - */ + */ - const graph = { - nodes: ['A', 'B'], - dependencies: { - A: ['B'], - }, - }; + const graph = { + nodes: ['A', 'B'], + dependencies: { + A: ['B'], + }, + }; + + const g = transformGraph(graph); + const path = getPath(g, { from: 'A', to: 'B' }); + + expect(path).toEqual(['A', 'B']); + }); + + it('should find indirect path', () => { + /* + + A -> B -> E -> F + \ + C -> D + + */ + + const graph = { + nodes: ['A', 'B', 'C', 'D', 'E', 'F'], + dependencies: { + A: ['B'], + B: ['C', 'E'], + C: ['D'], + E: ['F'], + }, + }; + + const g = transformGraph(graph); + const path = getPath(g, { from: 'A', to: 'F' }); + + expect(path).toEqual(['A', 'B', 'E', 'F']); + }); + + it('should find indirect path in a graph that has a simple cycle', () => { + /* + + A -> B -> C -> F + \ / + E <-- D + + */ + + const graph = { + nodes: ['A', 'B', 'C', 'D', 'E', 'F'], + dependencies: { + A: ['B'], + B: ['C'], + C: ['D', 'F'], + D: ['E'], + E: ['A'], + }, + }; + + const g = transformGraph(graph); + const path = getPath(g, { from: 'A', to: 'F' }); + + expect(path).toEqual(['A', 'B', 'C', 'F']); + }); + + it('should find indirect path in a graph that has a cycle', () => { + /* + + B <- A -> D -> E + \ /^ + C - const g = transformGraph(graph); - const path = getPath(g, { from: 'A', to: 'B' }); - - expect(path).toEqual(['A', 'B']); + */ + + const graph = { + nodes: ['A', 'B', 'C', 'D', 'E'], + dependencies: { + A: ['B', 'D'], + B: ['C'], + C: ['A'], + D: ['E'], + }, + }; + + const g = transformGraph(graph); + const path = getPath(g, { from: 'A', to: 'E' }); + + expect(path).toEqual(['A', 'D', 'E']); + }); + + it('should find indirect path in a graph with inner and outer cycles', () => { + /* + + A --> B + /^ \ /^ \ + C D E F + ^\ / ^\ / + G <-- H + + */ + + const graph = { + nodes: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'], + dependencies: { + A: ['B', 'D'], + B: ['F'], + C: ['A'], + D: ['G'], + E: ['B'], + F: ['H'], + G: ['C'], + H: ['G', 'E'], + }, + }; + + const g = transformGraph(graph); + + const path1 = getPath(g, { from: 'A', to: 'H' }); + expect(path1).toEqual(['A', 'B', 'F', 'H']); + + const path2 = getPath(g, { from: 'A', to: 'G' }); + expect(path2).toEqual(['A', 'D', 'G']); + + const path3 = getPath(g, { from: 'B', to: 'D' }); + expect(path3).toEqual(['B', 'F', 'H', 'G', 'C', 'A', 'D']); + }); }); - it('should find indirect path', () => { - /* - - A -> B -> E -> F - \ - C -> D - - */ - - const graph = { - nodes: ['A', 'B', 'C', 'D', 'E', 'F'], - dependencies: { - A: ['B'], - B: ['C', 'E'], - C: ['D'], - E: ['F'], - }, - }; - - const g = transformGraph(graph); - const path = getPath(g, { from: 'A', to: 'F' }); - - expect(path).toEqual(['A', 'B', 'E', 'F']); - }); - - it('should find indirect path in a graph that has a simple cycle', () => { - /* - - A -> B -> C -> F - \ / - E <-- D - - */ - - const graph = { - nodes: ['A', 'B', 'C', 'D', 'E', 'F'], - dependencies: { - A: ['B'], - B: ['C'], - C: ['D', 'F'], - D: ['E'], - E: ['A'], - }, - }; - - const g = transformGraph(graph); - const path = getPath(g, { from: 'A', to: 'F' }); - - expect(path).toEqual(['A', 'B', 'C', 'F']); - }); - - it('should find indirect path in a graph that has a cycle', () => { - /* - - B <- A -> D -> E - \ /^ - C - - */ - - const graph = { - nodes: ['A', 'B', 'C', 'D', 'E'], - dependencies: { - A: ['B', 'D'], - B: ['C'], - C: ['A'], - D: ['E'], - }, - }; - - const g = transformGraph(graph); - const path = getPath(g, { from: 'A', to: 'E' }); - - expect(path).toEqual(['A', 'D', 'E']); - }); - - it('should find indirect path in a graph with inner and outer cycles', () => { - /* - - A --> B - /^ \ /^ \ - C D E F - ^\ / ^\ / - G <-- H - - */ - - const graph = { - nodes: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'], - dependencies: { - A: ['B', 'D'], - B: ['F'], - C: ['A'], - D: ['G'], - E: ['B'], - F: ['H'], - G: ['C'], - H: ['G', 'E'], - }, - }; - - const g = transformGraph(graph); - - const path1 = getPath(g, { from: 'A', to: 'H' }); - expect(path1).toEqual(['A', 'B', 'F', 'H']); - - const path2 = getPath(g, { from: 'A', to: 'G' }); - expect(path2).toEqual(['A', 'D', 'G']); - - const path3 = getPath(g, { from: 'B', to: 'D' }); - expect(path3).toEqual(['B', 'F', 'H', 'G', 'C', 'A', 'D']); + describe('expandIgnoreCircularDependencies', () => { + let graph: ProjectGraph; + beforeEach(() => { + graph = { + nodes: { + a: { name: 'a', type: 'lib', data: { root: 'a' } }, + b: { name: 'b', type: 'lib', data: { root: 'b' } }, + c: { name: 'c', type: 'lib', data: { root: 'c' } }, + d: { name: 'd', type: 'lib', data: { root: 'd' } }, + }, + dependencies: {}, + }; + }); + + it('should return a map with all combinations of ignored circular dependencies', () => { + const res = expandIgnoredCircularDependencies([['a', 'b']], graph); + + expect(res.get('a')).toContain('b'); + expect(res.get('b')).toContain('a'); + + expect(res.get('a')).not.toContain('c'); + expect(res.get('b')).not.toContain('c'); + expect(res.get('c')).not.toBeDefined(); + }); }); }); diff --git a/packages/eslint-plugin/src/utils/graph-utils.ts b/packages/eslint-plugin/src/utils/graph-utils.ts index bb4ef5107f192..ba407cc7e0dc1 100644 --- a/packages/eslint-plugin/src/utils/graph-utils.ts +++ b/packages/eslint-plugin/src/utils/graph-utils.ts @@ -9,6 +9,7 @@ import { fileDataDepTarget, fileDataDepType, } from 'nx/src/config/project-graph'; +import { findMatchingProjects } from 'nx/src/utils/find-matching-projects'; interface Reach { graph: ProjectGraph; @@ -141,6 +142,25 @@ export function checkCircularPath( return getPath(graph, targetProject.name, sourceProject.name); } +export function circularPathHasPair( + circularPath: ProjectGraphProjectNode[], + ignored: Map> +): boolean { + if (circularPath.length < 2) return false; + + for (let i = 0; i < circularPath.length - 1; i++) { + const dependencyIsIgnored = ignored + .get(circularPath[i].name) + ?.has(circularPath[i + 1].name); + + if (dependencyIsIgnored) { + return true; + } + } + + return false; +} + export function findFilesInCircularPath( projectFileMap: ProjectFileMap, circularPath: ProjectGraphProjectNode[] @@ -184,3 +204,39 @@ export function findFilesWithDynamicImports( return files; } + +export function expandIgnoredCircularDependencies( + ignoredCircularDependencies: Array<[string, string]>, + projectGraph: ProjectGraph +) { + const allowed = new Map>(); + + for (const [a, b] of ignoredCircularDependencies) { + const setA = new Set(findMatchingProjects([a], projectGraph.nodes)); + const setB = new Set(findMatchingProjects([b], projectGraph.nodes)); + + for (const projectA of setA) { + if (!allowed.has(projectA)) { + allowed.set(projectA, new Set()); + } + const currentSetA = allowed.get(projectA); + + for (const projectB of setB) { + currentSetA.add(projectB); + } + } + + for (const projectB of setB) { + if (!allowed.has(projectB)) { + allowed.set(projectB, new Set()); + } + const currentSetB = allowed.get(projectB); + + for (const projectA of setA) { + currentSetB.add(projectA); + } + } + } + + return allowed; +}