I published turkerdev/kbox-eslint-rules in January: a shared lint configuration for four repositories that had each been carrying their own copy since 2022. The copies had diverged on eleven rules and nobody could say which side of any of them was intended.
The symptom
$ for r in kbox-web kbox-api kbox-admin kbox-tools; do
(cd "../$r" && npx eslint --print-config src/index.ts)
| jq -S '.rules' > "/tmp/$r.json"
done
$ diff /tmp/kbox-web.json /tmp/kbox-api.json | grep -c '^[<>]'
22
$ jq -r 'keys[]' /tmp/kbox-*.json | sort | uniq -c | awk '$1 < 4'
3 @typescript-eslint/no-floating-promises
2 no-console
1 @typescript-eslint/consistent-type-imports
...Printing the resolved configuration rather than diffing the source files is the technique — extends chains and plugin defaults mean two files that look different can resolve identically, and two that look identical can not. Eleven rules present in some repositories and not others, none of them a decision anybody remembered making.
Why it happens
A configuration is copied at project creation and diverges from that moment, because every subsequent change is made in the repository where the problem appeared. There is no mechanism by which a rule added in one place reaches the others.
The fix
What a shared config exports
// index.js
import { correctness } from './correctness.js'
import { style } from './style.js'
export const typescript = [...ts.configs.recommended, ...correctness]
export const tests = [{ files: ['**/*.test.ts'], rules: { /* ... */ } }]
export default [...typescript, ...style]
// and a consumer, composing what it needs
import { typescript, tests } from '@turkerdev/kbox-eslint-rules'
export default [...typescript, ...tests, { ignores: ['dist/**'] }]
Exporting layers rather than one configuration is what makes this usable across a browser application and a Node tool, which need different globals and the same correctness rules. Flat config being an array is what makes composition the natural operation — the previous format’s extends chain made this a merge with resolution rules.
Rules that are opinions and rules that catch bugs
correctness.js — a violation is a bug
no-floating-promises, no-misused-promises,
require-await, no-unsafe-argument, eqeqeq,
and 14 more
style.js — a violation is a preference
consistent-type-imports, prefer-const, no-console,
and 19 more
the split matters because a consumer can take
correctness and decline style, and one of the four does.Separating them is what stopped the adoption conversation being about semicolons. The correctness set is not negotiable and is nineteen rules; the style set is a default that a repository can decline, and offering that is what got the fourth repository to adopt at all.
Versioning, where a new rule is a major
patch a rule's options adjusted, no new violations
minor a rule added as a WARNING
major a rule added as an error, or a severity raised
which means most useful changes are majors, and that is
correct: a config bump that fails a consumer's build is
a breaking change however small the rule.
4.0.0 after five months. three majors, each of which
shipped as a warning in the preceding minor.Shipping a new rule as a warning first is what makes the eventual major uneventful — consumers see the violations for a release before they are asked to fix them, and one of the three was reverted at that stage because it produced four hundred violations nobody wanted to address.
The migration path shipped with each major
## 4.0.0
`@typescript-eslint/consistent-type-imports` raised to
error.
**To migrate:**
```
npx eslint --fix 'src/**/*.ts'
```
This rule is entirely auto-fixable. If `--fix` leaves
violations, they are in files excluded by your
`ignores`.
## 3.0.0
`no-floating-promises` raised to error. **Not**
auto-fixable; each violation needs a decision about
whether to await or to mark deliberately fire-and-
forget with `void`.
Stating whether a rule is auto-fixable is the most useful line in the release notes, because it is the difference between a one-command upgrade and an afternoon. The 3.0.0 rule was the afternoon and the notes said so, which is why nobody was surprised.
Testing a lint config
// fixtures/no-floating-promises.invalid.ts
async function main() {
doSomethingAsync() // ← the violation
}
// tests/rules.test.ts
test('flags a floating promise', async () => {
const [result] = await lintFixture('no-floating-promises.invalid.ts')
expect(result.messages).toContainEqual(
expect.objectContaining({
ruleId: '@typescript-eslint/no-floating-promises',
severity: 2,
}),
)
})
A config change with no test is a change whose effect is discovered by four consumers on their next install, which is the worst feedback loop available. The fixtures are one valid and one invalid file per rule that matters, and the assertion names the rule rather than the whole output.
The consumer test
# in the config's own pipeline, against each consumer
strategy:
matrix:
consumer: [kbox-web, kbox-api, kbox-admin, kbox-tools]
steps:
- uses: actions/checkout@v4
with: { repository: turkerdev/${{ matrix.consumer }} }
- run: npm ci && npm i ../kbox-eslint-rules
- run: npx eslint . --max-warnings=0
Running the candidate config against every consumer before publishing is what turns “this is a minor” from a claim into a check. It is also the only reason the reverted rule was caught before release — four hundred violations in one repository is a number nobody predicts from reading a rule description.
Verifying it worked
$ for r in kbox-web kbox-api kbox-admin kbox-tools; do
(cd "../$r" && npx eslint --print-config src/index.ts)
| jq -S '.rules' | sha256sum
done | sort -u | wc -l
2
# two, because one repository declines the style layer.
# it was four.
$ npm ls @turkerdev/kbox-eslint-rules --workspaces 2>/dev/null
4.0.1 in all four
$ git log --oneline --since=2026-01 -- .eslintrc* eslint.config.*
| wc -l
0 # no local rule edits since adoptionTwo distinct resolved configurations across four repositories, both of them intentional, is the outcome. Zero local edits since January is the more meaningful number — the divergence mechanism has been closed rather than the divergence merely corrected.
What this costs
A package with consumers, which means every rule is now a negotiation rather than a decision. Adding a rule to one repository was a commit; adding it to the shared config is a warning release, a consumer test run, a major release and a migration note — which is correct and is friction.
The consumer test also creates a coupling in the wrong direction: the config’s pipeline checks out four other repositories, so a repository being renamed or made private breaks the config’s build. That has happened once and the failure was confusing, because nothing about the config had changed.