Skip to content

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

Closed
wants to merge 2 commits into from

Conversation

yeonjuan
Copy link
Contributor

@yeonjuan yeonjuan commented Apr 22, 2024

PR Checklist

Overview

In this pr, I implemented no-circular-import which checks circular-import using the information that TS 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.

// foo.ts
const foo = () => import("./bar.ts");

// bar.ts
const bar = () => import("./foo.ts");

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.

I'm not sure if dynamic imports should be warned at all because (a) there are many ways they are not statically analyzable (b) one of their use cases is to avoid circular imports (by deferring them) - Josh-Cena

@typescript-eslint
Copy link
Contributor

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.

Copy link

netlify bot commented Apr 22, 2024

Deploy Preview for typescript-eslint ready!

Name Link
🔨 Latest commit 82f36a9
🔍 Latest deploy log https://app.netlify.com/sites/typescript-eslint/deploys/662be03a2275f900088944af
😎 Deploy Preview https://deploy-preview-8965--typescript-eslint.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 97 (🟢 up 4 from production)
Accessibility: 100 (no change from production)
Best Practices: 92 (no change from production)
SEO: 98 (no change from production)
PWA: 80 (no change from production)
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify site configuration.

Copy link

nx-cloud bot commented Apr 22, 2024

@yeonjuan yeonjuan force-pushed the circular-import branch 2 times, most recently from 58bcfba to 780fb1a Compare April 22, 2024 16:24
@yeonjuan yeonjuan marked this pull request as ready for review April 26, 2024 17:11
@bradzacher bradzacher added the enhancement: new plugin rule New rule request for eslint-plugin label May 28, 2024
>
> 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.
Copy link
Member

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}}.',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'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.

Comment on lines +30 to +36
hasEdge(name: string): boolean {
return this.graph.has(name);
}

getEdges(name: string): Edge[] {
return this.graph.get(name) ?? [];
}
Copy link
Member

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.

Copy link
Member

@JoshuaKGoldberg JoshuaKGoldberg left a 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.
Copy link
Member

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.

Suggested change
// imports “a.ts” and is imported from “b.ts”, resulting in a circular reference.

Comment on lines +102 to +105
if (
!resolved.resolvedModule ||
resolved.resolvedModule.isExternalLibraryImport
) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Style] Nit:

Suggested change
if (
!resolved.resolvedModule ||
resolved.resolvedModule.isExternalLibraryImport
) {
if (resolved.resolvedModule?.isExternalLibraryImport !== false) {

defaultOptions: [],
create(context) {
const services = getParserServices(context);
const graph = new Graph();
Copy link
Member

@JoshuaKGoldberg JoshuaKGoldberg Jun 2, 2024

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?

@Josh-Cena
Copy link
Member

Can the graph be created once per lint run?

@yeonjuan
Copy link
Contributor Author

@Josh-Cena

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)

@JoshuaKGoldberg
Copy link
Member

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.

@JoshuaKGoldberg
Copy link
Member

@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 eslint-plugin-import-x, let us know - we'd be happy to give advice! ❤️

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Jul 3, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
enhancement: new plugin rule New rule request for eslint-plugin
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Rule proposal: detect and warn about circular imports
4 participants
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy