Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 51 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

<!-- begin auto-generated configs list -->
Expand Down
7 changes: 4 additions & 3 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,15 @@
}
},
"devDependencies": {
"@eslint/config-helpers": "0.4.1",
"@eslint/js": "^9.38.0",
"@stylistic/eslint-plugin": "^5.5.0",
"@types/common-tags": "^1.8.4",
"@types/node": "~18.18.0",
"@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",
Expand Down
9 changes: 5 additions & 4 deletions src/configs/recommended.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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);
9 changes: 5 additions & 4 deletions src/configs/strict.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -40,4 +41,4 @@ export const createStrictConfig = (
allowThrowingUnknown: false as const,
}],
},
} satisfies TSESLint.FlatConfig.Config);
} satisfies Linter.Config);
104 changes: 54 additions & 50 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -49,63 +50,66 @@ 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,
configs: {
recommended: createRecommendedConfig(plugin),
strict: createStrictConfig(plugin),
},
} satisfies TSESLint.FlatConfig.Plugin;
} satisfies ESLint.Plugin;

export default rxjsX;
16 changes: 11 additions & 5 deletions src/utils/rule-creator.ts
Original file line number Diff line number Diff line change
@@ -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<unknown[]>;
export interface RxjsXRuleDocs<Options extends readonly unknown[], Desc extends string> {
description: Desc;
recommended?: TSESLint.RuleRecommendation | TSESLint.RuleRecommendationAcrossConfigs<Options>;
requiresTypeChecking?: boolean;
}

const REPO_URL = 'https://github.com/JasonWeinzierl/eslint-plugin-rxjs-x';

export const ruleCreator = ESLintUtils.RuleCreator<RxjsXRuleDocs>(
export const ruleCreator = ESLintUtils.RuleCreator<RxjsXRuleDocs<unknown[], string>>(
(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<Options, Desc>,
>({ meta, name, ...rule }: Readonly<ESLintUtils.RuleWithMetaAndName<Options, MessageIds, Docs>>) => TSESLint.RuleModule<MessageIds, Options, Docs>;
17 changes: 11 additions & 6 deletions tests/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]]);
}
});
});
Loading