diff --git a/README.md b/README.md index db84925f..bbb96459 100644 --- a/README.md +++ b/README.md @@ -7,32 +7,70 @@ This ESLint plugin is intended to prevent issues with [RxJS](https://github.com/ Most of these rules require TypeScript typed linting and are indicated as such below. -## Migrating from `eslint-plugin-rxjs` +## Migration Guide from `eslint-plugin-rxjs` -This project is a fork of [`eslint-plugin-rxjs`](https://github.com/cartant/eslint-plugin-rxjs) -initially started to support the new ESLint flat config format. -There are some breaking changes: +This project started as a fork of [`eslint-plugin-rxjs`](https://github.com/cartant/eslint-plugin-rxjs) +but has since introduced new features which involve breaking changes. -- The old `.eslintrc` format is not supported. - - If you need to continue using this old format, use the original `eslint-plugin-rxjs` or a different fork. -- The plugin namespace specified in the `recommended` config was changed from `rxjs` to `rxjs-x`. - - e.g. In your ESLint config, `rxjs/no-subject-value` should be renamed to `rxjs-x/no-subject-value`. -- The rule `rxjs/no-ignored-observable` is renamed to `rxjs-x/no-floating-observables`. +1. Migrate your config from the old `.eslintrc` format to `eslint.config.mjs` (or similar), and uninstall `eslint-plugin-rxjs`. + - See ESLint's guide here: [https://eslint.org/docs/latest/use/configure/migration-guide]. + - If you need to continue using the deprecated format, use the original `eslint-plugin-rxjs` or a different fork. +2. Install `eslint-plugin-rxjs-x`, and import it into your config. -A complete description of all changes are documented in the [CHANGELOG](CHANGELOG.md) file. + ```diff + + import rxjsX from 'eslint-plugin-rxjs-x'; + ``` -## Install +3. If you previously used the `plugin:rxjs/recommended` shared config, add `rxjsX.configs.recommended` to your `configs` block: + + ```diff + configs: { + + rxjsX.configs.recommended, + }, + ``` + + - Note: `eslint-plugin-rxjs-x` provides a `strict` shared config, so consider using `rxjsX.configs.strict` instead. +4. If you previously did _not_ use a shared config, add the plugin to your `plugins` block with the new namespace: + + ```diff + plugins: { + + 'rxjs-x': rxjsX, + }, + ``` + + - Note: this is unnecessary if you are using a shared config. +5. In your `rules` block, replace the namespace `rxjs` with `rxjs-x`: + + ```diff + - 'rxjs/no-subject-value': 'error', + + 'rxjs-x/no-subject-value': 'error', + ``` + +6. If you previously used `rxjs/no-ignored-observable`, replace it with `rxjs-x/no-floating-observables`. + + ```diff + - 'rxjs/no-ignored-observable': 'error', + + 'rxjs-x/no-floating-observables': 'error', + ``` + +> [!TIP] +> A complete description of all changes are documented in the [CHANGELOG](CHANGELOG.md) file. + +## Installation Guide See [typescript-eslint's Getting Started](https://typescript-eslint.io/getting-started) for a full ESLint setup guide. -Then use the `recommended` configuration in your `eslint.config.mjs` and enable typed linting: +1. Enable typed linting. + - See [Linting with Type Information](https://typescript-eslint.io/getting-started/typed-linting/) for more information. +2. Start with the `recommended` shared config in your `eslint.config.mjs`. ```js // @ts-check +import { defineConfig } from 'eslint/config'; import tseslint from 'typescript-eslint'; import rxjsX from 'eslint-plugin-rxjs-x'; -export default tseslint.config({ +export default defineConfig({ extends: [ ...tseslint.configs.recommended, rxjsX.configs.recommended, @@ -45,10 +83,6 @@ export default tseslint.config({ }); ``` -The above example uses `typescript-eslint`'s built-in config to set up the TypeScript parser for us. -Enabling `projectService` then turns on typed linting. -See [Linting with Type Information](https://typescript-eslint.io/getting-started/typed-linting/) for details. - ## Configs diff --git a/eslint.config.mjs b/eslint.config.mjs index d0840bed..e8e0ed48 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,14 +1,15 @@ // @ts-check import js from '@eslint/js'; import stylistic from '@stylistic/eslint-plugin'; +import vitest from '@vitest/eslint-plugin'; import gitignore from 'eslint-config-flat-gitignore'; +import eslintPlugin from 'eslint-plugin-eslint-plugin'; import importX from 'eslint-plugin-import-x'; import n from 'eslint-plugin-n'; +import { defineConfig } from 'eslint/config'; import tseslint from 'typescript-eslint'; -import vitest from '@vitest/eslint-plugin'; -import eslintPlugin from 'eslint-plugin-eslint-plugin'; -export default tseslint.config(gitignore(), { +export default defineConfig(gitignore(), { files: [ 'src/**/*.ts', 'tests/**/*.ts', diff --git a/package.json b/package.json index 7c618efc..68e029a2 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ } }, "devDependencies": { + "@eslint/config-helpers": "0.4.1", "@eslint/js": "^9.38.0", "@stylistic/eslint-plugin": "^5.5.0", "@types/common-tags": "^1.8.4", @@ -79,7 +80,7 @@ "@typescript-eslint/rule-tester": "^8.46.2", "@typescript/vfs": "^1.6.1", "@vitest/coverage-v8": "^3.2.4", - "@vitest/eslint-plugin": "^1.2.7", + "@vitest/eslint-plugin": "^1.4.0", "bumpp": "^10.3.1", "eslint": "^9.38.0", "eslint-config-flat-gitignore": "^2.1.0", diff --git a/src/configs/recommended.ts b/src/configs/recommended.ts index 5d08cb94..0029fd87 100644 --- a/src/configs/recommended.ts +++ b/src/configs/recommended.ts @@ -1,11 +1,12 @@ -import { TSESLint } from '@typescript-eslint/utils'; +import type { ESLint, Linter } from 'eslint'; export const createRecommendedConfig = ( - plugin: TSESLint.FlatConfig.Plugin, + plugin: ESLint.Plugin, ) => ({ name: 'rxjs-x/recommended' as const, plugins: { - 'rxjs-x': plugin, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- "A type annotation is necessary." + 'rxjs-x': plugin as ESLint.Plugin, }, rules: { 'rxjs-x/no-async-subscribe': 'error', @@ -29,4 +30,4 @@ export const createRecommendedConfig = ( 'rxjs-x/prefer-root-operators': 'error', 'rxjs-x/throw-error': 'error', }, -} satisfies TSESLint.FlatConfig.Config); +} satisfies Linter.Config); diff --git a/src/configs/strict.ts b/src/configs/strict.ts index 89591926..a5418177 100644 --- a/src/configs/strict.ts +++ b/src/configs/strict.ts @@ -1,11 +1,12 @@ -import { TSESLint } from '@typescript-eslint/utils'; +import type { ESLint, Linter } from 'eslint'; export const createStrictConfig = ( - plugin: TSESLint.FlatConfig.Plugin, + plugin: ESLint.Plugin, ) => ({ name: 'rxjs-x/strict' as const, plugins: { - 'rxjs-x': plugin, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- "A type annotation is necessary." + 'rxjs-x': plugin as ESLint.Plugin, }, rules: { 'rxjs-x/no-async-subscribe': 'error', @@ -40,4 +41,4 @@ export const createStrictConfig = ( allowThrowingUnknown: false as const, }], }, -} satisfies TSESLint.FlatConfig.Config); +} satisfies Linter.Config); diff --git a/src/index.ts b/src/index.ts index cf693fa2..93b9dd6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -import { TSESLint } from '@typescript-eslint/utils'; +import type { TSESLint } from '@typescript-eslint/utils'; +import type { ESLint, Rule } from 'eslint'; import { name, version } from '../package.json'; import { createRecommendedConfig } from './configs/recommended'; import { createStrictConfig } from './configs/strict'; @@ -49,56 +50,59 @@ import { preferRootOperatorsRule } from './rules/prefer-root-operators'; import { suffixSubjectsRule } from './rules/suffix-subjects'; import { throwErrorRule } from './rules/throw-error'; +const allRules = { + 'ban-observables': banObservablesRule, + 'ban-operators': banOperatorsRule, + 'finnish': finnishRule, + 'just': justRule, + 'macro': macroRule, + 'no-async-subscribe': noAsyncSubscribeRule, + 'no-compat': noCompatRule, + 'no-connectable': noConnectableRule, + 'no-create': noCreateRule, + 'no-cyclic-action': noCyclicActionRule, + 'no-explicit-generics': noExplicitGenericsRule, + 'no-exposed-subjects': noExposedSubjectsRule, + 'no-finnish': noFinnishRule, + 'no-floating-observables': noFloatingObservablesRule, + 'no-ignored-default-value': noIgnoredDefaultValueRule, + 'no-ignored-error': noIgnoredErrorRule, + 'no-ignored-notifier': noIgnoredNotifierRule, + 'no-ignored-replay-buffer': noIgnoredReplayBufferRule, + 'no-ignored-subscribe': noIgnoredSubscribeRule, + 'no-ignored-subscription': noIgnoredSubscriptionRule, + 'no-ignored-takewhile-value': noIgnoredTakewhileValueRule, + 'no-implicit-any-catch': noImplicitAnyCatchRule, + 'no-index': noIndexRule, + 'no-internal': noInternalRule, + 'no-misused-observables': noMisusedObservablesRule, + 'no-nested-subscribe': noNestedSubscribeRule, + 'no-redundant-notify': noRedundantNotifyRule, + 'no-sharereplay': noSharereplayRule, + 'no-subclass': noSubclassRule, + 'no-subject-unsubscribe': noSubjectUnsubscribeRule, + 'no-subject-value': noSubjectValueRule, + 'no-subscribe-handlers': noSubscribeHandlersRule, + 'no-subscribe-in-pipe': noSubscribeInPipeRule, + 'no-tap': noTapRule, + 'no-topromise': noTopromiseRule, + 'no-unbound-methods': noUnboundMethodsRule, + 'no-unsafe-catch': noUnsafeCatchRule, + 'no-unsafe-first': noUnsafeFirstRule, + 'no-unsafe-subject-next': noUnsafeSubjectNext, + 'no-unsafe-switchmap': noUnsafeSwitchmapRule, + 'no-unsafe-takeuntil': noUnsafeTakeuntilRule, + 'prefer-observer': preferObserverRule, + 'prefer-root-operators': preferRootOperatorsRule, + 'suffix-subjects': suffixSubjectsRule, + 'throw-error': throwErrorRule, +} satisfies TSESLint.FlatConfig.Plugin['rules']; + const plugin = { meta: { name, version }, - rules: { - 'ban-observables': banObservablesRule, - 'ban-operators': banOperatorsRule, - 'finnish': finnishRule, - 'just': justRule, - 'macro': macroRule, - 'no-async-subscribe': noAsyncSubscribeRule, - 'no-compat': noCompatRule, - 'no-connectable': noConnectableRule, - 'no-create': noCreateRule, - 'no-cyclic-action': noCyclicActionRule, - 'no-explicit-generics': noExplicitGenericsRule, - 'no-exposed-subjects': noExposedSubjectsRule, - 'no-finnish': noFinnishRule, - 'no-floating-observables': noFloatingObservablesRule, - 'no-ignored-default-value': noIgnoredDefaultValueRule, - 'no-ignored-error': noIgnoredErrorRule, - 'no-ignored-notifier': noIgnoredNotifierRule, - 'no-ignored-replay-buffer': noIgnoredReplayBufferRule, - 'no-ignored-subscribe': noIgnoredSubscribeRule, - 'no-ignored-subscription': noIgnoredSubscriptionRule, - 'no-ignored-takewhile-value': noIgnoredTakewhileValueRule, - 'no-implicit-any-catch': noImplicitAnyCatchRule, - 'no-index': noIndexRule, - 'no-internal': noInternalRule, - 'no-misused-observables': noMisusedObservablesRule, - 'no-nested-subscribe': noNestedSubscribeRule, - 'no-redundant-notify': noRedundantNotifyRule, - 'no-sharereplay': noSharereplayRule, - 'no-subclass': noSubclassRule, - 'no-subject-unsubscribe': noSubjectUnsubscribeRule, - 'no-subject-value': noSubjectValueRule, - 'no-subscribe-handlers': noSubscribeHandlersRule, - 'no-subscribe-in-pipe': noSubscribeInPipeRule, - 'no-tap': noTapRule, - 'no-topromise': noTopromiseRule, - 'no-unbound-methods': noUnboundMethodsRule, - 'no-unsafe-catch': noUnsafeCatchRule, - 'no-unsafe-first': noUnsafeFirstRule, - 'no-unsafe-subject-next': noUnsafeSubjectNext, - 'no-unsafe-switchmap': noUnsafeSwitchmapRule, - 'no-unsafe-takeuntil': noUnsafeTakeuntilRule, - 'prefer-observer': preferObserverRule, - 'prefer-root-operators': preferRootOperatorsRule, - 'suffix-subjects': suffixSubjectsRule, - 'throw-error': throwErrorRule, - }, -} satisfies TSESLint.FlatConfig.Plugin; + /** Compatibility with `defineConfig` until https://github.com/typescript-eslint/typescript-eslint/issues/11543 is addressed. */ + rules: allRules as { [K in keyof typeof allRules]: (typeof allRules)[K] & Rule.RuleModule }, +} satisfies ESLint.Plugin; const rxjsX = { ...plugin, @@ -106,6 +110,6 @@ const rxjsX = { recommended: createRecommendedConfig(plugin), strict: createStrictConfig(plugin), }, -} satisfies TSESLint.FlatConfig.Plugin; +} satisfies ESLint.Plugin; export default rxjsX; diff --git a/src/utils/rule-creator.ts b/src/utils/rule-creator.ts index 15b662d2..c9870adc 100644 --- a/src/utils/rule-creator.ts +++ b/src/utils/rule-creator.ts @@ -1,15 +1,21 @@ import { ESLintUtils, TSESLint } from '@typescript-eslint/utils'; import { version } from '../../package.json'; -export interface RxjsXRuleDocs { - description: string; - recommended?: TSESLint.RuleRecommendation | TSESLint.RuleRecommendationAcrossConfigs; +export interface RxjsXRuleDocs { + description: Desc; + recommended?: TSESLint.RuleRecommendation | TSESLint.RuleRecommendationAcrossConfigs; requiresTypeChecking?: boolean; } const REPO_URL = 'https://github.com/JasonWeinzierl/eslint-plugin-rxjs-x'; -export const ruleCreator = ESLintUtils.RuleCreator( +export const ruleCreator = ESLintUtils.RuleCreator>( (name) => `${REPO_URL}/blob/v${version}/docs/rules/${name}.md`, -); +// Ensure the resulting types are narrowed to exactly what each rule declares. +) as < + Options extends readonly unknown[], + MessageIds extends string, + Desc extends string, + Docs extends RxjsXRuleDocs, +>({ meta, name, ...rule }: Readonly>) => TSESLint.RuleModule; diff --git a/tests/package.test.ts b/tests/package.test.ts index aa388512..01753b85 100644 --- a/tests/package.test.ts +++ b/tests/package.test.ts @@ -51,14 +51,16 @@ describe('package', () => { const namespace = 'rxjs-x'; const fullRuleName = `${namespace}/${ruleName}`; - if (!rule.meta.docs?.recommended) { + const ruleRec = rule.meta.docs?.recommended; + + if (!ruleRec) { // Rule is not included in any configuration. expect(plugin.configs.recommended.rules).not.toHaveProperty(fullRuleName); expect(plugin.configs.strict.rules).not.toHaveProperty(fullRuleName); - } else if (typeof rule.meta.docs.recommended === 'string') { + } else if (typeof ruleRec === 'string') { // Rule specifies only a configuration name. - expect(rule.meta.docs.recommended).toMatch(/^(recommended|strict)$/); - if (rule.meta.docs.recommended === 'recommended') { + expect(ruleRec).toMatch(/^(recommended|strict)$/); + if (ruleRec === 'recommended') { expect(plugin.configs.recommended.rules).toHaveProperty(fullRuleName); } else { expect(plugin.configs.recommended.rules).not.toHaveProperty(fullRuleName); @@ -67,14 +69,17 @@ describe('package', () => { // Strict configuration always includes all recommended rules. // Not allowed to specify non-default options since rule only specifies a configuration name. expect(plugin.configs.strict.rules).toHaveProperty(fullRuleName, expect.any(String)); + } else if (typeof ruleRec !== 'object' || !('strict' in ruleRec && Array.isArray(ruleRec.strict))) { + // Rule has invalid recommended configuration. + expect.fail(`Unexpected type for 'rule.meta.docs.recommended': '${typeof ruleRec}'.`); } else { // Rule specifies non-default options for strict. - if (rule.meta.docs.recommended.recommended) { + if ('recommended' in ruleRec) { expect(plugin.configs.recommended.rules).toHaveProperty(fullRuleName); } else { expect(plugin.configs.recommended.rules).not.toHaveProperty(fullRuleName); } - expect(plugin.configs.strict.rules).toHaveProperty(fullRuleName, [expect.any(String), rule.meta.docs.recommended.strict[0]]); + expect(plugin.configs.strict.rules).toHaveProperty(fullRuleName, [expect.any(String), ruleRec.strict[0]]); } }); }); diff --git a/yarn.lock b/yarn.lock index fe7b5504..2b09e824 100644 --- a/yarn.lock +++ b/yarn.lock @@ -312,12 +312,12 @@ __metadata: languageName: node linkType: hard -"@eslint/config-helpers@npm:^0.4.1": - version: 0.4.2 - resolution: "@eslint/config-helpers@npm:0.4.2" +"@eslint/config-helpers@npm:0.4.1, @eslint/config-helpers@npm:^0.4.1": + version: 0.4.1 + resolution: "@eslint/config-helpers@npm:0.4.1" dependencies: - "@eslint/core": "npm:^0.17.0" - checksum: 10c0/92efd7a527b2d17eb1a148409d71d80f9ac160b565ac73ee092252e8bf08ecd08670699f46b306b94f13d22e88ac88a612120e7847570dd7cdc72f234d50dcb4 + "@eslint/core": "npm:^0.16.0" + checksum: 10c0/bb7dd534019a975320ac0f8e0699b37433cee9a3731354c1ee941648e6651032386e7848792060fb53a0fd603ea6cf7a101ed3bd5b82ee2f641598986d1e080a languageName: node linkType: hard @@ -886,7 +886,7 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.46.2, @typescript-eslint/scope-manager@npm:^8.19.1": +"@typescript-eslint/scope-manager@npm:8.46.2, @typescript-eslint/scope-manager@npm:^8.19.1, @typescript-eslint/scope-manager@npm:^8.46.1": version: 8.46.2 resolution: "@typescript-eslint/scope-manager@npm:8.46.2" dependencies: @@ -948,7 +948,7 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.46.2, @typescript-eslint/utils@npm:^8.0.0, @typescript-eslint/utils@npm:^8.19.1, @typescript-eslint/utils@npm:^8.24.1": +"@typescript-eslint/utils@npm:8.46.2, @typescript-eslint/utils@npm:^8.0.0, @typescript-eslint/utils@npm:^8.19.1, @typescript-eslint/utils@npm:^8.46.1": version: 8.46.2 resolution: "@typescript-eslint/utils@npm:8.46.2" dependencies: @@ -1146,21 +1146,22 @@ __metadata: languageName: node linkType: hard -"@vitest/eslint-plugin@npm:^1.2.7": - version: 1.2.7 - resolution: "@vitest/eslint-plugin@npm:1.2.7" +"@vitest/eslint-plugin@npm:^1.4.0": + version: 1.4.0 + resolution: "@vitest/eslint-plugin@npm:1.4.0" dependencies: - "@typescript-eslint/utils": "npm:^8.24.1" + "@typescript-eslint/scope-manager": "npm:^8.46.1" + "@typescript-eslint/utils": "npm:^8.46.1" peerDependencies: - eslint: ">= 8.57.0" - typescript: ">= 5.0.0" + eslint: ">=8.57.0" + typescript: ">=5.0.0" vitest: "*" peerDependenciesMeta: typescript: optional: true vitest: optional: true - checksum: 10c0/85f71e977ed898345fbe32aaa3c149a5ec62b447a021978c002ce8c10494249f5d47f84eee7e5ee68df323fe893dd7a5fe2a04dab8daa6bd3e3f898196bdc340 + checksum: 10c0/6958ce071d4118560c126077e2aae7b628a3dd26bf586511ed168ec833f5aaf3981cc43ba8783b1b0db5f8517e7d8ed4e7b54b616b4beee43a4bc957d2b1a076 languageName: node linkType: hard @@ -2181,6 +2182,7 @@ __metadata: version: 0.0.0-use.local resolution: "eslint-plugin-rxjs-x@workspace:." dependencies: + "@eslint/config-helpers": "npm:0.4.1" "@eslint/js": "npm:^9.38.0" "@stylistic/eslint-plugin": "npm:^5.5.0" "@types/common-tags": "npm:^1.8.4" @@ -2190,7 +2192,7 @@ __metadata: "@typescript-eslint/utils": "npm:^8.19.1" "@typescript/vfs": "npm:^1.6.1" "@vitest/coverage-v8": "npm:^3.2.4" - "@vitest/eslint-plugin": "npm:^1.2.7" + "@vitest/eslint-plugin": "npm:^1.4.0" bumpp: "npm:^10.3.1" common-tags: "npm:^1.8.0" decamelize: "npm:^5.0.1"