-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(eslint-plugin): [no-circular-imports] add new rule #8965
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
--- | ||
description: 'Disallow the use of import module that result in circular imports.' | ||
--- | ||
|
||
import Tabs from '@theme/Tabs'; | ||
import TabItem from '@theme/TabItem'; | ||
|
||
> 🛑 This file is source code, not the primary documentation location! 🛑 | ||
> | ||
> See **https://typescript-eslint.io/rules/no-circular-import** for documentation. | ||
This rule disallows the use of import module that result in circular imports except for the type-only imports. | ||
|
||
## Examples | ||
|
||
<Tabs> | ||
<TabItem value="❌ Incorrect"> | ||
|
||
```ts skipValidation | ||
// foo.ts | ||
import { bar } from './bar'; | ||
export const foo = 1; | ||
|
||
// bar.ts | ||
import { foo } from './foo'; | ||
export const bar = 1; | ||
``` | ||
|
||
</TabItem> | ||
<TabItem value="✅ Correct"> | ||
|
||
```ts | ||
// foo.ts | ||
import type { bar } from './bar'; | ||
export type baz = number; | ||
|
||
// bar.ts | ||
import { type baz } from './baz'; | ||
export type bar = number; | ||
``` | ||
|
||
</TabItem> | ||
</Tabs> | ||
|
||
## When Not To Use It | ||
|
||
## Related To |
Original file line number | Diff line number | Diff line change | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,181 @@ | ||||||||||||
import * as ts from 'typescript'; | ||||||||||||
|
||||||||||||
import { createRule, getParserServices } from '../util'; | ||||||||||||
|
||||||||||||
type Options = []; | ||||||||||||
type MessageIds = 'noCircularImport'; | ||||||||||||
|
||||||||||||
interface Edge { | ||||||||||||
filename: string; | ||||||||||||
specifier: string; | ||||||||||||
} | ||||||||||||
|
||||||||||||
class Graph { | ||||||||||||
private graph = new Map< | ||||||||||||
string, | ||||||||||||
{ | ||||||||||||
filename: string; | ||||||||||||
specifier: string; | ||||||||||||
}[] | ||||||||||||
>(); | ||||||||||||
|
||||||||||||
addEdge(start: string, edge: Edge): void { | ||||||||||||
if (this.graph.has(start)) { | ||||||||||||
this.graph.get(start)?.push(edge); | ||||||||||||
} else { | ||||||||||||
this.graph.set(start, [edge]); | ||||||||||||
} | ||||||||||||
} | ||||||||||||
|
||||||||||||
hasEdge(name: string): boolean { | ||||||||||||
return this.graph.has(name); | ||||||||||||
} | ||||||||||||
|
||||||||||||
getEdges(name: string): Edge[] { | ||||||||||||
return this.graph.get(name) ?? []; | ||||||||||||
} | ||||||||||||
Comment on lines
+30
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I couldn't understand this graph at a glance. I think it's supposed to be an adjancency list indexed by the file names, and the specifiers are extra data for each edge right? This should be added as comments. These functions' names are not apparent. I think they should be called |
||||||||||||
} | ||||||||||||
// imports “a.ts” and is imported from “b.ts”, resulting in a circular reference. | ||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Docs] Not really a place for this, since we have the full docs page.
Suggested change
|
||||||||||||
export default createRule<Options, MessageIds>({ | ||||||||||||
name: 'no-circular-import', | ||||||||||||
meta: { | ||||||||||||
docs: { | ||||||||||||
description: | ||||||||||||
'Disallow the use of import module that result in circular imports', | ||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||
requiresTypeChecking: true, | ||||||||||||
}, | ||||||||||||
messages: { | ||||||||||||
noCircularImport: 'Circular import dcetected via {{paths}}.', | ||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||
}, | ||||||||||||
schema: [], | ||||||||||||
type: 'suggestion', | ||||||||||||
}, | ||||||||||||
defaultOptions: [], | ||||||||||||
create(context) { | ||||||||||||
const services = getParserServices(context); | ||||||||||||
const graph = new Graph(); | ||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Performance] This is what we've been afraid of 😬. Creating a new graph on each file becomes expensive in larger projects. From #224 (comment):
Having to manually recreate the graph on demand for each file is not what I was hoping we'd have to do here. I don't see any TypeScript APIs that can give the full dependency graph on-demand. I tried out this rule locally with a basic config and it increased the lint time from ~35-36 seconds to ~39-40 seconds on my M1 Mac. So, not immediately terrible... but noticeable, and will get exponentially worse in larger projects. Testing local
|
||||||||||||
|
||||||||||||
function resolveSpecifier( | ||||||||||||
containingFile: string, | ||||||||||||
specifier: string, | ||||||||||||
): ts.ResolvedModuleWithFailedLookupLocations { | ||||||||||||
return ts.resolveModuleName( | ||||||||||||
specifier, | ||||||||||||
containingFile, | ||||||||||||
services.program.getCompilerOptions(), | ||||||||||||
ts.sys, | ||||||||||||
); | ||||||||||||
} | ||||||||||||
|
||||||||||||
function isTypeOnlyImport(node: ts.ImportDeclaration): boolean { | ||||||||||||
return ( | ||||||||||||
node.importClause?.isTypeOnly || | ||||||||||||
(!!node.importClause?.namedBindings && | ||||||||||||
ts.isNamedImports(node.importClause.namedBindings) && | ||||||||||||
node.importClause.namedBindings.elements.every( | ||||||||||||
elem => elem.isTypeOnly, | ||||||||||||
)) | ||||||||||||
); | ||||||||||||
} | ||||||||||||
|
||||||||||||
function addEdgesRecursively( | ||||||||||||
graph: Graph, | ||||||||||||
containingFile: string, | ||||||||||||
importDeclaration: ts.ImportDeclaration, | ||||||||||||
): void { | ||||||||||||
if (graph.hasEdge(containingFile)) { | ||||||||||||
return; | ||||||||||||
} | ||||||||||||
|
||||||||||||
if (isTypeOnlyImport(importDeclaration)) { | ||||||||||||
return; | ||||||||||||
} | ||||||||||||
|
||||||||||||
if (!ts.isStringLiteral(importDeclaration.moduleSpecifier)) { | ||||||||||||
return; | ||||||||||||
} | ||||||||||||
|
||||||||||||
const specifier = importDeclaration.moduleSpecifier.text; | ||||||||||||
|
||||||||||||
const resolved = resolveSpecifier(containingFile, specifier); | ||||||||||||
|
||||||||||||
if ( | ||||||||||||
!resolved.resolvedModule || | ||||||||||||
resolved.resolvedModule.isExternalLibraryImport | ||||||||||||
) { | ||||||||||||
Comment on lines
+102
to
+105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Style] Nit:
Suggested change
|
||||||||||||
return; | ||||||||||||
} | ||||||||||||
|
||||||||||||
const resolvedFile = resolved.resolvedModule.resolvedFileName; | ||||||||||||
|
||||||||||||
graph.addEdge(containingFile, { | ||||||||||||
filename: resolvedFile, | ||||||||||||
specifier, | ||||||||||||
}); | ||||||||||||
|
||||||||||||
const sourceCode = services.program.getSourceFile(resolvedFile); | ||||||||||||
sourceCode?.statements.forEach(statement => { | ||||||||||||
if (ts.isImportDeclaration(statement)) { | ||||||||||||
addEdgesRecursively(graph, resolvedFile, statement); | ||||||||||||
} | ||||||||||||
}); | ||||||||||||
} | ||||||||||||
|
||||||||||||
function detectCycleWorker( | ||||||||||||
start: string, | ||||||||||||
graph: Graph, | ||||||||||||
filename: string, | ||||||||||||
visited: Set<string>, | ||||||||||||
paths: string[], | ||||||||||||
): string[] { | ||||||||||||
visited.add(filename); | ||||||||||||
|
||||||||||||
for (const edge of graph.getEdges(filename)) { | ||||||||||||
if (visited.has(edge.filename)) { | ||||||||||||
if (edge.filename === start) { | ||||||||||||
return paths.concat(edge.specifier); | ||||||||||||
} | ||||||||||||
return []; | ||||||||||||
} | ||||||||||||
const detected = detectCycleWorker( | ||||||||||||
start, | ||||||||||||
graph, | ||||||||||||
edge.filename, | ||||||||||||
visited, | ||||||||||||
paths.concat(edge.specifier), | ||||||||||||
); | ||||||||||||
if (detected.length) { | ||||||||||||
return detected; | ||||||||||||
} | ||||||||||||
} | ||||||||||||
return []; | ||||||||||||
} | ||||||||||||
|
||||||||||||
function detectCycle(graph: Graph, filename: string): string[] { | ||||||||||||
const visited = new Set<string>(); | ||||||||||||
return detectCycleWorker(filename, graph, filename, visited, []); | ||||||||||||
} | ||||||||||||
|
||||||||||||
return { | ||||||||||||
ImportDeclaration(node): void { | ||||||||||||
const tsNode = services.esTreeNodeToTSNodeMap.get(node); | ||||||||||||
const containingFile = tsNode.parent.getSourceFile().fileName; | ||||||||||||
addEdgesRecursively(graph, containingFile, tsNode); | ||||||||||||
|
||||||||||||
const cycle = detectCycle(graph, containingFile); | ||||||||||||
if (cycle.length > 1) { | ||||||||||||
context.report({ | ||||||||||||
messageId: 'noCircularImport', | ||||||||||||
node, | ||||||||||||
data: { | ||||||||||||
paths: | ||||||||||||
cycle.length === 2 | ||||||||||||
? cycle[0] | ||||||||||||
: `${cycle[0]} ... ${cycle[cycle.length - 2]}`, | ||||||||||||
}, | ||||||||||||
}); | ||||||||||||
} | ||||||||||||
}, | ||||||||||||
}; | ||||||||||||
}, | ||||||||||||
}); |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
import { entry } from './entry'; | ||
export const one = entry; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
import { two } from './depth-two'; | ||
export const three = two; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
import { one } from './depth-one'; | ||
export const two = one; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export const entry = 1; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
import { b } from './isolated-circular-b'; | ||
export const a = 1; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
import { a } from './isolated-circular-a'; | ||
export const b = 1; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export default {}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import type { entry } from './entry'; | ||
import { type entry as EntryType } from './entry'; | ||
export type TypeOnly = typeof entry | typeof EntryType; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add explanation about what benefit it has over
import/no-cycle
(i.e. uses the TS resolver and is faster)