@exadev/eslint-config 1.4.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -44
- package/dist/index.cjs +287 -2
- package/dist/index.d.cts +10 -2
- package/dist/index.d.ts +10 -2
- package/dist/index.js +259 -2
- package/package.json +1 -14
- package/dist/plugin-BscQLyGY.cjs +0 -46
- package/dist/plugin-DYny7gdb.js +0 -45
- package/dist/plugin.cjs +0 -2
- package/dist/plugin.d.cts +0 -4
- package/dist/plugin.d.ts +0 -5
- package/dist/plugin.js +0 -2
- package/dist/recommended-type-checked.cjs +0 -52
- package/dist/recommended-type-checked.d.cts +0 -5
- package/dist/recommended-type-checked.d.ts +0 -6
- package/dist/recommended-type-checked.js +0 -29
- package/dist/rules/no-non-barrel-index.cjs +0 -24
- package/dist/rules/no-non-barrel-index.d.cts +0 -4
- package/dist/rules/no-non-barrel-index.d.ts +0 -5
- package/dist/rules/no-non-barrel-index.js +0 -24
- package/dist/rules/no-non-barrel-reexport.cjs +0 -89
- package/dist/rules/no-non-barrel-reexport.d.cts +0 -4
- package/dist/rules/no-non-barrel-reexport.d.ts +0 -5
- package/dist/rules/no-non-barrel-reexport.js +0 -89
- package/dist/rules/no-pointless-reassignment.cjs +0 -57
- package/dist/rules/no-pointless-reassignment.d.cts +0 -4
- package/dist/rules/no-pointless-reassignment.d.ts +0 -5
- package/dist/rules/no-pointless-reassignment.js +0 -57
- package/dist/rules/no-side-effects-in-index.cjs +0 -24
- package/dist/rules/no-side-effects-in-index.d.cts +0 -4
- package/dist/rules/no-side-effects-in-index.d.ts +0 -5
- package/dist/rules/no-side-effects-in-index.js +0 -24
package/README.md
CHANGED
|
@@ -12,23 +12,49 @@ Only the *rules* are centralized here, not a consumer's whole `eslint.config.ts`
|
|
|
12
12
|
|
|
13
13
|
## Getting started
|
|
14
14
|
|
|
15
|
-
Consumers need `eslint >=10.0.0`
|
|
15
|
+
Consumers need `eslint >=10.0.0` and `typescript-eslint >=8.0.0` as peer dependencies -- both required, not optional. Importing anything from this package, including the lighter `plugin` export described below, resolves `typescript-eslint`: the package's default export (the full type-checked bundle) and the `plugin` named export live in the same root module, and ESM/CJS module evaluation runs a module's entire top-level import graph regardless of which specific export the caller reads. See [Architecture](#architecture) for why that's an accepted trade-off rather than an oversight.
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
pnpm add -D @exadev/eslint-config
|
|
18
|
+
pnpm add -D @exadev/eslint-config typescript-eslint eslint
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
The default export is the full, type-checked ruleset: typescript-eslint's own `recommendedTypeChecked` + `stylisticTypeChecked` presets, this package's own four rules (self-scoped internally to the barrel -- see [Rules](#rules) -- so no `files`/`ignores` wiring is needed for them), `linterOptions.noInlineConfig`, `@typescript-eslint/consistent-type-assertions` banning all type assertions, and `@typescript-eslint/ban-ts-comment` banning `@ts-expect-error` outright alongside the preset's own existing `@ts-ignore`/`@ts-nocheck` bans -- the last two relaxed automatically in `*.test.ts`/`*.spec.ts` files (see below). Spread it directly into `tseslint.config(...)`:
|
|
22
|
+
|
|
21
23
|
```ts
|
|
22
24
|
// eslint.config.ts
|
|
23
25
|
import exadev from '@exadev/eslint-config';
|
|
24
26
|
import tseslint from 'typescript-eslint';
|
|
25
27
|
|
|
28
|
+
export default tseslint.config(
|
|
29
|
+
{
|
|
30
|
+
languageOptions: {
|
|
31
|
+
parserOptions: { project: './tsconfig.json', tsconfigRootDir: import.meta.dirname },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
...exadev,
|
|
35
|
+
// ...your own config on top...
|
|
36
|
+
);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`recommendedTypeChecked` already subsumes typescript-eslint's own plain `recommended` outright -- every one of its 46 rules is a strict subset of `recommendedTypeChecked`'s 73, confirmed by inspecting the actual rule maps. This is a real bundling, not a rule reference that assumes you already have typescript-eslint registered: `recommendedTypeChecked`'s own base config registers the `@typescript-eslint` plugin and sets `languageOptions.parser` itself. That is exactly why **you must remove your own `...tseslint.configs.recommended`/`recommendedTypeChecked`/`stylisticTypeChecked` spreads** rather than keep them alongside this -- ESLint flat config rejects two different plugin object instances registered under the same namespace. What you still supply yourself is `languageOptions.parserOptions.project`/`projectService` pointing at your own tsconfig(s); this bundle's base config never sets that, since it's genuinely project-specific.
|
|
40
|
+
|
|
41
|
+
**Test files (`**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`) get two narrow relaxations of this package's own additions above, and only those two.** A compile-time-only `@ts-expect-error` proving a construct genuinely fails to type-check is a well-established, legitimate test pattern -- TypeScript's own "unused `@ts-expect-error` directive" diagnostic already catches one that stops being needed, independent of this rule -- so a test file reverts to the rule's own pre-ban default, `allow-with-description`, rather than the outright ban. `@ts-ignore`/`@ts-nocheck` stay banned even in test files: `@ts-expect-error` is strictly better for both, so there's no legitimate test-specific reason to reach for either. `consistent-type-assertions` relaxes to `assertionStyle: 'as'` in test files -- letting a test construct a partial/stub value with a real `as` assertion where the full type wouldn't otherwise accept it -- while the legacy angle-bracket `<Type>value` form stays banned everywhere, tests included. Nothing inherited from `recommendedTypeChecked`/`stylisticTypeChecked` itself is relaxed in test files; only this package's own two additions are.
|
|
42
|
+
|
|
43
|
+
### The lighter option: the `plugin` named export
|
|
44
|
+
|
|
45
|
+
For a project that wants only this package's own four rules -- without the full type-checked bundle, e.g. one already running its own separate type-aware setup -- import the named `plugin` export instead and wire the rules individually:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// eslint.config.ts
|
|
49
|
+
import { plugin } from '@exadev/eslint-config';
|
|
50
|
+
import tseslint from 'typescript-eslint';
|
|
51
|
+
|
|
26
52
|
export default tseslint.config(
|
|
27
53
|
// ...your own config...
|
|
28
54
|
{
|
|
29
55
|
files: ['src/**/*.ts'],
|
|
30
56
|
ignores: ['src/index.ts'],
|
|
31
|
-
plugins: { exadev },
|
|
57
|
+
plugins: { exadev: plugin },
|
|
32
58
|
rules: {
|
|
33
59
|
'exadev/no-non-barrel-reexport': 'error',
|
|
34
60
|
},
|
|
@@ -36,17 +62,17 @@ export default tseslint.config(
|
|
|
36
62
|
);
|
|
37
63
|
```
|
|
38
64
|
|
|
39
|
-
Or use one of
|
|
65
|
+
Or use one of `plugin`'s two bundled configs to enable a whole set at once:
|
|
40
66
|
|
|
41
67
|
```ts
|
|
42
|
-
import
|
|
68
|
+
import { plugin } from '@exadev/eslint-config';
|
|
43
69
|
import { defineConfig } from 'eslint/config';
|
|
44
70
|
|
|
45
71
|
export default defineConfig([
|
|
46
72
|
{
|
|
47
73
|
files: ['**/*.ts'],
|
|
48
|
-
plugins: { exadev },
|
|
49
|
-
extends: ['exadev/recommended'], // this plugin's own four rules, plus linterOptions.noInlineConfig -- no
|
|
74
|
+
plugins: { exadev: plugin },
|
|
75
|
+
extends: ['exadev/recommended'], // this plugin's own four rules, plus linterOptions.noInlineConfig -- no type-checked rules at all
|
|
50
76
|
// or: extends: ['exadev/barrel'], // just the barrel-discipline trio (no-non-barrel-index, no-non-barrel-reexport, no-side-effects-in-index)
|
|
51
77
|
},
|
|
52
78
|
]);
|
|
@@ -55,52 +81,24 @@ export default defineConfig([
|
|
|
55
81
|
`typescript-eslint`'s own `tseslint.config()` helper (rather than ESLint's `defineConfig()`) does **not** accept the string form of `extends` at all -- it throws `has an 'extends' array that contains a string ... This is a feature of eslint's defineConfig() helper and is not supported by typescript-eslint`. A `tseslint.config()`-based project passes the config value directly instead:
|
|
56
82
|
|
|
57
83
|
```ts
|
|
58
|
-
import
|
|
84
|
+
import { plugin } from '@exadev/eslint-config';
|
|
59
85
|
import tseslint from 'typescript-eslint';
|
|
60
86
|
|
|
61
87
|
export default tseslint.config(
|
|
62
88
|
// ...your own config...
|
|
63
89
|
{
|
|
64
90
|
files: ['**/*.ts'],
|
|
65
|
-
plugins: { exadev },
|
|
66
|
-
extends: [
|
|
91
|
+
plugins: { exadev: plugin },
|
|
92
|
+
extends: [plugin.configs.recommended], // or plugin.configs.barrel
|
|
67
93
|
},
|
|
68
94
|
);
|
|
69
95
|
```
|
|
70
96
|
|
|
71
|
-
**`recommended`/`barrel` carry no `files`/`ignores` of their own, and are safe to apply unscoped anyway -- the two rules that care which file they're looking at (`no-side-effects-in-index`, `no-non-barrel-reexport`) each check `context.filename` themselves, the same self-scoping pattern `no-non-barrel-index` already used.** `no-side-effects-in-index` no-ops on every file except `src/index.ts`, since it has no legitimate target anywhere else; `no-non-barrel-reexport` no-ops specifically on `src/index.ts`, since a real single-statement re-export there is the intended, normal shape. An earlier version of this package lacked that self-scoping and genuinely misfired when `recommended`/`barrel` were applied without an external `files: ['src/index.ts']`/`ignores: ['src/index.ts']` wrapper -- 88 false-positive errors on a single real source file in a repo that tried it, since `no-side-effects-in-index` flagged every ordinary `export function`/`export const`/`export interface` declaration it saw. That's fixed at the rule level now, not documented around.
|
|
97
|
+
**`plugin.configs.recommended`/`plugin.configs.barrel` carry no `files`/`ignores` of their own, and are safe to apply unscoped anyway -- the two rules that care which file they're looking at (`no-side-effects-in-index`, `no-non-barrel-reexport`) each check `context.filename` themselves, the same self-scoping pattern `no-non-barrel-index` already used.** `no-side-effects-in-index` no-ops on every file except `src/index.ts`, since it has no legitimate target anywhere else; `no-non-barrel-reexport` no-ops specifically on `src/index.ts`, since a real single-statement re-export there is the intended, normal shape. An earlier version of this package lacked that self-scoping and genuinely misfired when `recommended`/`barrel` were applied without an external `files: ['src/index.ts']`/`ignores: ['src/index.ts']` wrapper -- 88 false-positive errors on a single real source file in a repo that tried it, since `no-side-effects-in-index` flagged every ordinary `export function`/`export const`/`export interface` declaration it saw. That's fixed at the rule level now, not documented around.
|
|
72
98
|
|
|
73
99
|
The one thing self-scoping can't know on your behalf is a barrel that lives somewhere other than `src/index.ts`, or a project-specific exception beyond the barrel (an extra file you want exempt from the re-export ban). For either of those, layer an additional override on top of `recommended`/`barrel` -- e.g. `{ files: ['lib/other-legacy-reexport.ts'], rules: { 'exadev/no-non-barrel-reexport': 'off' } }` -- rather than falling back to wiring all four rules individually, which is still fine but no longer required for the common case.
|
|
74
100
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
### The typed-linting bundle: `@exadev/eslint-config/recommended-type-checked`
|
|
78
|
-
|
|
79
|
-
For a TypeScript project that wants the full typed-linting baseline bundled in, import the separate `recommended-type-checked` entry point instead of using `configs.recommended`:
|
|
80
|
-
|
|
81
|
-
```ts
|
|
82
|
-
// eslint.config.ts
|
|
83
|
-
import exadevRecommendedTypeChecked from '@exadev/eslint-config/recommended-type-checked';
|
|
84
|
-
import tseslint from 'typescript-eslint';
|
|
85
|
-
|
|
86
|
-
export default tseslint.config(
|
|
87
|
-
{
|
|
88
|
-
languageOptions: {
|
|
89
|
-
parserOptions: { project: './tsconfig.json', tsconfigRootDir: import.meta.dirname },
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
...exadevRecommendedTypeChecked,
|
|
93
|
-
// ...your own config on top...
|
|
94
|
-
);
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
This bundles `typescript-eslint`'s own `recommendedTypeChecked` and `stylisticTypeChecked` presets (`recommendedTypeChecked` already subsumes plain `recommended` outright -- every one of its 46 rules is a strict subset of `recommendedTypeChecked`'s 73, confirmed by inspecting the actual rule maps) alongside this plugin's own four rules, `linterOptions.noInlineConfig`, `@typescript-eslint/consistent-type-assertions` set to `never` (no `as`/angle-bracket type assertions -- narrow with a guard or parse with Zod instead), and `@typescript-eslint/ban-ts-comment` raised to ban `@ts-expect-error` outright alongside the preset's own existing `@ts-ignore`/`@ts-nocheck` bans -- with `noInlineConfig` already removing `eslint-disable` as an escape hatch, this leaves no way to suppress a type error inline anywhere in a consuming project.
|
|
98
|
-
|
|
99
|
-
It's a real bundling, not a rule reference that assumes the consumer already has `typescript-eslint` set up: `recommendedTypeChecked`'s own base config registers the `@typescript-eslint` plugin and sets `languageOptions.parser` itself. **A consumer adopting this bundle must remove its own `...tseslint.configs.recommended`/`recommendedTypeChecked`/`stylisticTypeChecked` spreads** rather than keep them alongside it -- ESLint flat config rejects two different plugin object instances registered under the same namespace. What a consumer still supplies itself is `languageOptions.parserOptions.project`/`projectService` pointing at its own tsconfig(s); `recommendedTypeChecked`'s base config never sets that, since it's genuinely project-specific.
|
|
100
|
-
|
|
101
|
-
**Test files (`**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`) get two narrow relaxations of this package's own additions above, and only those two.** A compile-time-only `@ts-expect-error` proving a construct genuinely fails to type-check is a well-established, legitimate test pattern -- TypeScript's own "unused `@ts-expect-error` directive" diagnostic already catches one that stops being needed, independent of this rule -- so a test file reverts to the rule's own pre-ban default, `allow-with-description`, rather than the outright ban. `@ts-ignore`/`@ts-nocheck` stay banned even in test files: `@ts-expect-error` is strictly better for both, so there's no legitimate test-specific reason to reach for either. `consistent-type-assertions` relaxes to `assertionStyle: 'as'` in test files -- letting a test construct a partial/stub value with a real `as` assertion where the full type wouldn't otherwise accept it -- while the legacy angle-bracket `<Type>value` form stays banned everywhere, tests included. Nothing inherited from `recommendedTypeChecked`/`stylisticTypeChecked` itself is relaxed in test files; only this package's own two additions are.
|
|
102
|
-
|
|
103
|
-
This lives in its own module, separate from the main `@exadev/eslint-config` entry point, specifically so importing the main package never attempts to resolve `typescript-eslint`. A plain object property (or a lazy getter) on the base plugin's own `configs` map can't achieve that: ESLint's `extends` resolution is synchronous, so a dynamic `import()` doesn't help either -- it just hides the same requirement behind an unawaited promise. Splitting into a genuinely separate module sidesteps the problem at the right layer: Node's own module resolution only loads a module when something actually imports it.
|
|
101
|
+
`plugin.configs.recommended`/`plugin.configs.barrel` are not usable without `typescript-eslint` installed, even though neither config itself references it: `plugin` is a named export sharing its root module with the default export, so `typescript-eslint` resolves the moment anything is imported from `@exadev/eslint-config` at all -- see [Architecture](#architecture) for the trade-off this reflects.
|
|
104
102
|
|
|
105
103
|
## Rules
|
|
106
104
|
|
|
@@ -127,19 +125,21 @@ Each rule has a co-located `*.test.ts` file (`src/rules/no-non-barrel-index.test
|
|
|
127
125
|
|
|
128
126
|
The `lint`/`typecheck`/`test`/`build` npm scripts are thin wrappers around turbo tasks whose own names carry a leading underscore (`_lint`/`_typecheck`/`_test`/`_build`, declared in `turbo.json`) -- run `pnpm build`, not `turbo run build` directly, since turbo's task names don't match the npm script names.
|
|
129
127
|
|
|
130
|
-
`pnpm build` runs `tsdown`,
|
|
128
|
+
`pnpm build` runs `tsdown` from the single `src/index.ts` entry, bundling the whole module graph (`plugin.ts`, `recommended-type-checked.ts`, and every rule under `src/rules/`) into one ESM output and one CJS output plus declaration files (platform-neutral). Before any publish -- local or the CI alias job -- `prepublishOnly` re-runs lint, typecheck, `test`, `tsdown`, `publint`, and `attw --pack`, so a broken export shape fails at publish time even outside the main CI pipeline.
|
|
131
129
|
|
|
132
130
|
## Architecture
|
|
133
131
|
|
|
134
|
-
`src/plugin.ts` builds an `ESLint.Plugin` object (ESLint's own `ESLint.Plugin` type, not a hand-written interface) combining the four rule modules under `src/rules/` into a flat `rules` map. `configs.recommended` and `configs.barrel` are defined as getters directly in the object literal, not attached after construction: each needs to reference the fully-built `plugin` object itself (`plugins: { exadev: plugin }`), which a plain property initializer can't do for its own binding while it's still being constructed. A getter closes over the `plugin` binding rather than its value, so it resolves correctly the moment a consumer actually reads the property, by which point construction has finished -- no `Object.assign`, no post-construction mutation, no null-checked destructure needed.
|
|
132
|
+
`src/plugin.ts` builds an `ESLint.Plugin` object (ESLint's own `ESLint.Plugin` type, not a hand-written interface) combining the four rule modules under `src/rules/` into a flat `rules` map. `configs.recommended` and `configs.barrel` are defined as getters directly in the object literal, not attached after construction: each needs to reference the fully-built `plugin` object itself (`plugins: { exadev: plugin }`), which a plain property initializer can't do for its own binding while it's still being constructed. A getter closes over the `plugin` binding rather than its value, so it resolves correctly the moment a consumer actually reads the property, by which point construction has finished -- no `Object.assign`, no post-construction mutation, no null-checked destructure needed.
|
|
133
|
+
|
|
134
|
+
`src/recommended-type-checked.ts` bundles typescript-eslint's own `recommendedTypeChecked` + `stylisticTypeChecked` presets alongside this plugin's own rules into a flat config array. Its own value must specifically be typed as an array, not the wider `NonNullable<ESLint.Plugin['configs']>[string]` union (`LegacyConfigObject | ConfigObject | ConfigObject[]`) `plugin.ts`'s own `configs.recommended`/`configs.barrel` correctly use: that union isn't guaranteed to be an array, so annotating an always-array value with it broke `...exadev`, the way every real consumer spreads this default export, with `TS2488: Type '...' must have a '[Symbol.iterator]()' method`. `ConfigArrayValue = Extract<ConfigValue, unknown[]>` narrows to the array-only member of the identical union -- still derived from ESLint's own `Plugin` type (never typescript-eslint's own narrower internal element type, `CompatibleConfig`, which has no `plugins` field), per this codebase's "don't hand-type external libraries" convention.
|
|
135
135
|
|
|
136
|
-
`src/recommended-type-checked
|
|
136
|
+
`src/index.ts` is the public entry point: `export { default } from './recommended-type-checked'; export { default as plugin } from './plugin';` -- a genuine pure re-export barrel with a default export and one named export, not just a single re-export. An earlier version of this package kept `recommended-type-checked` as a genuinely separate npm subpath (`@exadev/eslint-config/recommended-type-checked`), specifically so importing the main entry point never resolved `typescript-eslint` at all for a plain-JS consumer. Two module specifiers for one package turned out more awkward in practice than the alternative: `typescript-eslint` is now a required (not optional) peer dependency of the whole package, and both `plugin` and the default export live in the same root module -- ESM/CJS module evaluation runs a module's entire top-level import graph regardless of which specific export the caller reads, so importing `{ plugin }` alone still resolves `typescript-eslint` via the *other* re-export statement in the same file. This is an accepted, deliberate trade-off (see [Getting started](#getting-started)), not something a future fix should try to undo without weighing the same two-entry-point cost that made the earlier split feel worse.
|
|
137
137
|
|
|
138
138
|
`pnpm-workspace.yaml` deliberately declares an empty `packages: []`. This is not a real multi-package pnpm workspace; its only purpose is giving turbo a workspace root to anchor local task caching against, matching the same single-package-workspace pattern used across this repo family.
|
|
139
139
|
|
|
140
140
|
## Conventions
|
|
141
141
|
|
|
142
|
-
`eslint.config.ts` dogfoods this package's own
|
|
142
|
+
`eslint.config.ts` dogfoods this package's own default export on itself, importing `./src/index` by relative path rather than as an installed dependency, and spreading it (`...exadevRecommendedTypeChecked`) exactly as a real consumer would -- the live proof that the spread typechecks and behaves correctly against this repo's own `src/index.ts` barrel and `src/plugin.ts` non-barrel module. `no-side-effects-in-index` and `no-non-barrel-reexport` (both bundled in) self-scope to `src/index.ts` internally, so no `files`/`ignores` wiring is needed for them here either. The plugin-construction logic lives in `src/plugin.ts` specifically so `src/index.ts` can stay a pure re-export point both rules assume.
|
|
143
143
|
|
|
144
144
|
`tsconfig.json` enables `verbatimModuleSyntax` (type-only imports/exports must use `import type`/`export type` explicitly -- enforced too by the `consistent-type-imports` eslint rule) and `noUncheckedIndexedAccess` (indexed access returns `T | undefined`, narrow before use rather than asserting).
|
|
145
145
|
|
|
@@ -148,6 +148,7 @@ Conventional commits are enforced by commitlint, restricted to the type-enum def
|
|
|
148
148
|
## Gotchas and quirks
|
|
149
149
|
|
|
150
150
|
- `.attw.json` ignores the `false-export-default` rule: tsdown/rolldown's CJS output for this plugin's sole default export doesn't emit the `export =` form `arethetypeswrong`'s check wants under legacy `node10` resolution. The resolution modes an ESLint flat config actually uses (`node16`, `bundler`) are unaffected, so the rule is suppressed rather than moving the plugin away from ESLint's own documented default-export shape.
|
|
151
|
+
- `src/index.ts` mixing a default export with a named one (`plugin`) triggers rolldown's own `MIXED_EXPORTS` build warning: Node's *native* `import()` of the built `.cjs` file does not respect the `__esModule` marker TypeScript/bundler interop helpers use, so a raw `require('@exadev/eslint-config').default` differs from what a TS-compiled or bundler-mediated `import exadev from '@exadev/eslint-config'` resolves to. Confirmed empirically (packing the tarball and installing it as a real dependency in a `"type": "module"` project): the actual consumer path -- ESM `import` -- resolves both the default export and `plugin` correctly; only a hypothetical direct-`require()` CommonJS consumer would see the raw exports object instead. No current consumer of this package is CommonJS, and both `attw --pack` and `publint` report no problems, so the warning is accepted (see `tsdown.config.ts`'s own top-of-file comment) rather than restructuring the build for a consumer that doesn't exist.
|
|
151
152
|
- Husky hooks: `pre-commit` runs lint-staged (`eslint --fix` on staged `*.ts`), `commit-msg` runs commitlint against the message, `pre-push` runs `typecheck`, `test`, and `build` -- pushing here re-runs the whole test suite and rebuilds the package first.
|
|
152
153
|
- The CI release job sets `HUSKY=0` (so the commit-msg hook never fires against the automated release commit) and blanks `NPM_TOKEN`/`NODE_AUTH_TOKEN` explicitly rather than omitting them, so an inherited token can't win over npm's OIDC trusted-publishing exchange.
|
|
153
154
|
|
package/dist/index.cjs
CHANGED
|
@@ -1,2 +1,287 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
Object.defineProperties(exports, {
|
|
2
|
+
__esModule: { value: true },
|
|
3
|
+
[Symbol.toStringTag]: { value: "Module" }
|
|
4
|
+
});
|
|
5
|
+
//#region \0rolldown/runtime.js
|
|
6
|
+
var __create = Object.create;
|
|
7
|
+
var __defProp = Object.defineProperty;
|
|
8
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
9
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
10
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
11
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
14
|
+
key = keys[i];
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
16
|
+
get: ((k) => from[k]).bind(null, key),
|
|
17
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return to;
|
|
21
|
+
};
|
|
22
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
23
|
+
value: mod,
|
|
24
|
+
enumerable: true
|
|
25
|
+
}) : target, mod));
|
|
26
|
+
//#endregion
|
|
27
|
+
let typescript_eslint = require("typescript-eslint");
|
|
28
|
+
typescript_eslint = __toESM(typescript_eslint, 1);
|
|
29
|
+
//#region package.json
|
|
30
|
+
var version = "2.0.0";
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/rules/no-non-barrel-index.ts
|
|
33
|
+
const INDEX_BASENAME = /^index\.[cm]?[tj]s$/;
|
|
34
|
+
const noNonBarrelIndex = {
|
|
35
|
+
meta: {
|
|
36
|
+
type: "problem",
|
|
37
|
+
schema: [],
|
|
38
|
+
messages: { barrel: "Only src/index.ts may be named index.* (the public convenience barrel); give any other module a descriptive filename." }
|
|
39
|
+
},
|
|
40
|
+
create(context) {
|
|
41
|
+
const filename = context.filename;
|
|
42
|
+
const slash = filename.lastIndexOf("/");
|
|
43
|
+
const basename = slash === -1 ? filename : filename.slice(slash + 1);
|
|
44
|
+
if (!INDEX_BASENAME.test(basename)) return {};
|
|
45
|
+
if (filename.endsWith("/src/index.ts")) return {};
|
|
46
|
+
return { Program(node) {
|
|
47
|
+
context.report({
|
|
48
|
+
node,
|
|
49
|
+
messageId: "barrel"
|
|
50
|
+
});
|
|
51
|
+
} };
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/rules/no-non-barrel-reexport.ts
|
|
56
|
+
function removeListMember(fixer, sourceCode, declaration, members, target) {
|
|
57
|
+
if (members.length === 1) return fixer.remove(declaration);
|
|
58
|
+
const targetIndex = members.indexOf(target);
|
|
59
|
+
const isLast = targetIndex === members.length - 1;
|
|
60
|
+
const neighbor = members[isLast ? targetIndex - 1 : targetIndex + 1];
|
|
61
|
+
if (neighbor === void 0) throw new Error("Unreachable: a list with more than one member always has a neighbor either side of any member within it.");
|
|
62
|
+
return isLast ? fixer.removeRange([sourceCode.getRange(neighbor)[1], sourceCode.getRange(target)[1]]) : fixer.removeRange([sourceCode.getRange(target)[0], sourceCode.getRange(neighbor)[0]]);
|
|
63
|
+
}
|
|
64
|
+
function importIsOnlyUsedByThisExport(sourceCode, trackedImport, usageIdentifier) {
|
|
65
|
+
const variable = sourceCode.getDeclaredVariables(trackedImport.declaration).find((candidate) => candidate.defs.some((def) => def.node === trackedImport.specifier));
|
|
66
|
+
if (variable === void 0) return false;
|
|
67
|
+
if (variable.references.length !== 1) return false;
|
|
68
|
+
const [onlyReference] = variable.references;
|
|
69
|
+
if (onlyReference === void 0) throw new Error("Unreachable: the length check above guarantees exactly one element.");
|
|
70
|
+
return onlyReference.identifier === usageIdentifier;
|
|
71
|
+
}
|
|
72
|
+
const noNonBarrelReexport = {
|
|
73
|
+
meta: {
|
|
74
|
+
type: "problem",
|
|
75
|
+
fixable: "code",
|
|
76
|
+
schema: [],
|
|
77
|
+
messages: {
|
|
78
|
+
splitStatementReexport: "'{{ name }}' is imported here and handed straight back out via a bare export -- the identical re-export 'export { {{ name }} } from ...' would be, just split across two statements. Re-exports belong only in src/index.ts (the public barrel).",
|
|
79
|
+
splitStatementDefaultReexport: "'{{ name }}' is imported here and handed straight back out via `export default` -- the identical re-export 'export { {{ name }} as default } from ...' would be, just split across two statements. Re-exports belong only in src/index.ts (the public barrel)."
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
create(context) {
|
|
83
|
+
if (context.filename.endsWith("/src/index.ts")) return {};
|
|
84
|
+
const importsByName = /* @__PURE__ */ new Map();
|
|
85
|
+
const bareExportSpecifiers = [];
|
|
86
|
+
const defaultExportDeclarations = [];
|
|
87
|
+
return {
|
|
88
|
+
ImportDeclaration(node) {
|
|
89
|
+
for (const specifier of node.specifiers) importsByName.set(specifier.local.name, {
|
|
90
|
+
declaration: node,
|
|
91
|
+
specifier
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
ExportNamedDeclaration(node) {
|
|
95
|
+
if (node.source !== null && node.source !== void 0) return;
|
|
96
|
+
for (const specifier of node.specifiers) bareExportSpecifiers.push({
|
|
97
|
+
declaration: node,
|
|
98
|
+
specifier
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
ExportDefaultDeclaration(node) {
|
|
102
|
+
defaultExportDeclarations.push(node);
|
|
103
|
+
},
|
|
104
|
+
"Program:exit"() {
|
|
105
|
+
const { sourceCode } = context;
|
|
106
|
+
for (const { declaration, specifier } of bareExportSpecifiers) {
|
|
107
|
+
const name = specifier.local.type === "Identifier" ? specifier.local.name : void 0;
|
|
108
|
+
if (name === void 0) continue;
|
|
109
|
+
const trackedImport = importsByName.get(name);
|
|
110
|
+
if (trackedImport === void 0) continue;
|
|
111
|
+
context.report({
|
|
112
|
+
node: specifier,
|
|
113
|
+
messageId: "splitStatementReexport",
|
|
114
|
+
data: { name },
|
|
115
|
+
fix(fixer) {
|
|
116
|
+
const fixes = [removeListMember(fixer, sourceCode, declaration, declaration.specifiers, specifier)];
|
|
117
|
+
if (specifier.local.type === "Identifier" && importIsOnlyUsedByThisExport(sourceCode, trackedImport, specifier.local)) fixes.push(removeListMember(fixer, sourceCode, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
|
|
118
|
+
return fixes;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
for (const declarationNode of defaultExportDeclarations) {
|
|
123
|
+
const name = declarationNode.declaration.type === "Identifier" ? declarationNode.declaration.name : void 0;
|
|
124
|
+
if (name === void 0) continue;
|
|
125
|
+
const trackedImport = importsByName.get(name);
|
|
126
|
+
if (trackedImport === void 0) continue;
|
|
127
|
+
context.report({
|
|
128
|
+
node: declarationNode,
|
|
129
|
+
messageId: "splitStatementDefaultReexport",
|
|
130
|
+
data: { name },
|
|
131
|
+
fix(fixer) {
|
|
132
|
+
const fixes = [fixer.remove(declarationNode)];
|
|
133
|
+
if (declarationNode.declaration.type === "Identifier" && importIsOnlyUsedByThisExport(sourceCode, trackedImport, declarationNode.declaration)) fixes.push(removeListMember(fixer, sourceCode, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
|
|
134
|
+
return fixes;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
//#endregion
|
|
143
|
+
//#region src/rules/no-pointless-reassignment.ts
|
|
144
|
+
function isIdentifierReference(reference) {
|
|
145
|
+
return reference.identifier.type === "Identifier";
|
|
146
|
+
}
|
|
147
|
+
const noPointlessReassignment = {
|
|
148
|
+
meta: {
|
|
149
|
+
type: "problem",
|
|
150
|
+
fixable: "code",
|
|
151
|
+
schema: [],
|
|
152
|
+
messages: { pointlessReassignment: "Pointless reassignment: '{{ name }}' is just an alias for '{{ value }}'. Use the original directly." }
|
|
153
|
+
},
|
|
154
|
+
create(context) {
|
|
155
|
+
return { VariableDeclarator(node) {
|
|
156
|
+
if (node.id.type !== "Identifier" || node.init?.type !== "Identifier" || node.id.name.startsWith("_")) return;
|
|
157
|
+
if (node.parent.type !== "VariableDeclaration" || node.parent.kind !== "const") return;
|
|
158
|
+
const scope = context.sourceCode.getScope(node);
|
|
159
|
+
const sourceVariable = scope.references.find((reference) => reference.identifier === node.init)?.resolved;
|
|
160
|
+
if (!sourceVariable || sourceVariable.references.some((reference) => reference.isWrite() && !reference.init)) return;
|
|
161
|
+
const aliasName = node.id.name;
|
|
162
|
+
const originalName = node.init.name;
|
|
163
|
+
context.report({
|
|
164
|
+
node,
|
|
165
|
+
messageId: "pointlessReassignment",
|
|
166
|
+
data: {
|
|
167
|
+
name: aliasName,
|
|
168
|
+
value: originalName
|
|
169
|
+
},
|
|
170
|
+
fix(fixer) {
|
|
171
|
+
const variable = scope.set.get(aliasName);
|
|
172
|
+
if (!variable) return null;
|
|
173
|
+
if (variable.references.filter((reference) => reference.isWrite() && reference.identifier !== node.id).length > 0) return null;
|
|
174
|
+
const readRefs = variable.references.filter((reference) => reference.isRead() && isIdentifierReference(reference));
|
|
175
|
+
if (readRefs.some((reference) => {
|
|
176
|
+
const afterToken = context.sourceCode.getTokenAfter(reference.identifier);
|
|
177
|
+
if (afterToken?.value === ":") return false;
|
|
178
|
+
if (afterToken?.value !== "}" && afterToken?.value !== ",") return false;
|
|
179
|
+
let token = context.sourceCode.getTokenBefore(reference.identifier);
|
|
180
|
+
while (token) {
|
|
181
|
+
if (token.value === "{") return true;
|
|
182
|
+
if (token.value === "[" || token.value === "(") return false;
|
|
183
|
+
if (token.value === ":") return false;
|
|
184
|
+
token = context.sourceCode.getTokenBefore(token);
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
})) return null;
|
|
188
|
+
const fixes = readRefs.map((reference) => fixer.replaceText(reference.identifier, originalName));
|
|
189
|
+
const declaration = node.parent;
|
|
190
|
+
if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return null;
|
|
191
|
+
fixes.push(fixer.remove(declaration));
|
|
192
|
+
return fixes;
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
} };
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/rules/no-side-effects-in-index.ts
|
|
200
|
+
function isPureReexport(statement) {
|
|
201
|
+
if (statement.type === "ExportAllDeclaration") return true;
|
|
202
|
+
return statement.type === "ExportNamedDeclaration" && statement.source !== null && statement.source !== void 0;
|
|
203
|
+
}
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/plugin.ts
|
|
206
|
+
const plugin = {
|
|
207
|
+
meta: {
|
|
208
|
+
name: "@exadev/eslint-config",
|
|
209
|
+
version,
|
|
210
|
+
namespace: "exadev"
|
|
211
|
+
},
|
|
212
|
+
rules: {
|
|
213
|
+
"no-non-barrel-index": noNonBarrelIndex,
|
|
214
|
+
"no-non-barrel-reexport": noNonBarrelReexport,
|
|
215
|
+
"no-pointless-reassignment": noPointlessReassignment,
|
|
216
|
+
"no-side-effects-in-index": {
|
|
217
|
+
meta: {
|
|
218
|
+
type: "problem",
|
|
219
|
+
schema: [],
|
|
220
|
+
messages: { notAPureReexport: "The public barrel (src/index.ts) may contain only re-export statements ('export * from ...' / 'export { x } from ...' / 'export type { x } from ...') -- nothing else, so it can never have a side effect at import time by construction. Found: {{ description }}." }
|
|
221
|
+
},
|
|
222
|
+
create(context) {
|
|
223
|
+
if (!context.filename.endsWith("/src/index.ts")) return {};
|
|
224
|
+
return { Program(node) {
|
|
225
|
+
for (const statement of node.body) if (!isPureReexport(statement)) context.report({
|
|
226
|
+
node: statement,
|
|
227
|
+
messageId: "notAPureReexport",
|
|
228
|
+
data: { description: statement.type }
|
|
229
|
+
});
|
|
230
|
+
} };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
configs: {
|
|
235
|
+
get recommended() {
|
|
236
|
+
return {
|
|
237
|
+
plugins: { exadev: plugin },
|
|
238
|
+
linterOptions: { noInlineConfig: true },
|
|
239
|
+
rules: {
|
|
240
|
+
"exadev/no-non-barrel-index": "error",
|
|
241
|
+
"exadev/no-non-barrel-reexport": "error",
|
|
242
|
+
"exadev/no-pointless-reassignment": "error",
|
|
243
|
+
"exadev/no-side-effects-in-index": "error"
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
get barrel() {
|
|
248
|
+
return {
|
|
249
|
+
plugins: { exadev: plugin },
|
|
250
|
+
rules: {
|
|
251
|
+
"exadev/no-non-barrel-index": "error",
|
|
252
|
+
"exadev/no-non-barrel-reexport": "error",
|
|
253
|
+
"exadev/no-side-effects-in-index": "error"
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
//#endregion
|
|
260
|
+
//#region src/recommended-type-checked.ts
|
|
261
|
+
const TEST_FILE_PATTERNS = "**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
|
|
262
|
+
const recommendedTypeChecked = [
|
|
263
|
+
...typescript_eslint.default.configs.recommendedTypeChecked,
|
|
264
|
+
...typescript_eslint.default.configs.stylisticTypeChecked,
|
|
265
|
+
{
|
|
266
|
+
plugins: { exadev: plugin },
|
|
267
|
+
linterOptions: { noInlineConfig: true },
|
|
268
|
+
rules: {
|
|
269
|
+
"exadev/no-non-barrel-index": "error",
|
|
270
|
+
"exadev/no-non-barrel-reexport": "error",
|
|
271
|
+
"exadev/no-pointless-reassignment": "error",
|
|
272
|
+
"exadev/no-side-effects-in-index": "error",
|
|
273
|
+
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
|
|
274
|
+
"@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": true }]
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
files: [TEST_FILE_PATTERNS],
|
|
279
|
+
rules: {
|
|
280
|
+
"@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": "allow-with-description" }],
|
|
281
|
+
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "as" }]
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
];
|
|
285
|
+
//#endregion
|
|
286
|
+
exports.default = recommendedTypeChecked;
|
|
287
|
+
exports.plugin = plugin;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { ESLint } from "eslint";
|
|
2
|
+
//#region src/recommended-type-checked.d.ts
|
|
3
|
+
type ConfigValue = NonNullable<ESLint.Plugin['configs']>[string];
|
|
4
|
+
type ConfigArrayValue = Extract<ConfigValue, unknown[]>;
|
|
5
|
+
declare const recommendedTypeChecked: ConfigArrayValue;
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/plugin.d.ts
|
|
8
|
+
declare const plugin: ESLint.Plugin;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { recommendedTypeChecked as default, plugin };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { ESLint } from "eslint";
|
|
2
|
+
//#region src/recommended-type-checked.d.ts
|
|
3
|
+
type ConfigValue = NonNullable<ESLint.Plugin['configs']>[string];
|
|
4
|
+
type ConfigArrayValue = Extract<ConfigValue, unknown[]>;
|
|
5
|
+
declare const recommendedTypeChecked: ConfigArrayValue;
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/plugin.d.ts
|
|
8
|
+
declare const plugin: ESLint.Plugin;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { recommendedTypeChecked as default, plugin };
|