Skip to content
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: Flat config extends #126

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open

feat: Flat config extends #126

wants to merge 14 commits into from

Conversation

nzakas
Copy link
Member

@nzakas nzakas commented Nov 14, 2024

Summary

This RFC proposes adding an extends key to flat config in order to make it easier to mix and match other configs.

Related Issues

eslint/eslint#19116

@nzakas nzakas added the Initial Commenting This RFC is in the initial feedback stage label Nov 14, 2024
recommended: {
plugins: { "#": null },
rules: {
"#/no-duplicate-keys": "error",

Choose a reason for hiding this comment

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

Wouldn't using this rewrite mechanism then disallow people to use the rules directly from plugin config objects? Today, it's not uncommon to just spread a plugin's config's rules into a config. In this model, if they did that, the rules wouldn't have the token replaced, right? If that's the case, it seems like taking that usage pattern away would be a big side effect.

Copy link
Member Author

Choose a reason for hiding this comment

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

The intent isn't to take that usage pattern away, but rather to have another option.

Also, people are accessing rules directly right now because there isn't another way to easily extend configs. I'm not sure how necessary that pattern is in this proposal.

@michaelfaith
Copy link

michaelfaith commented Nov 14, 2024

I think everything up until extending named configs is a solid enhancement and would address the pain points you highlight. The named configs piece, though, feels a bit like a step on the road to rc-configs part 2. There's a lot of "magic" baked into that, and it shifts more away from the pure JavaScript spirit that the Flat config was based on. It also doesn't seem to move the needle that much more than passing the configs into extends as regular objects / arrays.

I do really like the fact that you can pass objects or arrays into extends and users don't need to know the underlying implementation anymore.

];
```

Here, the `files` keys will be combined and the `ignores` key will be inherited, resulting in a final config that looks like this:

Choose a reason for hiding this comment

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

I don't think that files should be combined -- if I write

{
  files: ["**/*.ts"],
  extends: [someConfig],
}

My expectation would definitely be that I'm overriding the files, eg:

{
  ...someConfig,
  files: ["**/*.ts"],
}

Merging just seems odd, IMO.

Choose a reason for hiding this comment

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

Worth noting that overriding is the behaviour we went with for typescript-eslint's extends property and so far we haven't had anyone report issues asking for a merge instead (it has been live for ~9 months now)

Copy link
Member

@aladdin-add aladdin-add Nov 15, 2024

Choose a reason for hiding this comment

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

how to merge:

{ files: ["src/**/*"], extends: [{files: ["**/*.js"]}], }
  • option A: files: ["src/**/*"]
  • option B: files: ["**/*.js"]
  • option C: files: ["src/**/*", "**/*.js"]

IMHO, all these are inappropriate. most likely it's expected: files: ["src/**/*.js"]

Copy link

@bradzacher bradzacher Nov 15, 2024

Choose a reason for hiding this comment

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

IMO Option A is the correct result. It's the least magical and most straightforward answer, IMO.

Option C is what this RFC currently proposes.

Copy link
Member

Choose a reason for hiding this comment

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

I think a typical use case for shareable configs specifying files is to provide different configs for different file extensions, while a typical use case for end-user configs specifying files along with extends would be to restrict the sharable config to certain directories only, so what would make the most sense, I think, is resulting config that matches the intersection of files, i.e., files that match both user-specified files and sharable config's files. In @aladdin-add's example, that would be files that match src/**/* AND **/*.js, which is indeed files: ["src/**/*.js"] (though I'm not sure if we would be able to implement this kind of calculations, but rather introduce another mechanism for specifying intersections in the resulting config).

Copy link
Member

Choose a reason for hiding this comment

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

Copy link

@bradzacher bradzacher Nov 18, 2024

Choose a reason for hiding this comment

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

My 2c as a user of eslint is that:

  1. I want the least surprising behaviour, and
  2. I want to be able to override that behaviour

If you do something magic internally like intersecting the globs to create new globs then:

  • that violates (1) because I wouldn't have expected such magic -- similar magic isn't applied elsewhere in flat configs AFAIK - it's all pretty "non-magical" in its behaviour.
  • that violates (2) because I cannot specify an override manually and I would have to opt-out of extends to override the behaviour.

If you merge the files arrays together then:

  • for me at least that violates (1) because I expect that that my array overrides the config array
    • I expect this works same as how my rule config array rule: ["error", "option2"] overrides the config's rule config array rule: ["warn", "option1"]
  • that violates (2) because I cannot specify an override manually and I would have to opt-out of extends to override the behaviour.

What I would reiterate is that typescript-eslint has used the override behaviour for the last 9 months and nobody has complained yet. Which is decent signal, IMO, that people expect overrides.


Note that the intersection or array merge behaviours would be trivial to implement on the user side as an opt-in. For example one might consider a util like intersect(...globs: Array<string | FlatConfig | Array<FlatConfig>>): string[] which would let me do some magic glob merging. Eg

{
  files: intersect("src/**/*", plugin.recommended),
  extends: [plugin.recommended],
}

Choose a reason for hiding this comment

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

I was surprised by typescript-eslint overriding extended files patterns. It was not what I wanted or expected.

My 2 cents, reiterating my comment here: eslint/eslint#19116 (comment)

If an extended config specifies files, and I also specify files, I expect both patterns to apply.

It's easy enough to understand: the extended config filters the list of input files, then my config filters it again. This seems useful.

It's easy enough to implement: instead of glob merging, just use an array-of-arrays format that requires input files to match any pattern in every array.

It's also easy enough to provide an escape hatch like files: () => ["src/**/*"] when you don't want the merging behaviour.

Copy link
Member Author

Choose a reason for hiding this comment

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

We do actually have an intersection syntax already supported in flat config (see eslint/eslint#18966), which we were planning on removing in v10 because we weren't using it. We can certainly leverage that for this proposal as I find the arguments of @mdjermanovic and @aaronadamsCA compelling.

@bradzacher I did take a look at what you were doing with extends and I don't believe that's the correct behavior.

designs/2024-config-extends/README.md Outdated Show resolved Hide resolved

The extended objects are evaluated in the same order and result in the same final config.

#### Extending Named Configs

Choose a reason for hiding this comment

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

I'd just like to reiterate my comment from the original issue in that I strongly believe that supporting this is a bad idea.

Copy link
Member Author

Choose a reason for hiding this comment

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

Noted.

Choose a reason for hiding this comment

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

I think supporting nested arrays is good, as currently most recommended configs are non-arrays and if they would have to become arrays then that would become a breaking change unless it can be consumed in the same way as a non-array

1. It encourages plugin authors to use the `configs` key on their plugin in order to allow this usage.
1. It allows ESLint to modify configs before they are used (see below).

#### Reassignable Plugin Configs

Choose a reason for hiding this comment

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

should this be moved to a separate RFC?
seems like it's largely unrelated to adding extends.

Copy link
Member Author

Choose a reason for hiding this comment

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

No. We like to keep related ideas in the same proposal because it makes it easier to evaluate the whole picture. Part of why we ended up in the mess we had with eslintrc was each proposal was being considered separately rather than seeing how it fit into the whole.

If consensus is that people don't like this, we can always remove it later.

Choose a reason for hiding this comment

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

I agree with @bradzacher, this should be two different RFC:s

Copy link
Member Author

Choose a reason for hiding this comment

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

Your opinions are noted and I disagree. I see this as one complete proposal.

Copy link

@voxpelli voxpelli Nov 25, 2024

Choose a reason for hiding this comment

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

As a procedural note I'm noting that in the initial commenting phase:

the community and ESLint team are invited to provide feedback on the proposal. During this period, you should expect to update your RFC based on the feedback provided. Very few RFCs are ready for approval without edits, so this period is important for fine-tuning ideas and building consensus.

And is transitioned to the next phase:

when all feedback has been addressed, the pull request author requests a final commenting period

And finally:

if the TSC reaches consensus on approving the RFC, the pull request will be merged

So I think all comments should be welcomed in this initial commenting phase, as the comments are not just to improve the RFC but also to inform the TSC and the community on the evaluation of the RFC.

Expressing disagreement with comments, especially when doing so repeatedly, does not encourage feedback and as such short circuits the initial commenting phase – it's generally something one stays away from in a brainstorming phase, and the initial commenting phase pretty much is a brainstorming phase when it comes to feedback.

Copy link
Member Author

Choose a reason for hiding this comment

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

It sounds like you're telling me I should never disagree with comments that are left on RFCs? I don't think that's conducive to the RFC process at all. All comments are welcome, as was yours, and I don't consider disagreeing with a comment to be unwelcome.

In this case, you doubled-down on a comment that I already disagreed with and I already gave an explanation as to why. I'm not sure your intent for doing so, but my choices were either to ignore it (which I don't think is respectful, every comment deserves a response) or to respond again, which is what I chose to do.

So once again, I disagree with your assertion. I think it's the job of the RFC writer to respond to every comment, even if it is to disagree, so that the conversation can remain open and honest.

Copy link

@voxpelli voxpelli Nov 27, 2024

Choose a reason for hiding this comment

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

Right, this will become fully off-topic, I'm sorry for that, I guess it should really be held in a separate place on how to improve the RFC process:

It sounds like you're telling me I should never disagree with comments that are left on RFCs?

  1. Since you respond with We like to keep related ideas in the same proposal its not clear if you are responding as the RFC author, as a TSC-member / project lead or refer to some kind of project principle.

  2. Generally disagreements should be held back early on in a feedback process to avoid short circuiting the feedback process by shutting down a discussion before it has had time to bloom into something fruitful.

In this case, you doubled-down on a comment that I already disagreed with and I already gave an explanation as to why. I'm not sure your intent for doing so

Since I agreed with the original comment and disagreed with you disagreement?

but my choices were either to ignore it (which I don't think is respectful, every comment deserves a response) or to respond again, which is what I chose to do

Comments on an RFC are not only directed to the RFC author but also to the TSC and the community at large.

I think it's the job of the RFC writer to respond to every comment, even if it is to disagree

I disagree.

  • Its the job of the RFC writer to listen and improve the RFC.
  • Its the job of the community and ultimately the TSC to indicate whether they agree or disagree with a piece of feedback, so that the RFC writer can know how to improve the RFC so that the TSC will become the most likely to accept it.
  • When the RFC writer is also part of the TSC it's important to know when they are replying as the TSC and when they reply as the writer (and they probably should try to avoid replying as the TSC since there is a perceptible conflict of interest)

so that the conversation can remain open and honest

Premature disagreement does not promote open and honest conversations. It shuts down conversations by shifting the focus to the disagreement.

By nurturing the conversation and helping explore it to its final conclusion one can give it an honest final judgement when it has fully flourished, rather than prematurely closing down the discussion by sidetracking it when its only just a seed.

And as a sign that this is not just me who are disillusioned: See eg. 1. Keep comments positive, 3. Don’t execute too early and 4. Always look forward in this Brainstorming 101

Copy link
Member Author

Choose a reason for hiding this comment

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

Just to close the loop on this, I can see that we have a difference in opinion of how the RFC process is meant to proceed and what the expected interactions may be. I apologize if you felt that I was dismissing your comments, that was certainly not my intent.

I don't think there's any value in going point-for-point on this topic. We'll just have to agree to disagree and move on.

designs/2024-config-extends/README.md Outdated Show resolved Hide resolved
];
```

Here, `js/recommended` refers to the plugin defined as `js`. Internally, ESLint will look up the plugin with the name `js`, looks at the `configs` key, and retrieve the `recommended` key as a replacement for the string `"js/recommended"`.

Choose a reason for hiding this comment

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

I would discourage this.

The fact that exported configs needs to reference the very plugin object that they are a property of (configs in js.configs needs to reference js in their plugins.js) makes creation of them quite convoluted and is something that should be moved away from rather than encouraged.

Since all plugin definitions that share the same name needs to be === to each other that means that the plugin configs needs to reference the very plugin object that exposes the recommended configs.

This has led to bugs like: https://github.com/eslint/eslint/blob/0583c87c720afe0b9aef5367b1a0a77923eefe9d/lib/config/flat-config-schema.js#L373-L375

And required workarounds like: neostandard/neostandard@26b472b

Right now plugins typically do:

const base = {
    meta: {
        name: pkg.name,
        version: pkg.version,
    },
    rules: {
        "callback-return": require("./rules/callback-return"),
   },
};
 
base.configs = {
  "flat/recommended": { plugins: { n: base }, ...recommendedConfig.flat },
};

module.exports = base;

I would prefer if one would do eg:

const plugin = {
    meta: {
        name: pkg.name,
        version: pkg.version,
    },
    rules: {
        "callback-return": require("./rules/callback-return"),
   },
};
 
const configs = {
  "flat/recommended": { plugins: { n: plugin }, ...recommendedConfig.flat },
};

module.exports = { plugin, configs };

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure I understand what you're saying. This proposal would solve the problem you're highlighting here.

Choose a reason for hiding this comment

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

@nzakas This proposal contains many proposals, one of which aims to solve this by adding magic that modifies the config. That part of the proposal feels like a suggestion that should be made separately as a follow up to this RFC and is needlessly complicated compared to what I highlight / suggest here.

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think using the term "magic" helpful in this context. I'm using the same lookup pattern that we already use for rules, processors, and languages, so this is actually a well-established pattern. Further, the implementation of extends may change depending on whether or not this part of the proposal is included, so it makes sense to include it in this RFC.

What you're suggesting is that everyone who writes plugins change the way they do it AND we change the way we interpret plugins. That's a much more dramatic proposal than named configs.

Choose a reason for hiding this comment

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

What I’m suggesting is not drastic at all. Not sure why you are suggesting that it is?

I’m simply suggesting that ESLint does not try to enforce a standard for configs exported by plugins – keeping configs completely separate from the plugin object.

Also: I’m sorry that “magic” offended you, I should have used “logic” as a more neutral word. I intended “magic” to be interpreted as “resolving and inserting plugin reference automagically”. I’ll try to be more careful with my choice of words. Thanks for pointing it out 🙏

Copy link
Member Author

Choose a reason for hiding this comment

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

I’m simply suggesting that ESLint does not try to enforce a standard for configs exported by plugins – keeping configs completely separate from the plugin object.

Yes, I understand what you were suggesting. However, the team has already agreed to standardize config exports, so encouraging plugin developers to use the already-defined plugin format is part of the goal of this RFC.

Choose a reason for hiding this comment

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

This RFC should really refer to such a crucial change request then as its essential background information for anyone who it has passed by.

Its also kind of confusing what becomes RFC:s and what doesn't. That change request would have been great to have as an RFC so that it would have eg appeared on my radar and not mostly/just on the radar of those following all issues in the ESLint repository. Or is there something I'm missing where I should have made myself aware of this?

As I'm a co-maintainer of quite a few plugins I have made sure to follow the RFC:s, but I do not have the bandwidth to follow all of the ESLint issues and I'm not a member of the ESLint team so I shouldn't have to be following it to give my feedback as a community member?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm sorry, I don't know how to solve the problem of making sure everyone is aware of any changes they're interested in at any point in time. The biggest changes are always done via RFC, and we promote those on Twitter and Mastodon for maximum visibility.

Standardizing something, or indeed, deciding to document something that was previously undocumented, doesn't really lend itself to the RFC process.

In this case, we're really talking about a documentation change for what we'd like to see people do. We have no way to enforce such a convention and this documentation change won't affect the way existing packages can be used.


Here, we are hardcoding the namespace `json` even though that might not be the namespace that the user assigns to this plugin. This is something we can now address with the use of `extends` because we have the ability to alter the config before inserting it.

Instead of using a hardcoded plugin namespace, plugins can instead use `#` to indicate that they'd like to have the plugin itself included and referenced using the namespace the user assigned. For example, we could rewrite the JSON plugin like this:

Choose a reason for hiding this comment

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

Or one could define it as:

const plugin = {
  // ...
};

const configs = {
        recommended: (pluginAlias = 'js') => {
            plugins: { pluginAlias: plugin },
            rules: {
                `${pluginAlias}/no-duplicate-keys`: "error",
            },
        },
};

module.exports = { plugin, configs };

And use it like:

js.configs.recommended('foo');

Simpler and less magic. No need for ESLint to modify the config, the config would modify itself.

One could even have "j/recommended" result in js.configs.recommended('j'); if one strongly wants to support the "j/recommended" shortcut.


Generating configs from a function is a pattern that we eg. use in neostandard:

import neostandard from 'neostandard'

export default neostandard({
  noStyle: true,
  ts: true,
});

Copy link
Member Author

Choose a reason for hiding this comment

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

Exporting a function then muddies the water of a config is, forcing people to understand not just whether something is an object or array (a problem we trying to remedy with this proposal), but then they'd also have to know whether something was a function.

As stated in this RFC Motivation section, right now, end users needing to configure things differently based on how plugins export config is a big problem and source of confusion. I'm trying to remove the necessity for end users to think about this.

Choose a reason for hiding this comment

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

I would split the two goals of this RFC into one RFC each. The case for extends is more proven and clear to me than the need for and even more so the solution for discoverability of shared configs.

Choose a reason for hiding this comment

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

Another example of configs being returned by a function: https://github.com/github/eslint-plugin-github/releases/tag/v5.1.0

One more: https://eslint.nuxt.com/packages/config

@antfu You like generating the flat config through a function, right?

Copy link

Choose a reason for hiding this comment

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

After quite a few experiments with flag config over a year, I ended up thinking that functions provide the best overall UX. Where the functions can further hide the implementation details and provide high-level options to make things easier for the end.

I don't know how many people can relate to this, but for example, if we want a shared config for Vue (similar stories for other embedded languages like Svelte or Astro etc.), we need to setup some different overrides for the scripts inside .vue files. As people might have their choices to use TypeScript or not, we might end up exposing:

export default {
  configs: {
    'javascript': [...],
    'typescript': [...],
    'typescript-strict': [...],
    'vue-javascript': [...],
    'vue-typescript': [...],
    'vue-typescript-strict': [...],
    // ...
  }
}

It would basically double the number of configs for each variation we want to introduce, where if the configs is a function, it can abstract the details and expose configs for compose easily:

configs({
  typescript: true, // 'strict'
  vue: true,
  // ...
})

I know it's a bit off-topic, what I am trying to say is the value of using functions of configs. Whether this RFC lands or not, it doesn't diminish that.

end users needing to configure things differently based on how plugins export config is a big problem and source of confusion. I'm trying to remove the necessity for end users to think about this.

(this is why I am bringing #126 (comment) up)

Copy link
Member Author

Choose a reason for hiding this comment

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

We've heard the feedback that plugins using functions to define configs (such as typescript-eslint) actually introduce more confusion for end users. I think we need to encourage plugins to stop defining their own ways of using their configs and get them to all start behaving more similarly so users don't have to read the READMEs of every plugin they want to use in order to get their project configured.

Copy link

@voxpelli voxpelli Nov 27, 2024

Choose a reason for hiding this comment

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

I guess the major conclusion that can be had from this and eg. eslint/eslint#18800 is that ESLint does not desire to be a tool that a ruleset can use to enforce itself, it wants to be a tool that users use and configure for themselves.

Users should always start with ESLint and be configuring it with the mechanisms that ESLint provides and plugins should only provide rules and static named config presets.

Added neostandard/neostandard#220 for neostandard to look into how to adapt to these changes.

Copy link
Member Author

Choose a reason for hiding this comment

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

ESLint does not desire to be a tool that a ruleset can use to enforce itself, it wants to be a tool that users use and configure for themselves.

Yes, that's correct. The entire design of ESLint is to support end-user configuration and not allow plugins to arbitrarily enforce things without the user's knowledge.

designs/2024-config-extends/README.md Outdated Show resolved Hide resolved
Comment on lines 398 to 400
1. Enable nested arrays in `FlatConfigArray`
1. Create a new `ConfigExtender` class that encapsulates the functionality for extending configs.
1. Update `FlatConfigArray` to use `ConfigExtender` inside of the `normalize()` and `normalizeSync()` methods.

Choose a reason for hiding this comment

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

Since FlatConfigArray is not something that's exported (see discussion in eslint/eslint#18619) this would not be something that the config-inspector can make use of but something that it would need to re-implement, which would be a lot of extra work and a risk of divergence / differences in implementation.

I would strongly suggest that the non-exporting of FlatConfigArray is reconsidered if all this is added there.

An option could be that the ConfigExtender is added as part of @eslint/config-array instead and that ConfigArray rather than FlatConfigArray is updated to use it in normalize(), and that it behaves like nested arrays: Its something one opts into / out of for a config array

Thoughts @antfu?

Copy link

Choose a reason for hiding this comment

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

Yeah, the fact I love the flat config format also makes config-inspector possible is that it's very transparent, where you can import the config file as a plain js module without eslint to get all the information. What I understand is that we now want to move to a bit of the middle ground to have ESLint interop the config a bit to provide better DX, which I would be love to see.

In that regard, in order to keep the inspector working (where we still present the final resolved flat configs to users), I agree with @voxpelli that we do need ESLint to expose those API to handle the config resolutions to the final config array.

Copy link
Member Author

@nzakas nzakas Nov 21, 2024

Choose a reason for hiding this comment

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

That's good feedback re: FlatConfigArray and a good argument for adding this functionality into @eslint/config-array directly. I'll take a look at what this would look like and update the RFC once I've figured it out.

I'm still hesitant to export FlatConfigArray itself because we are still making changes and I don't want to be tied to that API by exposing it.

Comment on lines 494 to 498
### Why is this functionality added to `FlatConfigArray` instead of `@eslint/config-array`

In order to support named configs, we need the concept of a plugin. The generic `ConfigArray` class has no concept of plugins, which means the functionality needs to live in `FlatConfigArray` in some way. There may be an argument for supporting `extends` with just objects and arrays in `ConfigArray`, with `FlatConfigArray` overriding that to support named configs, but that would increase the complexity of implementation.

If we end up not supporting named configs, then we can revisit this decision.

Choose a reason for hiding this comment

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

As I mentioned in another comment, this makes it harder for eg. config-inspector to mimic the same logic since FlatConfigArrayis currently only available internally in ESLint (see discussion in eslint/eslint#18619)

// intersected files and original ignores
{
name: "myconfig > config1",
files: [["**/src/*.js", "**/*.cjs.js"]],
Copy link
Member

Choose a reason for hiding this comment

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

What would be files in the final config when files in the user config and extended config have multiple items? For example:

{
    files: ["src/**", "lib/**"],
    extends: [{ files: ["**/*.js", "**/*.mjs"] }]
}

Copy link
Member Author

Choose a reason for hiding this comment

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

In order to get the intersection correct, it would look like this:

{
    files: [["src/**", "**/*.js"], ["lib/**", "**/*.js"], ["src/**", "**/*.mjs"], ["lib/**", "**/*.mjs"]]
}

Copy link

@aaronadamsCA aaronadamsCA Nov 22, 2024

Choose a reason for hiding this comment

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

Right now a path must match all of any, but I think any of all would be more intuitive and more powerful:

{
  files: [["src/**",   "lib/**"],   ["**/*.js",   "**/*.mjs"]]
  //      ("src/**" || "lib/**") && ("**/*.js" || "**/*.mjs")
}

This could make intersecting multiple arrays—or even arrays of arrays—as simple as smushing them together.

Copy link
Member Author

Choose a reason for hiding this comment

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

@aaronadamsCA While that makes merging arrays easier, I don't think this is what end users would expect. If my config says src/** and the extended config says **/*.js, then it would be confusing if, in my project, tests/foo.js was linted because that was definitely not what I specified.

Copy link

@aaronadamsCA aaronadamsCA Nov 26, 2024

Choose a reason for hiding this comment

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

@nzakas In the scenario you describe, I'd expect this intersection:

{
  files: [["src/**"],   ["**/*.js"]]
  //      ("src/**") && ("**/*.js")
}

Which still doesn't match tests/foo.js.

I think users and plugin developers can understand this pretty easily. If you provide one array, input files must match any pattern (union). If you provide multiple arrays, input files must match any pattern (union) in every array (intersection).

Maybe there's something I'm missing, but I think an intersection of unions will work better than a union of intersections for most cases.

Copy link
Member

Choose a reason for hiding this comment

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

an intersection of unions will work better than a union of intersections for most cases.

The result is same:

(A ∪ B) ∩ (C ∪ D) = (A ∩ C) ∪ (B ∩ C) ∪ (A ∩ D) ∪ (B ∩ D)

It's just that @eslint/config-array doesn't support intersections of unions while it does support unions of intersections. And this would be, I believe, just an internal technical detail - users wouldn't see the calculated files.

Copy link
Member Author

Choose a reason for hiding this comment

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

I think users and plugin developers can understand this pretty easily. If you provide one array, input files must match any pattern (union). If you provide multiple arrays, input files must match any pattern (union) in every array (intersection).

If I'm struggling to understand this, I'm not so sure how easily everyone will. 😄

Ultimately, @mdjermanovic is correct, this is an implementation detail and won't be visible to users.

Copy link

@voxpelli voxpelli left a comment

Choose a reason for hiding this comment

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

I would split this RFC into two then.

One which addresses the difficulty to extend another config and introduces the extends and another one which addresses the discoverability of exported configs.

I feel that the case for extends is quite solid. I wouldn’t say the same about the discoverability of exported configs.


Here, we are hardcoding the namespace `json` even though that might not be the namespace that the user assigns to this plugin. This is something we can now address with the use of `extends` because we have the ability to alter the config before inserting it.

Instead of using a hardcoded plugin namespace, plugins can instead use `#` to indicate that they'd like to have the plugin itself included and referenced using the namespace the user assigned. For example, we could rewrite the JSON plugin like this:

Choose a reason for hiding this comment

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

I would split the two goals of this RFC into one RFC each. The case for extends is more proven and clear to me than the need for and even more so the solution for discoverability of shared configs.

@nzakas
Copy link
Member Author

nzakas commented Nov 25, 2024

I updated the section on named configs based on @michaelfaith's feedback. I think this new approach (meta.namespace) allows plugin-exported configs to work the same way they do now while still allowing namespace rewriting.

@michaelfaith
Copy link

I updated the section on named configs based on @michaelfaith's feedback. I think this new approach (meta.namespace) allows plugin-exported configs to work the same way they do now while still allowing namespace rewriting.

I like that a lot more. Feels more like a progressive enhancement. I'm assuming that plugins that haven't yet updated to provide meta.namespace yet, that it'll be behave as it did before? Will there be a point where that's required?


#### Extending Named Configs

While the previous examples are an improvement over current situation, we can go a step further and restore the use of named configs by allowing strings inside of the `extends` array. When provided, the string must refer to a config contained in a plugin. For example:

Choose a reason for hiding this comment

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

restore the use of named configs by allowing strings inside of the extends array. When provided, the string must refer to a config contained in a plugin

This restores "extends": "plugin:js/recommended" as extends: ['js/recommended'] but doesn't restore the previous such use of extends – the shareable configs: "extends": "standard"

I think this makes the "restoration" historically confusing, as the way it used to work was that if you didn't prefix with plugin: then extends referred to a shareable config.

And as mentioned clearly in eslint/eslint#18800 (comment) you don't want to enable loading a shareable config if someone does extends: ['standard'], so someone migrating from an old config and expecting that to load and use eslint-config-standard would get confused.


Could it therefore maybe make sense to, for historical reasons, force a plugin: prefix and error when such a prefix isn't given, telling the user that extends of non-plugin configs is no longer supported?


And will this maybe encourage shareable configs like neostandard to start exposing themselves as a rule-less plugin so that users can reference them in extends through a string? As else a shareable config can't be referenced by its name, only by its definition?

Eg having to do:

import neostandard from "neostandard";

export default [
    {
        plugins: { neostandard },
        extends: ['neostandard/recommended']
    }
];

Rather than:

export default [
    {
        extends: ['neostandard']
    }
];

Copy link
Member Author

Choose a reason for hiding this comment

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

This restores "extends": "plugin:js/recommended" as extends: ['js/recommended'] but doesn't restore the previous such use of extends – the shareable configs: "extends": "standard"

There isn't really such a thing as shareable configs anymore. That's one of the side effects of going to a JS-based config system.

Ultimately, we want to encourage all packages that exports configs to use the configs property, in which case they all end up looking like plugins and can be used as such.

I see this more as a streamlining. In eslintrc, shareable configs predated plugins, which is why we needed to add the plugin: prefix to disambiguate. If we had to do it over again, everything would be a plugin, which is where we're heading now.

Choose a reason for hiding this comment

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

So if I'm hearing you correctly the suggested way forward for a shareable config like neostandard is to do this when this RFC gets accepted?

import neostandard from "neostandard";

export default [
    {
        plugins: { neostandard },
        extends: ['neostandard/recommended']
    }
];

Copy link
Member Author

Choose a reason for hiding this comment

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

If you want to allow configs to be referenced by strings, yes. However, you wouldn't be required to do that as extends will still support passing on objects and arrays in addition to strings. So if you're just exporting one object or one config, you could still tell people to do this:

import neostandard from "neostandard";

export default [
    {
        files: ["**/*.js"],
        extends: [neostandard]
    }
];

Copy link
Contributor

@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.

I concur with the existing points against extends allowing strings, but otherwise am positive on the proposal. It'll be nice to have more first-party standardization of these common plugin and flat config patterns! 🙌

];
```

When an extended config and the base config both have multiple `files` entries, then the result is a `files` entry containing all combinations. For example:
Copy link
Contributor

Choose a reason for hiding this comment

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

It would be nice to intelligently deduplicate these. For example, given:

{
  files: ["src/**", "lib/**"],
  extends: [{ files: ["src/**", "lib/*.js"] }]
}

...then the resultant files could look like, depending on how aggressive the deduplication:

  • Not very aggressive (i.e. just ===): ["src/**", "lib/**", "lib/*.js"]
  • Very aggressive (intelligent glob deduping): ["src/**", "lib/**"]

I think this would be a good followup and doesn't need to be in an initial version.

Copy link
Member Author

Choose a reason for hiding this comment

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

Agreed. I think this is an optimization that can be done during implementation.

];
```

Here, `js/recommended` refers to the plugin defined as `js`. Internally, ESLint will look up the plugin with the name `js`, looks at the `configs` key, and retrieve the `recommended` key as a replacement for the string `"js/recommended"`.
Copy link
Contributor

Choose a reason for hiding this comment

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

A vote against extends strings: I don't think they reasonably be modeled in a type system.

One of the points I really like about flat config is how it's much more statically analyzable than legacy/eslintrc. Other than rules entries, roughly everything can be seen and helped with by editor intellisense / type checking. It being "just JavaScript" means that as users type in files like eslint.config.*, they get TypeScript assistance.

Having "magic" not-easily-type-checked string constants is hard to model in the type system. I think that's reflective of how they need to be reasoned about. Users need to now memorize some "arbitrary" (unique to ESLint) system for mapping strings to plugins -> configs. As in eslintrc, those arbitrary systems are an extra burden to learn -- and extra burdensome because of the lessened editor help.

Copy link
Member Author

Choose a reason for hiding this comment

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

I understand your point, I'm just not sure that modeling in the type system is as necessary as it seems. We went ten years with eslintrc and people seemed to do just fine. Worse case there's an error if you access a string that doesn't exist.

Choose a reason for hiding this comment

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

and people seemed to do just fine

STRONGLY disagree.

People fumbled around and "made it work". They didn't do "just fine"!
Many people grumbled when the switch to .js eslintrcs was enforced because it meant that they no longer had a JSONSchema to give them IDE features on their configs.

People would often write their config file like this so that they would get a type-checked config with IDE features:

/** @type {import('eslint').Linter.Config} */
module.exports = { ... };

Here's an example query showing that there's still ~3000 eslintrcs across github that exist and do this

The type system allows for many great DevX improvements beyond just checking and enforcement.
If you use VSCode with JS -- you're relying on the type system provided by TypeScript without even knowing it!

  • Member autocomplete? TypeScript
  • Property autocomplete? TypeScript
  • Imported name autocomplete? TypeScript
  • JSDoc? TypeScript

All of these features are powered by TS's ability to model the code.

Worse case there's an error if you access a string that doesn't exist

If you've got the opportunity to provide a better, more immediate feedback loop to users -- why would you not want to support that?

  • "Write a magic string, run eslint, wait for eslint to error" is a slow workflow that's pretty painful.
  • "Change a plugin reference, run eslint, wait for eslint to error" is painful.
  • "Update a plugin version, run eslint, wait for eslint to error" is painful.

Providing these things in the type system means people can get immediate feedback in their IDE.
It means that plugin owners can provide JSDoc annotations to people to provide docs and links directly in their IDE.

--

By continuing on this path you are actively creating a worse DevX for users:

  • it cannot be modeled with types -- meaning:
    • it is not auto-complete-able
    • it is not checkable pre-lint run
    • it cannot have JSDoc annotations added
  • it is yet another way to do things:
    • shareable configs cannot use this system
    • not all plugins declare their configs on .configs -- these plugins won't work with this system
    • not all plugins declare objects and instead declare functions -- these plugins won't work with this system

Copy link

@bradzacher bradzacher Nov 27, 2024

Choose a reason for hiding this comment

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

The big thing the ecosystem has been asking for and looking for from you guys is clear documentation saying how to do things with flat configs. This is why there's a lack of standardisation -- because there wasn't clear documentation on how you thought people should do things.

And so in lieu of documentation the ecosystem made it up. And as expected - without direction people went in a lot of different directions.

If you want to standardise things -- clearly document the standard!
Clearly document the conventions in unambiguous terms.
Clearly document what properties are required and what is optional but recommended.
Clearly document that you discourage /configs import paths.
Heck - provide an example repo showing how you would recommend people structure things.

Creating a magic API that only works if a plugin uses a standard that wasn't ever standardised isn't a good way to introduce said standard. It's just going to confuse everyone and cause pain for your users.

Choose a reason for hiding this comment

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

I can just imagine how painful this is going to be for both users and plugin authors.

For example -- if a user does

import ts from 'typescript-eslint';

export default [
  { plugins: { ts: ts.plugin } },
  'ts/recommended',
];

They will get an error.
We do not export ts.plugin.configs.recommended.
We export ts.configs.recommended.

A user will be confused about why this didn't work.
They won't understand the differentiation.
They will file issues with us and we'll have to tell them to use ts.configs.recommended -- wasting everyone's time.

The same will happen for plugins that currently export a .flatConfigs property.
The same will happen for plugins that export config functions on configs.
The same will happen for plugins that have a /configs import path.

Copy link
Member

Choose a reason for hiding this comment

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

@nzakas Please listen to @bradzacher on this. He represents our collective experience of working with these constraints for nearly a decade at this point.

It's worth reiterating - meaningfully more than half of your userbase uses eslint via typescript-eslint. There is also an above average representation of large scale, real world repos in that proportion, because those teams overwhelming use TypeScript. Any data from our userbase has has a disproportionately large amount of value to you.

The typescript-eslint team in our day jobs spends a significant amount of time shipping code in these exact kinds of repos, so as well as our own experiences, we also watch developers (both colleagues and S&P 500 customers, from the most junior to the most senior) get tripped up and frustrated by the exact things Brad is feeding back on.

This RFC is itself an admission that Brad's feedback should not have been dismissed the first time around when releasing flat config, please, heed the feedback he is giving you now

Copy link
Member Author

Choose a reason for hiding this comment

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

First, I appreciate @bradzacher's passion for doing the right thing. While I don't believe all of his criticism is fair, I assure you that I and the team in general always read everything he posts.

That said, I do want to remind you that we are getting feedback that the current way of doing things, allowing plugins to export configs however they want, is not working for our users. That is data that we have and feedback we've received repeatedly. Simply adding extends and flattening the array doesn't do anything to address that problem. It still requires users to find all the different ways that plugins are providing configs.

I also want to remind you that we, too, have experience working with teams inside of companies that are having problems. That's why I put this RFC together. The feedback you're receiving and providing back to us is valuable, and so is the feedback we are receiving directly from our users and sponsors. We need to be balanced in assessing that feedback across the board.

This RFC is itself an admission that Brad's feedback should not have been dismissed the first time around when releasing flat config, please, heed the feedback he is giving you now

There was never any dismissal of Brad's feedback. His feedback came very late in the development cycle and so it wasn't prudent to start making changes before we got more feedback from the community at large. Now that we have, we're looking to make changes. I think that's an indication that the process is working.

export default plugin;
```

Here, we are hardcoding the namespace `json` even though that might not be the namespace that the user assigns to this plugin. This is something we can now address with the use of `extends` because we have the ability to alter the config before inserting it.
Copy link
Contributor

Choose a reason for hiding this comment

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

... that might not be the namespace that the user assigns to this plugin. This is something we can now address ...

I would prefer not to encourage people to rename plugins. Same reasoning as eslint/eslint#17766: it creates a surprising discrepancy between plugin authors, plugin config re-exporters, and end-users.

For example, with @typescript-eslint/ vs. ts/ as a prefix:

...so, as Brad predicted in eslint/eslint#17766 (reply in thread), there is some user request for this. But outside of heavily managed setups such as antfu/eslint-config I don't see any reason to encourage it in the wild.

Copy link
Member Author

Choose a reason for hiding this comment

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

I think there's a difference between allowing this and encouraging. This functionality was always intended to be a feature of flat config and the ability to specify arbitrary namespaces for plugins was requested way back in eslintrc.

Moreover, the feedback we've received is that people expect this to work today, and it doesn't.

What we are going for here is trying to reduce the amount of surprises in the way the config system works. As an end user, you just want to be able to say plugins: { foo } and then have rules: { "foo/bar": "error"} just work. You don't care about anything else. Needing to ensure you're using the prescribed namespace for a plugin means needing to read yet another README, and that's what we're trying to avoid.

There are two goals with this design:

1. **Make it easier to configure ESLint.** For users, we want to reduce unnecessarily boilerplate and guesswork as much as possible.
1. **Encourage plugins to use `configs`.** For plugin authors, we want to encourage the use of a consistent entrypoint, `configs`, where users can find predefined configurations.
Copy link
Contributor

Choose a reason for hiding this comment

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

[Praise] Yes two both of these - especially encouraging standardization on configs (eslint/eslint#18095)!

designs/2024-config-extends/README.md Outdated Show resolved Hide resolved
configs: {},
};

Object.assign(plugin.configs, {
Copy link
Contributor

Choose a reason for hiding this comment

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

An alternative could be to provide a builder function for configs. We're very likely to do that soon in typescript-eslint per the coincidentally timed typescript-eslint/typescript-eslint#10383). Vague first straw man example:

export const plugin = createPlugin({
  meta: {
    name: "@eslint/json",
    // This namespace is expected to match what's in configs[string].rules
    namespace: "json",
    version: "0.6.0",
  },
  languages: {
    json: new JSONLanguage({ mode: "json" }),
    jsonc: new JSONLanguage({ mode: "jsonc" }),
    json5: new JSONLanguage({ mode: "json5" }),
  },
  rules: {
    "no-duplicate-keys": noDuplicateKeys,
    "no-empty-keys": noEmptyKeys,
  },
  configs: {
    // Each config would automatically have added the equivalent of:
    //   plugins: { json: plugin }
    recommended: {
      rules: {
        "json/no-duplicate-keys": "error",
        "json/no-empty-keys": "error",
      },
    },
  },
});

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure I understand the benefit vs. what I'm proposing in this RFC. Creating a builder function just for the purpose of inserting a single plugin into all configs seems like overkill to me.

Design Summary:

1. Allow arrays in config arrays (in addition to objects)
1. Introduce an `extends` key in config objects
Copy link

@kirkwaiblinger kirkwaiblinger Nov 27, 2024

Choose a reason for hiding this comment

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

While I fully agree with the need for a canonical way to extend configs, it's not obvious to me why the mechanism to do so should be an object key added to the config. Is that obvious? If so can it be added to this document?

The old config system worked that way, and we're all used to that, but in a static JSON config there's no other choice than an "extends" key. Hasn't that constraint been lifted? Now that eslint is all-in on the executable config system, why wouldn't just a helper function be provided instead?

Adapting the first example in this RFC, this could look like

import eslint from "eslint";
import js from "@eslint/js";
import example from "eslint-plugin-example";

export default [
  extend(
    {
      files: ["**/src/*.js"],
    },
    [js.configs.recommended, example.configs.recommended],
  ),
];

which actually evaluates to

import js from "@eslint/js";
import example from "eslint-plugin-example";

export default [
  [
    {
      ...js.configs.recommended,
      files: ["**/src/*.js"],
    },
    {
      ...example.configs.recommended,
      files: ["**/src/*.js"],
    },
  ],
];

before even being exposed from the config file, rather than assuring "if an extends key is present, it is going to be treated equivalently by eslint as if you had written out a bunch of repetitive JSON". I would think this also lets users debug any surprising behavior better.


Part of my motivation for this is that I strongly agree with @bradzacher's every word in #126 (comment). I find that the least surprising, most user-friendly, and altogether only correct behavior regarding the "files" is to override the extended configs' files in an API style with an "extends" key, and I will not be budged from this perspective. That being said, there is certainly proof in this RFC that others see it differently.

A built-in helper can give the user the option, for example like so

extend(config, extensionConfigs, { files: "override" });
extend(config, extensionConfigs, { files: "intersect" });

And then we're only bikeshedding about what the right default is, rather than the right difficult-to-work-around behavior is.


Somewhat related to the previous, another concern is - as also noted several times - the "extends" key already exists in typescript-eslint's config() helper. This is a nontrivial user share, and it would be a real yucky situation for typescript-eslint config() users if "extends" came to have a different meaning with or without wrapping the configs in config().


Note - one can come up with all sorts of fancy helper API designs. I only suggest extend(config, extensionConfigs, options) because it's the simplest. But feel free to imagine in your head whatever the most appealing style is (maybe extend(config).with(extensionConfigs)?). The specific signature isn't really relevant to my question.

Note also that that the .flat(Infinity) behavior totally makes sense to me to be handled implicitly still.

Copy link
Member Author

Choose a reason for hiding this comment

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

While I fully agree with the need for a canonical way to extend configs, it's not obvious to me why the mechanism to do so should be an object key added to the config. Is that obvious? If so can it be added to this document?

This is covered in the "Alternatives" section in the RFC.

Somewhat related to the previous, another concern is - as also noted several times - the "extends" key already exists in typescript-eslint's config() helper.

I do realize this, and it's unfortunate, but I don't think that should prevent us from doing what makes the most sense for most users. We've heard the feedback that the way typescript-eslint recommends configuration is confusing because it looks different than the ESLint docs. Prior comments in this RFC also note that the way extends works in tsconfig doesn't always match expectations. It's unfortunate that tsconfig didn't at least try to mimic what extends did in eslintrc.

To reiterate from previous comments and the RFC itself, the overall feedback from ESLint users is that:

  1. There are too many different ways to extend configs in the ecosystem
  2. Beginners find using JavaScript-y ways of doing things confusing, so adding back extends (something familiar from eslintrc) seems to be the best approach.

Choose a reason for hiding this comment

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

This is covered in the "Alternatives" section in the RFC.

Ah, missed that, sorry!

I do realize this, and it's unfortunate, but I don't think that should prevent us from doing what makes the most sense for most users. We've heard the feedback that the way typescript-eslint recommends configuration is confusing because it looks different than the ESLint docs. Prior comments in this RFC also note that the way extends works in tsconfig doesn't always match expectations. It's unfortunate that tsconfig didn't at least try to mimic what extends did in eslintrc.

Yeah I don't disagree at all with the points that the state of the typescript-eslint helper isn't ideal. All I'm suggesting is that when weighing "extends" key vs helper, the ability to avoid inconsistencies between eslint and typescript-eslint, which many users seem to assume are more or less one and the same, is a benefit of the helper approach.

To reiterate from previous comments and the RFC itself, the overall feedback from ESLint users is that:

  1. There are too many different ways to extend configs in the ecosystem

+1

  1. Beginners find using JavaScript-y ways of doing things confusing, so adding back extends (something familiar from eslintrc) seems to be the best approach.

Sorry, they're asked to understand package management, module system and imports/requires, but a function call is what would put it over the edge??

Copy link
Member Author

Choose a reason for hiding this comment

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

Sorry, they're asked to understand package management, module system and imports/requires, but a function call is what would put it over the edge?

It's not necessarily the function call, but how it fits into the larger array. Trust me, I was just as surprised when we started getting feedback that people couldn't figure out how to use .map(), Object.assign(), ..., etc., to mix and match their configs.

We need to be cognizant that ESLint is used by a huge number of developers with a wide range of experience in JavaScript.

Choose a reason for hiding this comment

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

I hear you on this. FWIW though, I'm personally not surprised at all that users have difficulty with .map(), Object.assign(), or .... Those are very language-specific constructs (the last two triply so), not universal programming concepts. .map() is the least JS-specific, but in order to use it you need to write function expressions (arrow or not), which are language-specific knowledge. Calling an existing function with concrete arguments, on the other hand, is about as universal a concept to coding as I can think of.

Choose a reason for hiding this comment

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

But yes, to your point, how it all it all comes together is obviously an important part of the design intuitiveness as well. I think an extends helper is pretty intuitive in this context, but I'm willing to grant that that may or may not hold true to users at large

Copy link
Member Author

Choose a reason for hiding this comment

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

I think overall what I'm trying to avoid here is the need for users to understand a bunch of additional tools or techniques they have to become familiar with in order to properly assemble a config array. That's why I think a solution like defineConfig hits a sweetspot where they can always use that, and the rest is just JavaScript arrays and objects.

@nzakas
Copy link
Member Author

nzakas commented Nov 27, 2024

I like that a lot more. Feels more like a progressive enhancement. I'm assuming that plugins that haven't yet updated to provide meta.namespace yet, that it'll be behave as it did before?

Yes, exactly.

Will there be a point where that's required?

No. We will want to encourage it, but it won't be required.

I'll add these details to the RFC.

## Alternatives

1. **A utility package.** Instead of including `extends` in flat config itself, we could create a utility package that provides a method to accomplish the same thing. This has the advantage that ESLint v8 users could also use this utility package, but the downside that it would require users to install Yet Another Package to do something that many feel should be available out of the box.
1. **A utility function.** A variation of the previous approach is to export a function from the `eslint` package that people could use to extend configs. This has the advantage that it would be easier to feature test for but the downside that it still requires an extra step to use `extends`.

This comment was marked as resolved.

@nzakas
Copy link
Member Author

nzakas commented Dec 2, 2024

I updated the "Alternatives" section based on @kirkwaiblinger's comments and also after doing some thinking over the weekend.

At this point, I've almost convinced myself that providing a defineConfig() function (now listed as alternative 1) may actually be the best approach, but I'd like to hear some feedback on that before going back and reworking the RFC altogether.

@antfu
Copy link

antfu commented Dec 3, 2024

I love the defineConfig approach the most, as it keeps the transparency nature of flat config and also provides type checks. I'd love to see it land! 👍

];
```

If the objects in `extends` contain `files`, then ESLint will intersect those values (AND operation); if the object in `extends` contains `ignores`, then ESLint will merge those values (OR operation). For example:
Copy link

@kirkwaiblinger kirkwaiblinger Dec 4, 2024

Choose a reason for hiding this comment

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

I'd like to make one more plea to rethink this behavior to be overriding instead of intersecting (or, to sidestep the whole question by providing helper functions). I'm currently trying to set up a fairly out-of-the-box project with vue and TS for my day job.

So, I have this setup

import pluginVue from 'eslint-plugin-vue';
import tseslint from 'typescript-eslint';
import eslint from '@eslint/js';

export default [
  {
    ignores: ['node_modules', 'dist', 'dist-ssr'],
  },
  eslint.configs.recommended,
  tseslint.configs.eslintRecommended,
  tseslint.configs.recommended,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.filename,
      },
    },
  },
  {
    files: ['**/*.vue'],
    languageOptions: {
      parserOptions: {
        parser: tseslint.parser,
        extraFileExtensions: ['.vue'],
      },
    },
  },
  pluginVue.configs['flat/recommended'],
].flat(Infinity);

Did you catch the error? The base eslint config (eslint.configs.recommended) applies to all files regardless of file extension, and the tseslint.configs.eslintRecommended config applies to JS/TS-like files only. So I get false positives like no-undef and others in my *.vue TS code, since I haven't applied tseslint.configs.eslintRecommended to it.

As a user, I don't know or much care which config is in the right about the file extensions to which it applies. But, I want to be able to fix this issue easily enough. tseslint.configs.eslintRecommended doesn't apply to vue files? No problem; I'll chuck it in as an extends to my vue-specific config. With tseslint.config() that looks like:

import pluginVue from 'eslint-plugin-vue';
import tseslint from 'typescript-eslint';
import eslint from '@eslint/js';

export default tseslint.config(
  {
    ignores: ['node_modules', 'dist', 'dist-ssr'],
  },
  eslint.configs.recommended,
  tseslint.configs.eslintRecommended,
  tseslint.configs.recommended,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.filename,
      },
    },
  },
  {
    files: ['**/*.vue'],
    extends: [tseslint.configs.eslintRecommended],
    languageOptions: {
      parserOptions: {
        parser: tseslint.parser,
        extraFileExtensions: ['.vue'],
      },
    },
  },
  pluginVue.configs['flat/recommended'],
);

Done! And intuitive IMO.

What shall I do if extends is an intersection? Maybe this?

import pluginVue from 'eslint-plugin-vue';
import tseslint from 'typescript-eslint';
import eslint from '@eslint/js';

export default [
  {
    ignores: ['node_modules', 'dist', 'dist-ssr'],
  },
  eslint.configs.recommended,
  tseslint.configs.eslintRecommended,
  tseslint.configs.recommended,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.filename,
      },
    },
  },
  {
    files: ['**/*.vue'],
    languageOptions: {
      parserOptions: {
        parser: tseslint.parser,
        extraFileExtensions: ['.vue'],
      },
    },
    extends:
      (() => {
        const maybeMultipleConfigs = structuredClone([tseslint.configs.eslintRecommended]);
        const flattenedConfigs = maybeMultipleConfigs.flat(Infinity);
        for (const config of flattenedConfigs) {
          delete config.files;
        }
        return flattenedConfigs;
      })(),
  },
  pluginVue.configs['flat/recommended'],
];

How will a user possibly expand the included files of an arbitrarily deeply nested config array if the only operation they have access to is intersection? I don't see how it can be done without nontrivial amounts of user-defined JS... which is the express purpose of this rfc to eliminate.

Copy link
Member Author

Choose a reason for hiding this comment

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

I feel like the relevant points were covered in this thread already, so I don't want to duplicate here all of that discussion here.

Intersection is what eslintrc did, and I think it still makes the most sense for the majority of users. Overriding means we lose the ability to extend a config array in any meaningful way -- there's really not a great reason to provide an array if you can't limit each object to a subset of files, and overwriting files for every config object in an extended array is unlikely to produce the desired result.

};


export default defineConfig([

Choose a reason for hiding this comment

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

Given we're also proposing calling .flat(Infinity) -- I would suggest making this a variadic function.

This would allow the flexibility of doing both: defineConfig([ {...}, {...} ]) and defineConfig({...}, {...}) and having the same config as a result.

It's a nice thing to be able to drop the explicit extra [] wrapping inside of the function parens ().

Copy link
Member Author

Choose a reason for hiding this comment

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

Fair point. 👍


### A `defineConfig()` function

One alternative is to create a `defineConfig()` function that is exported from the `eslint` package. All of the functionality described in this RFC could be implemented in that function, leaving both `ConfigArray` and `FlatConfigArray` in their current state without the need for changes.

Choose a reason for hiding this comment

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

that is exported from the eslint package

One suggestion -- export it from a new package like @eslint/define-config which should be a dependency of eslint, and eslint should re-export it.

The reason I suggest this is that it means that if the plan is to add logic into defineConfig to allow utilities that make configs easier to use, then allowing a user to install and upgrade @eslint/define-config independently of the eslint version would mean you can easily "backport" config changes without forcing the user to upgrade their ESLint version.

I've seen a number of users that are locked on a specific ESLint version for various reasons (sometimes political/organisational problems that block them, and sometimes it's just hard to upgrade as a new version introduces new errors that will take time to fix). Being able to just install/bump @eslint/define-config would mean they can get config improvements without having to fix new reports and the like.

Feasibly it would also be possible for the latest version of @eslint/define-config to be backwards compatible across major releases -- meaning people could get the benefit of extends and other improvements on a previous major release. Major upgrades are often a real hurdle esp at large organisations.

This has been one of the benefits of tseslint.config -- it has existed outside of eslint's version so users have been able to use it for any ESLint version and get its benefits regardless of their underlying version -- including if they wanted to try flat configs on our minimum eslint version -- 8.57.0.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yup, this is covered under the "Benefits" section below. I'll consider this a vote in favor of that option. 👍

@bradzacher
Copy link

^^^ oops forgot to add my comment to the review 🤦
It's probably obvious that I'm strongly in favour of the defineConfig approach. There are many benefits to even just having it be an identity function as it allows users to get strict typing and intellisense without having to mess with JSDoc comments. This is a HUGE DevX win!

It also gives you the freedom to keep the flat config spec itself simple like it currently is, and the config definition function can do the heavy lifting and complicated / powerful extensions that improve DevX without changing ESLint internals.

@nzakas
Copy link
Member Author

nzakas commented Dec 6, 2024

It seems like there's a general agreement in favor of defineConfig as the approach. I'll spend some time reworking this RFC around that.

@JamesHenry
Copy link
Member

Thank you @nzakas! 🙏

@voxpelli
Copy link

voxpelli commented Dec 9, 2024

I think it would be preferable if defineConfig would be exposed from a separate module than eslint, considering that plugins are not necessarily loading code directly from eslint today, see eg. this search in eslint-plugin-promise.

One module that many plugins are using today is @eslint-community/eslint-utils, that or some new @eslint/config-utils or something could be good, then the plugins could have a direct dependency on that rather than relying on peer dependencies (that can pretty much only be bumped in semver major releases and where many plugins are still supporting ESLint 8.x or even older, so any new method in a 9.x release will not always be available to them)

Edit: And now I noticed that Brad had commented the same here #126 (comment), sorry for duplication

@nzakas
Copy link
Member Author

nzakas commented Dec 10, 2024

Updated the proposal to use defineConfig() exported from @eslint/config and passed-through eslint. Also added more details on how merging should work. Let me know what you think.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Initial Commenting This RFC is in the initial feedback stage
Projects
None yet
Development

Successfully merging this pull request may close these issues.