Josh Goldberg
Troy from Community looking down in horror

If I Wrote a Linter, Part 3: Ecosystem

May 16, 202515 minute read

This is how I would steer a new ecosystem around a modern linter from scratch: emphasizing shared terminology, a plugin registry, and one-way compatibility layers.

Today’s web linters are great. ESLint is robust and has a huge ecosystem of plugins and configs. Newer native speed linters such as Biome, deno lint, and Oxlint are fast and easy to set up with their own burgeoning ecosystems.

I’ve been working on TypeScript linting for almost a decade. I started contributing to TSLint community projects in 2016 and am now a number of the typescript-eslint and ESLint teams. Based on those years of various linters, I’ve collected a large set of design ideas that I want to try.

I see a linter that is fast by default, powerful upon request, and straightforward to configure. This post is how I would craft the ecosystem around it.

💡 This post is the third in a series:


Community Discord

Every mainstream linter has some kind of community Discord. They each have channels for:

I have no new ideas in this space. I think the existing Discords are great. No complaints here.

If I wrote a linter, it would have a community Discord akin to the existing ones.

Shared Glossary

Many important linting terms have inconsistent usage or even definitions in the wild today. For example, “stylistic” can alternately refer to:

Other ambiguous terms include:

…huh!?

I work on linters and I have a hard time keeping this all straight. Now imagine how confusing this all is for someone new to linting, and/or who doesn’t care much about their linter.

If I wrote a linter, I would continue the ESLint Glossary work by defining single recommended terms for all the shared linting concepts. I hope this would help reduce naming conflicts such as what we’re seeing with “config”, “format”, and “stylistic”.

Common Core Rules

One of the hardest parts of figuring out a project’s ecosystem is knowing what to put in core or delegate to users. I’ve seen two prevailing strategies in the wild:

I like the lean core approach for allowing the ecosystem to iterate and learn in areas that aren’t solidified yet or are too project-dependent for core. I don’t believe a linter’s core should have rules that don’t apply to a supermajority of its users. Putting a rule in core is a heavyweight action for that rule. Development on the rule slows down as the core team has to be more careful about breaking changes than the typical plugin.

On the other hand, once a plugin is known to be stable and applicable to a supermajority of users, putting it in core can be very useful. Core linter rules tend to be more stable than plugin rules and more discoverable by virtue of being in the core documentation website. Onboarding a plugin to core means users don’t have to go out of their way to set it up.

If I wrote a linter, its core rules would pull in all those from plugins that are:

That includes taking rules from the following plugins:

Note also from Part 1 > Core Common Languages that JSON, Markdown, TypeScript, and YML plugins would also be in core.

That does not include the following plugins:

Those plugins are important and should be discoverable. The next two sections in this post describe how the linter would help promote them to users.

Community Organization

The ESLint Community organization is wonderful. I think it serves a great need for housing high-applicability, high-value community projects that are not able to be part of ESLint core. It’s a kind of “next step” for finding plugins outside of ESLint core — not quite “first party”, and not an external “third party”.

If I wrote a linter, it would lean into having an equivalent community organization. That organization would have guidelines for inclusion, including:

To start, it would include equivalents of:

Those community plugins would be included in ecosystem tests for the core linter to ensure new core linter releases don’t unexpectedly break them 1.

Plugin Registry

Much of a linter’s ecosystem will always be third-party plugins. Finding the right third-party linter plugins for a project today is a pain. ESLint does not yet have a centralized listing 2 or one canonical approach users should take. The best process I’ve come to recommend for any given project is:

  1. Search dustinspecker/awesome-eslint for plugins that seem relevant to the project
  2. For each dependency the project relies upon, search online for “eslint plugin” and that dependency name

That’s a slow, unreliable process. Determining which plugins are popular or still actively maintained is time-consuming 3. It would be helpful if there was more automation and centralization around what plugins are available.

If I wrote a linter, I would create a centralized plugin registry of popular userland plugins. It would have similar guidelines for inclusion as the community organization, but with more lenient numbers, and allowing framework-specific plugins. The registry would automatically update plugin metadata such as:

Plugins that get too out-of-date on any of those metrics would be marked as such in the UI. That would allow users to filter and search for plugins that are, say, actively maintained and support the latest version of the linter.

The registry would be exposed to users in two ways:

Essentially, this would be a tailored npm for linter plugins 4.

Config Initializer

Every mainstream linter comes with some kind of configuration file initialization CLI: @eslint/config, biome init, oxlint --init, etc. Good! Initialization CLIs help users get started quickly and with confidence their configuration is correct.

My only gripe with those CLIs is that they only cover a few base starting points. You’re left on your own to figure out how to add plugins not explicitly hardcoded into the CLI. @eslint/config, for example, offers to support the React and Vue frameworks out-of-the-box, but that’s it.

If I wrote a linter, its config initializer would use plugin data from the centralized plugin registry to make the setup experience dynamic. If run in a project with existing dependencies, it would offer to add the plugins for those plugins into the created configuration. It would also offer the user an input to provide dependency names they want to search on plugins for.

It’d probably have to use a templating system like Bingo’s Stratum so plugins can define how they add to a config file.

Initialized Examples

Initialization CLIs are great for previewing changes locally, but many users also want to see examples in documentation form. Creating up-to-date documentation examples is a pain to do manually — especially for community-authored plugins specific to frameworks. But, once we factor in the centralized plugin registry and config initializer templating, we can automate that process!

If I wrote a linter, it would automatically keep a known set of example repositories up-to-date with the latest versions of the linter and a plugin each. CI actions would run that update the templates whenever a new version of a dependency is released.

I implemented a similar feature for my Bingo and create-typescript-app projects recently. bingo-js > Example Repositories lists several repositories auto-generated from templates. The created-typescript-app* ones each run a GitHub Action that checks in any changes from re-running CTA on Renovate pull requests.

One-Way Compatibility

If you want to get users to adopt a new linter, you need to make it easy for them to migrate from the current dominant market leader: ESLint. If users can’t switch from an existing ESLint setup confidently and quickly, they won’t.

I think most linters today target two-way compatibility. Meaning: they support all the use cases ESLint does by porting close equivalents to all of ESLint’s features.

Two-way compatibility doesn’t necessarily mean full compatibility. Details might not be the same or fully implemented, such as native speed linters not (yet?) 5 supporting JS configs rather than JSON. But those linters generally preserve ESLint’s features and allow users to migrate their configs with minimal conceptual changes 6.

My problem with two-way compatibility is twofold:

  1. It binds the new linter to older designs, even if they aren’t what the new linter would prefer (looking at you, errors vs. warnings…)
  2. By not allowing feature breakage, a new linter is restricted from choosing beneficial and splashy new features that can benefit users and drive excitement

If I wrote a linter, I would target one-way compatibility. Meaning: there would be support for all the use cases ESLint supports, but they might not map up to ESLint’s features. For drastically changed concepts, those migrations would one-way port to the linter’s equivalents — which might not preserve the exact original semantics.

Taking errors vs. warnings as an example, the equivalent would be the new linter’s “gradual onboardings” system 7 8. I’m envisioning a system that allows users to mark swathes of files as still onboarding to new rules. Compared to how other linters do it:

Building a first-class gradual onboardings system into the linter would give more granular control over the rule onboarding process. The new system would, I think, actually be better at supporting the use case by replacing, rather than porting, the specific feature.

Compatibility Layers

Existing userland ESLint plugins need to be consumable by the new linter. Getting developers to write -let alone maintain- a plugin for one linter is hard enough. Asking them to additionally work on one more implementation for each new linter is a non-starter.

If I wrote a linter, it would have a compatibility layer for ESLint plugins. It would allow you to install an ESLint plugin and directly use its configs and rules in your lint configuration.

Here’s a rough sketch of how it might look for a hypothetical @joshuakgoldberg/eslint-plugin:

// if-i-wrote-a-linter.config.ts
import { defineConfig, ts } from "@joshuakgoldberg/linter";
import { wrapPlugin } from "@joshuakgoldberg/linter-eslint";
import { joshuakgoldberg } from "@joshuakgoldberg/eslint-plugin";

export default defineConfig({
	use: [
		{
			glob: "**/*.ts",
			rules: [
				ts.configs.logical,
				ts.configs.stylistic,
				wrapPlugin(joshuakgoldberg.configs.recommended),
			],
		},
	],
});

ESLint’s compatibility utilities are a good reference of prior art for compatibility layers.

Configuration Migration

Existing user configuration files need to be automatically portable to the new linter’s format. Getting developers to maintain their configuration files for one linter -let alone understand mapping between multiple linters- is hard enough. Asking them to manually rewrite a configuration file in a new format is a non-starter.

ESLint’s configuration migrator and tslint-to-eslint-config are a good references of prior art for migration tooling. I’m particularly fond of how tslint-to-eslint-config aggressively suggests developers rethink how their configuration files are structured:

If I wrote a linter, it would provide a configuration migrator utility for ESLint configuration files. It would take an existing ESLint configuration file and output as close an approximation in the new linter configuration file as possible.

It would also provide flags for whether to adopt practices recommended by the new linter:

Those flags would allow the migration tool to be used as more than just a single-shot “closest possible equivalent” tool. It would also help users migrate to best practices and more powerful linter configurations.

Up Next

Ecosystem work is hard. It requires a lot energy and time. I think the ideas in this blog post are a nice blend of the areas, and I’d love to see them tried out in the wild.

Next week Later this month will see the final entry in this blog post series. It’ll summarize the entire series, answer FAQs, and provide a few final thoughts. I hope you enjoyed this post and the series so far!

💡 This post is the third in a series:


Footnotes

  1. feat: Introduce ecosystem tests for popular plugins

  2. eslint/eslint#18824 Create plugins.eslint.org website

  3. dustinspecker/awesome-eslint#245 fix: remove deprecated and 404 links: I noticed over a dozen archived or dead links in the list while working on this blog post.

  4. Lintbase was a project that aimed to do what I’m proposing.

  5. osc-project/oxc#10935 linter: intelligent config file

  6. Biome > Migrate from ESLint and Prettier

  7. If I Wrote a Linter, Part 2: Developer Experience > Only Errors

  8. ESLint: Introducing bulk suppressions

  9. If I Wrote a Linter, Part 1: Architecture > Formatting Coordination


Liked this post? Thanks! Let the world know: