-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
feat(eslint-plugin): [no-circular-imports] add new rule #8965
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
Conversation
Thanks for the PR, @yeonjuan! typescript-eslint is a 100% community driven project, and we are incredibly grateful that you are contributing to that community. The core maintainers work on this in their personal time, so please understand that it may not be possible for them to review your work immediately. Thanks again! 🙏 Please, if you or your company is finding typescript-eslint valuable, help us sustain the project by sponsoring it transparently on https://opencollective.com/typescript-eslint. |
✅ Deploy Preview for typescript-eslint ready!
To edit notification comments on pull requests, go to your Netlify site configuration. |
58bcfba
to
780fb1a
Compare
b64afbc
to
27d3002
Compare
> | ||
> 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. |
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)
requiresTypeChecking: true, | ||
}, | ||
messages: { | ||
noCircularImport: 'Circular import dcetected via {{paths}}.', |
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.
noCircularImport: 'Circular import dcetected via {{paths}}.', | |
noCircularImport: 'Circular import detected via {{paths}}.', |
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 comment
The reason will be displayed to describe this comment to others. Learn more.
'Disallow the use of import module that result in circular imports', | |
'Disallow a module from importing another module that transitively imports itself.', |
import/no-cycle
uses the description "Forbid a module from importing a module with a dependency path back to itself.". I think that's better.
hasEdge(name: string): boolean { | ||
return this.graph.has(name); | ||
} | ||
|
||
getEdges(name: string): Edge[] { | ||
return this.graph.get(name) ?? []; | ||
} |
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.
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 hasNode
and getOutEdges
.
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.
Sorry to be a bearer of bad news 😬 but even with using TypeScript's ts.resolveModuleName
, I think recreating a graph on every file parse is not a performance-friendly-enough architecture for us to go with. Let's discuss if anybody thinks it is?
(note: sending this PR was very useful in informing that, and I really appreciate you sending it in - sorry if it ends up being declined anyway!)
return this.graph.get(name) ?? []; | ||
} | ||
} | ||
// 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 comment
The 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.
// imports “a.ts” and is imported from “b.ts”, resulting in a circular reference. |
if ( | ||
!resolved.resolvedModule || | ||
resolved.resolvedModule.isExternalLibraryImport | ||
) { |
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.
[Style] Nit:
if ( | |
!resolved.resolvedModule || | |
resolved.resolvedModule.isExternalLibraryImport | |
) { | |
if (resolved.resolvedModule?.isExternalLibraryImport !== false) { |
defaultOptions: [], | ||
create(context) { | ||
const services = getParserServices(context); | ||
const graph = new Graph(); |
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.
[Performance] This is what we've been afraid of 😬. Creating a new graph on each file becomes expensive in larger projects. From #224 (comment):
We can rely on the fact that TS has already analysed every file and knows the dependency graph of the project.
So instead of manually building it - we can just ask TS for the information.
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 eslint.config.mjs
import tseslint from 'typescript-eslint';
import deprecationPlugin from 'eslint-plugin-deprecation';
import tseslintInternalPlugin from '@typescript-eslint/eslint-plugin-internal';
import eslintCommentsPlugin from 'eslint-plugin-eslint-comments';
import eslintPluginPlugin from 'eslint-plugin-eslint-plugin';
import importPlugin from 'eslint-plugin-import';
import jestPlugin from 'eslint-plugin-jest';
import jsdocPlugin from 'eslint-plugin-jsdoc';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import simpleImportSortPlugin from 'eslint-plugin-simple-import-sort';
import unicornPlugin from 'eslint-plugin-unicorn';
export default tseslint.config(
{
ignores: [
'**/__snapshots__/**',
'**/.docusaurus/**',
'**/.nx/**',
'**/build/**',
'**/coverage/**',
'**/dist/**',
'**/fixtures/**',
'**/jest.config.js',
'**/node_modules/**',
'packages/rule-tester/tests/eslint-base',
'packages/types/src/generated/**/*.ts',
'packages/website/src/vendor',
],
},
...tseslint.configs.recommended,
{
languageOptions: {
parserOptions: {
EXPERIMENTAL_useProjectService: {
allowDefaultProjectForFiles: ['./*.js', './*/*.js', './*/*/*.js'],
defaultProject: 'tsconfig.json',
},
},
},
plugins: {
['@typescript-eslint']: tseslint.plugin,
['@typescript-eslint/internal']: tseslintInternalPlugin,
['deprecation']: deprecationPlugin,
['eslint-comments']: eslintCommentsPlugin,
['eslint-plugin']: eslintPluginPlugin,
['import']: importPlugin,
['jest']: jestPlugin,
['jsdoc']: jsdocPlugin,
['jsx-a11y']: jsxA11yPlugin,
['react-hooks']: reactHooksPlugin,
['react']: reactPlugin,
['simple-import-sort']: simpleImportSortPlugin,
['unicorn']: unicornPlugin,
},
rules: {
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-array-constructor': 'off',
'@typescript-eslint/no-duplicate-enum-values': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-extra-non-null-assertion': 'off',
'@typescript-eslint/no-loss-of-precision': 'off',
'@typescript-eslint/no-misused-new': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/no-unnecessary-type-constraint': 'off',
'@typescript-eslint/no-unsafe-declaration-merging': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/prefer-as-const': 'off',
'@typescript-eslint/triple-slash-reference': 'off',
'no-async-promise-executor': 'off',
'no-case-declarations': 'off',
'no-constant-condition': 'off',
'no-empty': 'off',
'no-fallthrough': 'off',
'no-prototype-builtins': 'off',
'no-undef': 'off',
'no-unsafe-finally': 'off',
'@typescript-eslint/no-circular-import': 'error',
},
},
);
I question whether this really makes sense to include in our plugin with this "create a graph for each file" architecture. Similar to how #371 was closed in favor of external tooling like Knip, maybe this just doesn't belong in a lint rule?
Can the graph be created once per lint run? |
I can't think of a way to create a graph once without storing state.🥲 #224 (comment) |
Yeah, this is a tough situation. On the one hand, this rule would be really useful, and it makes a lot of sense to have in our plugin. On the other hand, per #224 (comment) & eslint/rfcs#102, there isn't a straightforward way to set up the graph creation system in a way that's both performant and works well with the various ways ESLint might be run. We've talked about this internally and think this rule would be better off in a separate plugin from typescript-eslint for now. It can be iterated on there more rapidly & not be bound by our project's limited flexibility & staffing. Closing this PR for now 😞. But, if someone does figure out how to overcome the performance limitations, please let us know! We'd be happy to reopen. |
@yeonjuan thanks for trying. I do hate to throw away great work, and IMO this is a step in the right direction. If you want help making a new plugin or giving this to |
PR Checklist
Overview
In this pr, I implemented
no-circular-import
which checkscircular-import
using the information thatTS
has. see #224 (comment).The current implementation lacks some handling compared to eslint-plugin-import/no-cycle. (dynamic-import, require..).
Before go any further, I have a question about the case with
dynamic-import
.With this implementation way, it would be difficult to checks for modules that reference each other via dynamic import. Actually there is a way to traverse the ASTs in the TS to check for dynamic imports, but performance may be poor.
Should this be left as an edge case?
I've already left an inquiry on discord about this. I'd be interested to hear what other members think.