eslint-plugin-perfectionist icon indicating copy to clipboard operation
eslint-plugin-perfectionist copied to clipboard

Release: v5.0.0

Open azat-io opened this issue 3 months ago • 16 comments

I think we've accumulated quite a bit of technical debt and it's time to think about releasing a new major version.

I'd like to tell you about some of the plans:

Make code refactoring

We have quite a bit of code in the project at the moment.

The utils folder looks like a mess, there are a lot of functions there that should be scattered into folders.

We have quite a few functions that are easy to get confused about, I would like to have a JSDoc in the project and actively use it.

I would also like to unify the naming of functions.

Migrate to ESLint Vitest Rule Tester

I would like the test code to become visually cleaner and more pleasant.

We will be able to use hooks (beforeEach, beforeAll, afterEach, afterAll). You can run a separate test it.only or skip a test it.skip.

It will be possible to use new Vitest features, such as snapshots.

Create sorting rule func

At the moment we have quite a bit of code duplication.

We have the createESLintRule function in use. We want to think about creating abstraction over abstraction.

But this abstraction must be thin and transparent. We want a lightweight wrapper, not a magic black box.

This is necessary in order not to complicate support and not to deviate too much from the standard ESLint plugins API. So that the project doesn't become difficult to support. “Thick” abstraction will complicate unique cases.

I see the realization roughly like this:

/**
 * Configuration options for creating a sorting rule.
 *
 * @template Options - The options type for the specific sorting rule.
 * @template MessageIds - Union of message IDs used by the rule.
 */
export interface CreateSortingRuleOptions<
  Options extends unknown[],
  MessageIds extends string,
> {
  /**
   * AST node visitors that transform nodes into SortingNode format Each visitor
   * should return an array of SortingNode or null to skip.
   */
  visitors: {
    [K in keyof RuleListener]?: (
      node: Parameters<NonNullable<RuleListener[K]>>[0],
      context: RuleContext<MessageIds, [Options?]>,
      options: Options,
    ) => SortingNode[] | null
  }

  /**
   * Main sorting logic that processes collected nodes.
   *
   * @param nodes - All collected SortingNode instances.
   * @param context - ESLint rule context.
   * @param options - Resolved rule options.
   */
  create(
    nodes: SortingNode[],
    context: RuleContext<MessageIds, [Options?]>,
    options: Options,
  ): void

  /** Rule name as it appears in ESLint configuration. */
  name: ESLintUtils.RuleWithMetaAndName<
    Options,
    MessageIds,
    ESLintPluginDocumentation
  >['name']

  /** ESLint rule metadata. */
  meta: ESLintUtils.RuleWithMetaAndName<
    Options,
    MessageIds,
    ESLintPluginDocumentation
  >['meta']

  /**
   * Optional function to resolve and validate options.
   *
   * @param context - ESLint rule context.
   * @param defaults - Default options.
   * @returns Resolved options.
   */
  resolveOptions?(
    context: RuleContext<MessageIds, [Options?]>,
    defaults: Options,
  ): Options

  /** Default rule options. */
  defaultOptions: Options
}

/**
 * Documentation metadata for ESLint rules.
 *
 * Provides additional information about the rule that can be used by ESLint
 * configurations and documentation generators.
 */
interface ESLintPluginDocumentation {
  /**
   * Indicates whether the rule is part of the recommended configuration. Rules
   * marked as recommended are typically enabled by default in the plugin's
   * recommended preset.
   *
   * @default false
   */
  recommended?: boolean
}

/**
 * Creates an ESLint rule with sorting functionality.
 *
 * @template Options - The options type for the specific sorting rule.
 * @template MessageIds - Union of message IDs used by the rule.
 * @param ruleOptions - Configuration for the sorting rule.
 * @returns ESLint rule object.
 */
export function createSortingRule<
  Options extends unknown[],
  MessageIds extends string,
>(
  ruleOptions: CreateSortingRuleOptions<Options, MessageIds>,
): ReturnType<typeof createEslintRule<[Options?], MessageIds>> {
  let { resolveOptions, defaultOptions, visitors, create, meta, name } =
    ruleOptions

  return createEslintRule<Options, MessageIds>({
    create: context => {
      let settings = getSettings(context.settings)
      let options = complete(context.options[0], settings, defaultOptions)

      /* ... */
    },
    defaultOptions,
    meta,
    name,
  })
}

Remove all deprecated APIs

We have quite a backlog of outdated configuration options.

This adds a lot to the amount of code and causes confusion for people. I would like to get rid of all the old code.

  • Old predefined group names
  • groupKind options
  • Old options like ignorePattern

Make project ESM-only

We are currently building our plugin in CJS. This is a requirement of the old ESLint version. Currently, all LTS versions of Node.js support require(esm), which means we can fully transition to ESM.

To migrate to ESM, we just need to update the build config and add "type": "module" to package.json.

Many ESLint plugins have already completely switched to ESM:

https://github.com/eslint-stylistic/eslint-stylistic/pull/670

https://github.com/eslint-community/eslint-plugin-eslint-plugin/pull/516

And many are planning to:

https://github.com/typescript-eslint/typescript-eslint/issues/11379

https://github.com/vuejs/eslint-plugin-vue/issues/2718

Drop support for Node.js v18.x.x

In April 2025, the end of life of Node.js v18 happened.

I think it's time to drop its support. Not only because of the possibility of switching to ESM, but it will also allow us to use new language features such as toSorted methods.

Besides, as far as I remember, we have already encountered bugs related to Node.js v18.

Drop support legacy ESLint v8.x.x

In September 2024, the end of life of ESLint version 8 happened. It's been 11 months since then. I think we can start thinking about discontinuing support for older versions of ESLint.

I understand that the number of downloads of older versions is still huge and many users still haven't switched to the current version.

But at the same time I think that we can influence the forcing of the update.

At the same time, users who use ESLint v8 are probably not actively updating packages.

It might not be a full drop of support for older versions of the configs. Maybe we will act like eslint-plugin-depend does.

New rules!

I think we should think about creating new rules.

sort-regexp

To develop the rule we will use the default parser, which is used in ESLint: @eslint-community/regexpp.

This rule could sort:

  • capture groups (/(error|warning|info|debug)/)
  • character classes (/[zxa-f0-9A-Z]/)
  • flags (/pattern/igmus)

sort-import-attributes

import wasmModule from './module.wasm' with { type: 'webassembly', credentials: 'include', mode: 'cors' }

import config from './config.json' with { type: 'json' }

Recently, import attributes support has been introduced in Node.js and even browsers.

It is unlikely that this rule will be popular. But I think we should add it anyway.

Do you have any ideas?

If you have any additional ideas, I'd love to hear them.

azat-io avatar Aug 09 '25 22:08 azat-io

@azat-io

Remove all deprecated APIs

To this day, the following rules use the deprecated customGroups API, but don't handle the new array-based one:

  • sort-decorators.
  • sort-heritage-clauses.

I think that we should migrate them in order to fully get rid of the object-based customGroups API everywhere.


New rules!

  • It could be the right time to see about implementing a customizable sort-array (https://github.com/azat-io/eslint-plugin-perfectionist/issues/422).

Breaking option changes

  • sort-enums
    • Make forceNumericSort: true by default.
    • I realize that the sortByValue: boolean and forceNumericSort: boolean options are a bit redundant. We could merge them in to a sortByValue: boolean | 'ifNumericEnum'

hugop95 avatar Aug 10 '25 18:08 hugop95

I think that we should migrate them in order to fully get rid of the object-based customGroups API everywhere.

Agree with you. I would like the rule configurations to be similar. 👍

It could be the right time to see about implementing a customizable sort-array (https://github.com/azat-io/eslint-plugin-perfectionist/issues/422).

Will this replace the sort-array-includes rule?

azat-io avatar Aug 10 '25 18:08 azat-io

@azat-io

Will this replace the sort-array-includes rule?

The ultimate objective is to be able to replace both sort-array-includes and sort-sets, as both are related to array-sorting.

hugop95 avatar Aug 10 '25 19:08 hugop95

I agree about sort-array-includes. But about sort-sets, I have my doubts about it being semantic.

azat-io avatar Aug 10 '25 19:08 azat-io

@azat-io

We can always go with a sort-array rule and keep sort-sets if needed 👍 It's experimental anyway as of now 🙂

hugop95 avatar Aug 10 '25 19:08 hugop95

I can't figure out if this is already available, but I would love to be able to enforce a newline after all imports. As it seems to work now a newline after all imports won't be removed, but if I delete it it will still be missing when eslint --fix is run.

supernaut avatar Aug 21 '25 18:08 supernaut

@supernaut

It's possible. You need to enable two things:

  • Separating each group in your groups option by a newline: newlinesBetween: 1
  • For each group in your groups option, make all of its elements be separated by a newline: replace any predefined group with a custom group and use newlinesInside: 1.

Example with an initial configuration

{
  groups: ["sibling", "external"],
  newlinesBetween: 1
}
import { c } from "./a";
import { d } from "./b";

import { a } from "a";
import { b } from "b";

⬆️ Autofixed result

To enforce a newline between c/d (sibling predefined group) and a/b (external predefined group), redefine those groups as custom groups and use newlinesInside: 1

{
"groups": ["sibling", "external"],
  "newlinesBetween": 1,
  "customGroups": [
    {
      "groupName": "sibling",
      "selector": "sibling", // Match `sibling` imports
      "newlinesInside": 1
    },
    {
      "groupName": "external",
      "selector": "external", Match `external` imports
      "newlinesInside": 1
    }
  ]
}

hugop95 avatar Aug 21 '25 20:08 hugop95

After migrating to ESM-only, our plugin no longer works with legacy ESLint configs (.eslintrc.js), even on Node.js v24. We need to decide: revert to CommonJS for compatibility or push forward with modern standards.

I tried using the plugin with the old ESLint config format in the upcoming Perfectionist v5 and encountered issues with configuration and usage after migrating to ESM-only.

Node.js v24 supports require(esm) without any additional flags. However, when using the plugin, I encountered an error:

1:1  error  Definition for rule 'perfectionist/sort-imports' was not found  perfectionist/sort-imports

I decided to analyze other ESLint plugins that had migrated to ESM-only and found that they all officially discontinued support for older config formats.

I then decided to investigate how this impacted download numbers:

Plugin Legacy config Downloads Flat config Downloads Migration date
eslint-stylistic <= 3.x.x 1,072,818 >= 4.0.0 1,773,260 February 18, 2025
eslint-plugin-unicorn <= 56.x.x 3,205,578 >= 57.0.0 1,469,292 February 17, 2025
eslint-plugin-svelte <= 2.x.x 226,475 >= 3.0.0 280,693 February 26, 2025
Script to check on npm page
function analyzeVersionSplit(breakingVersion) {
  const versionHistoryTable =
    document.querySelector('#version-history').nextElementSibling
  const rows = versionHistoryTable.querySelectorAll('tbody tr')

  let newVersionsDownloads = 0
  let oldVersionsDownloads = 0
  let foundBreaking = false

  rows.forEach(row => {
    const versionCell = row.querySelector('a[aria-label]')
    const downloadsCell = row.querySelector('.downloads')

    if (!versionCell || !downloadsCell) return

    const version = versionCell.textContent.trim()
    const downloads = parseInt(downloadsCell.textContent.replace(/,/g, ''))

    if (version === breakingVersion) {
      foundBreaking = true
    }

    if (!foundBreaking) {
      newVersionsDownloads += downloads
    } else {
      oldVersionsDownloads += downloads
    }
  })

  console.log(`${breakingVersion}+: ${newVersionsDownloads.toLocaleString()}`)
  console.log(`Old versions: ${oldVersionsDownloads.toLocaleString()}`)
}

We rejected this pull request:

https://github.com/azat-io/eslint-plugin-perfectionist/pull/590

The main reason is that supporting old configurations does not require much effort, and many users prefer to use the old format and are reluctant to migrate to ESLint v9.

However, we now face the problem that it actually doesn't work after migrating to ESM-only. And we need to make a decision.

Next, I want to share other people's opinions on this matter:

@hugop95 believes that the launch of ESLint v9 was not very successful. Many users do not want to spend time on migration. Forcing migration may alienate most users.

@antfu says that ESLint v8 EOL is long gone. Projects that still use ESLint v8 are unlikely to have the latest version of plugins.

@bradzacher says that users will definitely notice the discontinuation of ESLint v8 support. He says that only about 28% of users use flat configurations. Users are not motivated to migrate to the new format.

I don't have numbers on how many of our users are using the old format. But I think it's a significant number. At the same time, I believe that most have already switched to the new format.

We have two paths:

Option 1: Maintain compatibility. We should not motivate users to migrate to the new format, since we are not the core of ESLint. In this case, we should return to CommonJS and support the old config format. This will allow users to continue using the plugin without any problems.

Option 2: Drive modernization. We should stop supporting the old config format and motivate users to migrate to the new format. Promote modern standards and improve the community and ecosystem. This may result in the loss of some users. Some will stay on the old version of the plugin, while others will switch to Oxlint and Biome.

Do we need a revolution, or should we maintain the status quo?

Should plugin maintainers lead the migration effort or follow user adoption patterns?

azat-io avatar Sep 24 '25 15:09 azat-io

@azat-io

Great recap, thank you for the insights regarding the number of downloads per ESLint version.

  • From what I understand, keeping support of the legacy format would require us to revert https://github.com/azat-io/eslint-plugin-perfectionist/pull/567, which seems simple, and doesn't seem to add maintenance overhead.
  • But I do hear the argument that ESLint V8 is getting old, and that users still on V8 are not likely to use Perfectionist V5 anyway.
  • I also think that Perfectionist V4 is in a great state, and in my opinion, V5 isn't going to bring features "powerful" enough to justify a V4 user with a complex ESLint V8 configuration to migrate to ESLint V9.

As a result, I'm still slightly leaning toward keeping ESLint V8 compatibility for Perfectionist V5, but also understand if we decide to force a migration now.

hugop95 avatar Sep 24 '25 16:09 hugop95

Breaking down the ESLint download counts by major version:

Major Weekly DL # User %
7 4,358,033 7.04%
8 27,261,359 44.03%
9 26,637,292 43.02%

There are still more people not on v9 than there are on v9.

Note I used this gist to grab these numbers


But I do hear the argument that ESLint V8 is getting old, and that users still on V8 are not likely to use Perfectionist V5 anyway.

Breaking down @typescript-eslint download counts.

typescript-eslint is our package which provides flat config support and it has 16,539,353.

@typescript-eslint/eslint-plugin:

Major Weekly DL # User %
6 5,198,181 10.09%
7 6,128,692 11.9%
8 27,635,668 53.65%

I would challenge the assumption that "most people on ESLint v8 don't update their plugins". From these download counts alone we can roughly infer that approximately 60% (16m / 27m) of the people on our latest major plugin version are using flat configs.

So that means 40% of our latest major release users are using legacy configs -- which also HIGHLY likely means they're using ESLint v8.

There's a substantial portion of people that are purposely staying on ESLint v8 and are still updating their plugins. There's good reason to stay on v8 if you don't have the bandwidth to take on the flat config migration or if it doesn't work for you. I know at my company we fall into that bucket -- our monorepo setup means that flat configs break our linting setup and only in the last few months has ESLint core finally made changes that fix that breakage.


My 2c that maintaining ESLint v8 compat is still high value for end users and so it's something we should keep doing as an ecosystem. I don't think we should be forcing people to move to ESLint v9 -- I don't think that is our job as plugin maintainers.

bradzacher avatar Sep 24 '25 23:09 bradzacher

+1 to what Brad said. Dropping v8 compat would really be a blow.

it actually doesn't work after migrating to ESM-only

Sorry, I might have missed where it was stated why? Other ESLint plugins have successfully migrated to ESM-only without dropping eslintrc support. Example: https://github.com/JoshuaKGoldberg/eslint-plugin-package-json/issues/1080.

JoshuaKGoldberg avatar Sep 25 '25 14:09 JoshuaKGoldberg

@JoshuaKGoldberg Thank you for your migration example in eslint-plugin-package-json! I found a way to restore the plugin's functionality with legacy configurations.

azat-io avatar Sep 25 '25 18:09 azat-io

Yes I have a few plugins which also support both legacy and flat config but are esm only

ESLint uses a dynamic import to load legacy plugins under the hood iirc

43081j avatar Sep 25 '25 18:09 43081j

ESLint React X by @Rel1cx has dropped support for the old config format in the new major version.

It seems to be a trend. Perhaps we will return to this discussion next year. Possibly after the release of ESLint v10.

https://eslint-react.xyz/docs/release-notes/v2.0.0

azat-io avatar Sep 28 '25 11:09 azat-io

Hello @azat-io! I noticed that in this release branch (5.x), @typescript-eslint/utils was updated to its latest version. Would it be possible to backport this update also on the 4.x branch?

The version used on 4.x has a peer dependency requirement on typescript <= 5.9.0 and it generates a lot of warnings with the latest version of typescript.

It would be useful to have that dependency updated on the current release branch until we migrate to 5.x (that will take some time, since it requires ESM and eslint9)

lucacavallaro avatar Nov 13 '25 15:11 lucacavallaro

Migrating the package to ESM will not prevent it from being used in CommonJS projects, at least in the latest versions of Node.js.

ESLint Perfectionist will continue to support ESLint v8. We have decided not to remove support.

I expect the release to be ready in 1-2 weeks.

azat-io avatar Nov 13 '25 16:11 azat-io

I have serious doubts about adding the new sort-regexp rule. The risks and benefits seem unbalanced.

The rule has several serious problems:

1. Package size

As @hugop95 pointed out, the package sizes are as follows:

  • @eslint-community/regexpp: 474 kB (unpacked)
  • Current size of Perfectionist v4: 327 kB
  • @typescript-eslint/utils: 195 kB
  • natural-orderby: 72 kB

Adding one rule will increase the package size by ~145%. This is a significant overhead for all users, including those who don't use regular expressions extensively.

2. Safety concerns

The sort-regexp rule looks like a time bomb.

There are cases where the order of alternatives in regular expressions is critical for correct operation. As @hugop95 demonstrated:

/(a|.*ab)/.exec("ab")   // ['a', 'a', ...]
/(.*ab|a)/.exec("ab")   // ['ab', 'ab', ...]

The rule already covers many different cases, but I am 100% certain there are still edge cases that haven't been accounted for. Bugs are inevitable with regex semantics.

Regular expressions are inherently complex, and reordering them is risky.

3. Implementation complexity

The rule code is very different from other rules. It has:

  • 2,213 lines of code total
  • 13 separate utility files
  • Complex custom logic

This level of complexity will make maintenance and bug fixes challenging.

4. Architectural inconsistency

Unlike all other rules in this plugin, sort-regexp requires a non-standard parser (@eslint-community/regexpp). This creates architectural inconsistency and increases the dependency footprint.

I suggest closing this PR.

Thank you @hugop95 for the thorough review and for raising these concerns.

azat-io avatar Nov 23 '25 15:11 azat-io

@azat-io

I also believe that in the current state, the rule is not ready yet. IMO, the biggest blocker on the long run is the first point (package size). I think that the size cost is hardly justified for what the rules bring (it's nice, but I'm sure it won't be among the most popular ones).

I think that not all is lost in theory: the splitting of the rule in 3 (sort regex flags, sort regex capture groups, sort regex character classes) could lead to a first simple implementation in the future.

Maybe one of the rules is much easier to implement than the others and is fully safe (sort regex flags). But as Point 4 says, because we need another parser, I'm not sure how well it will integrate with the current framework all other rules use.


Overall, I don't think we need to rush this: adding a new rule can always be done within a major version (so long as we don't enable it in the recommended rules), and I don't think that not adding it in V5 would be that impactful.

All your work on https://github.com/azat-io/eslint-plugin-perfectionist/pull/600 will certainly still be used to build the rule again in the future if we decide to add it back !

hugop95 avatar Nov 23 '25 18:11 hugop95

@azat-io

For info, there are 3 things I would like to work on before I can consider V5 to be ready on my side:

  • https://github.com/azat-io/eslint-plugin-perfectionist/issues/593, which will be based off https://github.com/azat-io/eslint-plugin-perfectionist/pull/617 to make it simpler to add custom type sorts.
  • The replacement of the last deprecated option: ignorePattern in sort-objects.
    • This requires a bit of work to be able to precisely select elements we want to ignore (see https://github.com/azat-io/eslint-plugin-perfectionist/issues/571#issuecomment-3196206473).
  • I would like to improve the newly added useConfigurationIf.declarationCommentMatchesPattern: I realize that it doesn't always work in complex cases.

hugop95 avatar Nov 23 '25 18:11 hugop95

I know that this is probably undesired, but could we at least consider perhaps changing the default alphabet for sort-imports to sort subpaths before hyphenated packages, as per https://github.com/azat-io/eslint-plugin-perfectionist/issues/546#issuecomment-2981993513?

lishaduck avatar Nov 23 '25 20:11 lishaduck

@lishaduck

As explained in https://github.com/azat-io/eslint-plugin-perfectionist/issues/546#issuecomment-2892528011:

  • Updating the alphabetical sort isn't fitting, as it explicitly claims to use localeCompare.
  • Updating the natural sort might be more functionally fitting as it's already a custom sort, but it's also explicitly relying on a particular package, and this means introducing an inconsistency for a specific rule and not the others. It's also not technically simple.
  • This leaves the custom sort, which does not have an alphabet by default, because it's the user's job to enter it.
    • Setting a predefined alphabet can work, but this means doing that for all other rules for consistency.
    • I would actually prefer going with a dedicated type: 'subpaths-first' (placeholder name) that internally uses a setup custom alphabet.

However:

  • It's still an opinionated sort to maintain (and that means no modifying/getting rid of it until a next major version in theory).
  • It's a convenience feature (we offer a way to achieve the sort today in a relatively simple way).

So I find the idea interesting, but it's not a priority V5 feature IMO.

hugop95 avatar Nov 24 '25 07:11 hugop95

  • Updating the alphabetical sort isn't fitting, as it explicitly claims to use localeCompare.

Certainly wouldn't expect alphabetical to change, I'd expect it as a quirk...

  • Updating the natural sort might be more functionally fitting as it's already a custom sort, but it's also explicitly relying on a particular package, and this means introducing an inconsistency for a specific rule and not the others. It's also not technically simple.

I was thinking to switch natural, yes. If not technically simple, I'll leave you alone, but it's setting it for just one rule, would it be simpler to just change it for all of them?

  • This leaves the custom sort, which does not have an alphabet by default, because it's the user's job to enter it.

Again, wouldn't really expect custom to change.

  • I would actually prefer going with a dedicated type: 'subpaths-first' (placeholder name) that internally uses a setup custom alphabet.

Hm... that's an interesting idea. My goal is primarily to just make the default behavior (of natural) more natural, so that doesn't really help. At that point we might as well just add a thing to the docs with the custom alphabet.

However: [...] So I find the idea interesting, but it's not a priority V5 feature IMO.

Fair enough! What if we just add the snippet to the docs for now, and we could revisit in V6 if we notice people using it?

lishaduck avatar Nov 24 '25 13:11 lishaduck

@lishaduck

I was thinking to switch natural, yes. If not technically simple, I'll leave you alone, but it's setting it for just one rule, would it be simpler to just change it for all of them?

  • Functionally:
    • I don't think changing all rules is necessarily a good idea. Functionally, it does make sense for sort-imports or sort-exports, but not much for others (probably because other rules don't tend to encounter /). It would be consistent, but that's about it.
    • Today, we openly say that we fully rely on https://github.com/yobacca/natural-orderby for our natural sort, and it has the advantage to be fully transparent. Changing that means that we decide to run our own custom natural sort, and I don't think it's worth it (it's a convenience feature).
  • Technically, I don't think that natural-orderby exposes an easy way to do what we want.

Fair enough! What if we just add the snippet to the docs for now, and we could revisit in V6 if we notice people using it?

I think it's a good idea. We could indeed push for custom sort adoption by offering valuable examples.

hugop95 avatar Nov 26 '25 15:11 hugop95