|
| 1 | +--- |
| 2 | +name: eslint-rule-paradigm |
| 3 | +description: Best-practice workflow for designing, implementing, and testing ESLint rules/plugins (meta, schema/defaultOptions, RuleTester, fixes/suggestions, flat config). Includes authoritative links, example sources, test templates, packaging that supports `defineConfig(plugin.configs.recommended)`, and repo integration via `pnpm run lint-app`. |
| 4 | +--- |
| 5 | + |
| 6 | +# ESLint Rule Paradigm |
| 7 | + |
| 8 | +## When to use |
| 9 | + |
| 10 | +* Creating a new ESLint rule or refactoring an existing one |
| 11 | +* Building/maintaining plugin configs (especially for flat config) |
| 12 | +* Adding analyzers and validating behavior in a realistic TS project (packages/app) |
| 13 | + |
| 14 | +--- |
| 15 | + |
| 16 | +## Primary docs (authoritative) |
| 17 | + |
| 18 | +ESLint: |
| 19 | + |
| 20 | +* Custom rules: https://eslint.org/docs/latest/extend/custom-rules |
| 21 | +* Custom rule tutorial: https://eslint.org/docs/latest/extend/custom-rule-tutorial |
| 22 | +* Plugins (shape, exports): https://eslint.org/docs/latest/extend/plugins |
| 23 | +* Flat config - configure plugins: https://eslint.org/docs/latest/use/configure/plugins |
| 24 | +* Plugin migration to flat config (meta.name, meta.version, recommended structure): https://eslint.org/docs/latest/extend/plugin-migration-flat-config |
| 25 | +* Node.js API - RuleTester basics: https://eslint.org/docs/latest/integrate/nodejs-api |
| 26 | + |
| 27 | +TypeScript ecosystem: |
| 28 | + |
| 29 | +* typescript-eslint custom rules: https://typescript-eslint.io/developers/custom-rules/ |
| 30 | +* @typescript-eslint/rule-tester: https://typescript-eslint.io/packages/rule-tester/ |
| 31 | + |
| 32 | +--- |
| 33 | + |
| 34 | +## Where to copy examples from (high signal) |
| 35 | + |
| 36 | +1. ESLint core repo (canonical layout and style): |
| 37 | + |
| 38 | + * rules: `lib/rules/*` |
| 39 | + * tests: `tests/lib/rules/*` |
| 40 | + * docs: `docs/src/rules/*` |
| 41 | + * repo: https://github.com/eslint/eslint |
| 42 | + |
| 43 | +2. ESLint Custom Rule Tutorial: |
| 44 | + |
| 45 | + * small end-to-end example with RuleTester + packaging |
| 46 | + |
| 47 | +3. typescript-eslint docs and packages: |
| 48 | + |
| 49 | + * patterns for typed rules, parser services, safer ergonomics |
| 50 | + |
| 51 | +--- |
| 52 | + |
| 53 | +## Core workflow |
| 54 | + |
| 55 | +1. **Problem framing** |
| 56 | + |
| 57 | + * Specify the invariant precisely: forbidden/required property. |
| 58 | + * Specify the exact AST contexts: node kinds + minimal data required. |
| 59 | + * Decide if you need types or pure syntax is enough. |
| 60 | + |
| 61 | +2. **Rule classification** |
| 62 | + |
| 63 | + * `meta.type`: `problem | suggestion | layout` |
| 64 | + * Autofix -> set `meta.fixable: "code" | "whitespace"` |
| 65 | + * Suggestions -> set `meta.hasSuggestions: true` |
| 66 | + |
| 67 | +3. **Options contract first** |
| 68 | + |
| 69 | + * `meta.schema`: JSON Schema for options (`[]` if no options). |
| 70 | + * `defaultOptions`: top-level export field (NOT inside `meta`). |
| 71 | + * Compatibility: |
| 72 | + |
| 73 | + * don't change meaning of existing options |
| 74 | + * add new options as optional with defaults |
| 75 | + |
| 76 | +4. **Messages contract** |
| 77 | + |
| 78 | + * Put all messages under `meta.messages`. |
| 79 | + * Report via `{ messageId, data }`. |
| 80 | + * Keep message IDs stable (they're public API). |
| 81 | + |
| 82 | +5. **Implementation constraints** |
| 83 | + |
| 84 | + * Deterministic, no I/O, no global state. |
| 85 | + * No AST mutation. |
| 86 | + * Performance: |
| 87 | + |
| 88 | + * avoid expensive scans per-node |
| 89 | + * cache derived data from `context.getSourceCode()` |
| 90 | + * for cross-file-ish logic within one file: collect in visitors, finalize in `Program:exit` |
| 91 | + |
| 92 | +6. **Type-aware path (optional)** |
| 93 | + |
| 94 | + * Only touch types when parser services exist. |
| 95 | + * If types are required: document requirement and fail gracefully (no crash). |
| 96 | + |
| 97 | +7. **Testing** |
| 98 | + |
| 99 | + * Unit tests: RuleTester (valid/invalid + options + fix/suggestions). |
| 100 | + * Integration loop: `packages/app` via `pnpm run lint-app` (realistic output). |
| 101 | + |
| 102 | +8. **Packaging** |
| 103 | + |
| 104 | + * Export plugin object with `meta`, `rules`, `configs`. |
| 105 | + * Ensure flat-config `recommended` works standalone via `defineConfig(plugin.configs.recommended)`. |
| 106 | + |
| 107 | +--- |
| 108 | + |
| 109 | +# Templates |
| 110 | + |
| 111 | +## Minimal rule skeleton |
| 112 | + |
| 113 | +```js |
| 114 | +export default { |
| 115 | + meta: { |
| 116 | + type: "suggestion", |
| 117 | + docs: { description: "...", recommended: false }, |
| 118 | + schema: [], |
| 119 | + messages: { |
| 120 | + bad: "...", |
| 121 | + }, |
| 122 | + // fixable: "code", // only if you provide `fix` |
| 123 | + // hasSuggestions: true, // only if you provide `suggest` |
| 124 | + }, |
| 125 | + defaultOptions: [], |
| 126 | + create(context) { |
| 127 | + const sourceCode = context.getSourceCode(); |
| 128 | + |
| 129 | + return { |
| 130 | + Identifier(node) { |
| 131 | + // lightweight analysis |
| 132 | + context.report({ node, messageId: "bad" }); |
| 133 | + }, |
| 134 | + }; |
| 135 | + }, |
| 136 | +}; |
| 137 | +``` |
| 138 | + |
| 139 | +## Fix vs suggestion policy |
| 140 | + |
| 141 | +* Use **autofix** only if the transform is local + unambiguous + syntax-safe + semantics-safe. |
| 142 | +* If there is any ambiguity, prefer **suggestions**. |
| 143 | + |
| 144 | +--- |
| 145 | + |
| 146 | +# Testing templates (RuleTester) |
| 147 | + |
| 148 | +## Baseline RuleTester (ESLint) |
| 149 | + |
| 150 | +```js |
| 151 | +import rule from "../src/rules/my-rule.js"; |
| 152 | +import { RuleTester } from "eslint"; |
| 153 | + |
| 154 | +const ruleTester = new RuleTester({ |
| 155 | + languageOptions: { ecmaVersion: 2022, sourceType: "module" }, |
| 156 | +}); |
| 157 | + |
| 158 | +ruleTester.run("my-rule", rule, { |
| 159 | + valid: [ |
| 160 | + "const ok = 1;", |
| 161 | + { code: "const ok2 = 2;", options: [{ someOpt: true }] }, |
| 162 | + ], |
| 163 | + invalid: [ |
| 164 | + { |
| 165 | + code: "const bad = 1;", |
| 166 | + options: [{ someOpt: false }], |
| 167 | + errors: [{ messageId: "bad" }], |
| 168 | + }, |
| 169 | + ], |
| 170 | +}); |
| 171 | +``` |
| 172 | + |
| 173 | +## Autofix test |
| 174 | + |
| 175 | +```js |
| 176 | +ruleTester.run("my-rule", rule, { |
| 177 | + valid: [], |
| 178 | + invalid: [ |
| 179 | + { |
| 180 | + code: "let x = 1;", |
| 181 | + output: "const x = 1;", |
| 182 | + errors: [{ messageId: "useConst" }], |
| 183 | + }, |
| 184 | + ], |
| 185 | +}); |
| 186 | +``` |
| 187 | + |
| 188 | +## Suggestions test |
| 189 | + |
| 190 | +```js |
| 191 | +ruleTester.run("my-rule", rule, { |
| 192 | + valid: [], |
| 193 | + invalid: [ |
| 194 | + { |
| 195 | + code: "const foo = 'baz';", |
| 196 | + errors: [ |
| 197 | + { |
| 198 | + messageId: "wrongFoo", |
| 199 | + suggestions: [ |
| 200 | + { messageId: "replaceWithBar", output: "const foo = 'bar';" }, |
| 201 | + ], |
| 202 | + }, |
| 203 | + ], |
| 204 | + }, |
| 205 | + ], |
| 206 | +}); |
| 207 | +``` |
| 208 | + |
| 209 | +## TS-friendly testing (@typescript-eslint/rule-tester) |
| 210 | + |
| 211 | +```ts |
| 212 | +import { RuleTester } from "@typescript-eslint/rule-tester"; |
| 213 | +import rule from "../src/rules/my-rule"; |
| 214 | + |
| 215 | +const ruleTester = new RuleTester({ |
| 216 | + languageOptions: { ecmaVersion: 2022, sourceType: "module" }, |
| 217 | +}); |
| 218 | + |
| 219 | +ruleTester.run("my-rule", rule, { |
| 220 | + valid: [{ code: "const x: number = 1;" }], |
| 221 | + invalid: [{ code: "const y: any = 1;", errors: [{ messageId: "noAny" }] }], |
| 222 | +}); |
| 223 | +``` |
| 224 | + |
| 225 | +### Error assertion checklist |
| 226 | + |
| 227 | +* Always assert `messageId`. |
| 228 | +* Assert `data` keys when used. |
| 229 | +* Use `output` for fixes. |
| 230 | +* Use `suggestions: [{ messageId, output }]` for suggestions. |
| 231 | + |
| 232 | +--- |
| 233 | + |
| 234 | +# Flat config packaging that never errors with `defineConfig(plugin.configs.recommended)` |
| 235 | + |
| 236 | +## Goal |
| 237 | + |
| 238 | +Your plugin must be usable like this (no extra `plugins: {}` required in user config): |
| 239 | + |
| 240 | +```ts |
| 241 | +import plugin from "<plugin-package-name>"; |
| 242 | +import { defineConfig } from "eslint/config"; |
| 243 | + |
| 244 | +export default defineConfig( |
| 245 | + plugin.configs.recommended, |
| 246 | + // optional overrides |
| 247 | +); |
| 248 | +``` |
| 249 | + |
| 250 | +## Invariant (must hold) |
| 251 | + |
| 252 | +`configs.recommended` MUST be a flat config object (or an array of flat config objects) that **self-registers the plugin**: |
| 253 | + |
| 254 | +* `plugins: { "<plugin-namespace>": pluginObject }` |
| 255 | +* `rules: { "<plugin-namespace>/<rule>": "error" | ... }` |
| 256 | + |
| 257 | +If `configs.recommended` does not include `plugins: { namespace: plugin }`, ESLint will report "unknown rule" / "plugin missing" when the user only spreads recommended. |
| 258 | + |
| 259 | +## Recommended implementation pattern (no circular refs problems) |
| 260 | + |
| 261 | +Define `plugin` first, then attach configs using `Object.assign`, so `configs.recommended` can reference `plugin`. |
| 262 | + |
| 263 | +```ts |
| 264 | +import type { Linter } from "eslint"; |
| 265 | + |
| 266 | +const plugin = { |
| 267 | + meta: { |
| 268 | + name: "<plugin-package-name>", |
| 269 | + version: "1.0.0", |
| 270 | + }, |
| 271 | + rules: { |
| 272 | + "<rule-a>": ruleA, |
| 273 | + "<rule-b>": ruleB, |
| 274 | + "<rule-c>": ruleC, |
| 275 | + "<rule-d>": ruleD, |
| 276 | + }, |
| 277 | + configs: {}, |
| 278 | +} satisfies Linter.Plugin; |
| 279 | + |
| 280 | +Object.assign(plugin.configs, { |
| 281 | + recommended: { |
| 282 | + name: "<plugin-namespace>/recommended", |
| 283 | + plugins: { |
| 284 | + "<plugin-namespace>": plugin, |
| 285 | + }, |
| 286 | + rules: { |
| 287 | + "<plugin-namespace>/<rule-a>": "error", |
| 288 | + "<plugin-namespace>/<rule-b>": "error", |
| 289 | + "<plugin-namespace>/<rule-c>": "error", |
| 290 | + "<plugin-namespace>/<rule-d>": "error", |
| 291 | + }, |
| 292 | + } satisfies Linter.FlatConfig, |
| 293 | +}); |
| 294 | + |
| 295 | +export default plugin; |
| 296 | +``` |
| 297 | + |
| 298 | +### Notes |
| 299 | + |
| 300 | +* Namespace (`"<plugin-namespace>"`) must match what you want in rule IDs (`<plugin-namespace>/<rule>`). |
| 301 | +* In `plugin.rules`, keys do NOT include namespace. |
| 302 | +* In `configs.recommended.rules`, keys DO include namespace. |
| 303 | + |
| 304 | +## If you prefer `recommended` as an array |
| 305 | + |
| 306 | +This is also valid and composes better when you need multiple layers. |
| 307 | + |
| 308 | +```ts |
| 309 | +Object.assign(plugin.configs, { |
| 310 | + recommended: [ |
| 311 | + { |
| 312 | + name: "<plugin-namespace>/recommended", |
| 313 | + plugins: { "<plugin-namespace>": plugin }, |
| 314 | + rules: { |
| 315 | + "<plugin-namespace>/<rule-a>": "error", |
| 316 | + "<plugin-namespace>/<rule-b>": "error", |
| 317 | + }, |
| 318 | + }, |
| 319 | + { |
| 320 | + name: "<plugin-namespace>/recommended-overrides", |
| 321 | + rules: { |
| 322 | + "<plugin-namespace>/<rule-c>": "warn", |
| 323 | + }, |
| 324 | + }, |
| 325 | + ] satisfies Linter.FlatConfig[], |
| 326 | +}); |
| 327 | +``` |
| 328 | + |
| 329 | +--- |
| 330 | + |
| 331 | +# Repo integration: `packages/app` (live playground) |
| 332 | + |
| 333 | +## Purpose |
| 334 | + |
| 335 | +`packages/app` is an intentionally imperfect TS app used to validate analyzers/rules in a realistic run. |
| 336 | +You intentionally introduce mistakes (typos, wrong names, missing imports, etc.) to verify: |
| 337 | + |
| 338 | +* your rule triggers |
| 339 | +* message wording is correct |
| 340 | +* suggestions/fixes appear where intended |
| 341 | + |
| 342 | +## Run |
| 343 | + |
| 344 | +From repo root: |
| 345 | + |
| 346 | +```bash |
| 347 | +pnpm run lint-app |
| 348 | +``` |
| 349 | + |
| 350 | +This runs linting for `@effect-template/app` and prints diagnostics from ESLint (and other tools in the pipeline). Use it as fast feedback. |
| 351 | + |
| 352 | +## How to add a new scenario |
| 353 | + |
| 354 | +* Add/change code under `packages/app/src/**` to trigger one intended analyzer. |
| 355 | +* Keep each scenario small and isolated. |
| 356 | +* Use recognizable "intentional mistakes" (e.g. `ru1Main`, `modul3`, `formatGree7ing`) so the output is self-explanatory. |
| 357 | + |
| 358 | +## Guardrails |
| 359 | + |
| 360 | +* Don't "fix" the playground errors unless you are intentionally changing expected analyzer behavior. |
| 361 | +* If you change messages/suggestions, update: |
| 362 | + |
| 363 | + * unit tests (RuleTester) |
| 364 | + * and playground scenarios if they serve as demonstration |
| 365 | + |
| 366 | +--- |
| 367 | + |
| 368 | +# Ship-ready checklist |
| 369 | + |
| 370 | +* [ ] invariant is precise (AST scope + intended behavior) |
| 371 | +* [ ] `meta.type` chosen; `meta.fixable`/`meta.hasSuggestions` consistent with behavior |
| 372 | +* [ ] `meta.schema` + `defaultOptions` defined first; options compatibility preserved |
| 373 | +* [ ] reports use `messageId` (+ `data`), messages stable |
| 374 | +* [ ] perf: no heavy scans per-node; caching or `Program:exit` aggregation |
| 375 | +* [ ] RuleTester covers: valid/invalid, option matrix, fixes/suggestions, edge syntax |
| 376 | +* [ ] `packages/app` scenario demonstrates analyzer (`pnpm run lint-app`) |
| 377 | +* [ ] `configs.recommended` is self-contained and works via `defineConfig(plugin.configs.recommended)` |
0 commit comments