@ethlete/eslint-plugin 0.1.0-next.0 → 0.1.0-next.10

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/package.json +6 -5
  3. package/src/configs/recommended.js +414 -0
  4. package/src/index.js +138 -0
  5. package/src/rules/angular-decorator-property-order.js +205 -0
  6. package/src/rules/class-constant-property.js +365 -0
  7. package/src/rules/class-member-order.js +456 -0
  8. package/src/rules/enforce-routing-view-naming.js +84 -0
  9. package/src/rules/guard-return-newline.js +81 -0
  10. package/src/rules/inject-member-accessibility.js +93 -0
  11. package/src/rules/internals/angular-member-visibility.js +233 -0
  12. package/src/rules/internals/implemented-contract-members.js +294 -0
  13. package/src/rules/no-angular-router-api.js +158 -0
  14. package/src/rules/no-angular-seo-services.js +82 -0
  15. package/src/rules/no-direct-dom-manipulation.js +160 -0
  16. package/src/rules/no-document-cookie.js +55 -0
  17. package/src/rules/no-dom-query.js +81 -0
  18. package/src/rules/no-empty-angular-metadata-arrays.js +104 -0
  19. package/src/rules/no-empty-newlines-between-imports.js +45 -0
  20. package/src/rules/no-inject-chain.js +44 -0
  21. package/src/rules/no-leading-underscore-class-member.js +120 -0
  22. package/src/rules/no-legacy-angular-decorators.js +405 -0
  23. package/src/rules/no-locale-id.js +57 -0
  24. package/src/rules/no-member-alias.js +151 -0
  25. package/src/rules/no-native-observers.js +102 -0
  26. package/src/rules/no-pipe-logic.js +76 -0
  27. package/src/rules/no-public-property.js +85 -0
  28. package/src/rules/no-readonly-signal.js +119 -0
  29. package/src/rules/no-redundant-internal.js +94 -0
  30. package/src/rules/no-redundant-on-push-change-detection.js +237 -0
  31. package/src/rules/no-rxjs-in-effect.js +88 -0
  32. package/src/rules/no-screaming-case-local.js +113 -0
  33. package/src/rules/no-standalone-flag.js +95 -0
  34. package/src/rules/no-subscribe-in-pipe.js +89 -0
  35. package/src/rules/no-subscribe-with-body.js +95 -0
  36. package/src/rules/no-trivial-return-type.js +104 -0
  37. package/src/rules/no-trivial-wrapper-method.js +120 -0
  38. package/src/rules/no-type-only-import.js +79 -0
  39. package/src/rules/no-typed-injected-element-ref.js +88 -0
  40. package/src/rules/no-unused-class-member.js +209 -0
  41. package/src/rules/no-window-location.js +109 -0
  42. package/src/rules/prefer-clone-equal.js +112 -0
  43. package/src/rules/prefer-concise-angular-host-directives.js +225 -0
  44. package/src/rules/prefer-concise-angular-style-metadata.js +108 -0
  45. package/src/rules/prefer-element-dimensions.js +128 -0
  46. package/src/rules/prefer-linked-signal.js +124 -0
  47. package/src/rules/prefer-match-media.js +101 -0
  48. package/src/rules/prefer-rxjs-timer.js +82 -0
  49. package/src/rules/prefer-scroll-state.js +158 -0
  50. package/src/rules/prefer-viewport-size.js +75 -0
  51. package/src/rules/require-dollar-suffix.js +123 -0
  52. package/src/rules/require-on-push-change-detection.js +320 -0
  53. package/src/rules/require-view-encapsulation-none.js +264 -0
  54. package/src/rules/template-member-accessibility.js +204 -0
@@ -0,0 +1,237 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallow explicit `changeDetection: ChangeDetectionStrategy.OnPush` on
6
+ * `@Component` decorators. Since Angular 22, OnPush is the default change
7
+ * detection strategy, so declaring it is redundant noise.
8
+ *
9
+ * The rule is version-aware: it only activates when the workspace's installed
10
+ * Angular is v22 or newer (or when the version cannot be determined). On older
11
+ * Angular versions — where OnPush is NOT the default — it stays silent so that
12
+ * removing it would not change behaviour.
13
+ *
14
+ * BAD (Angular >= 22):
15
+ * @Component({ selector: 'et-x', template: '', changeDetection: ChangeDetectionStrategy.OnPush })
16
+ *
17
+ * GOOD (Angular >= 22):
18
+ * @Component({ selector: 'et-x', template: '' })
19
+ */
20
+
21
+ const REQUIRED_MAJOR = 22;
22
+
23
+ /** @type {number | null | undefined} */
24
+ let cachedAngularMajor;
25
+
26
+ /**
27
+ * Detect the workspace's installed Angular major version. Cached across files.
28
+ *
29
+ * @returns {number | null} major version, or null when it cannot be determined
30
+ */
31
+ const detectAngularMajor = () => {
32
+ if (cachedAngularMajor !== undefined) return cachedAngularMajor;
33
+
34
+ try {
35
+ const pkg = require('@angular/core/package.json');
36
+ const major = Number.parseInt(String(pkg.version).split('.')[0], 10);
37
+ cachedAngularMajor = Number.isNaN(major) ? null : major;
38
+ } catch {
39
+ cachedAngularMajor = null;
40
+ }
41
+
42
+ return cachedAngularMajor;
43
+ };
44
+
45
+ /**
46
+ * Resolve the Angular major version to gate on. An explicit
47
+ * `settings.ethlete.angularMajor` wins over auto-detection — handy for
48
+ * pinning behaviour and for tests.
49
+ *
50
+ * @param {import('eslint').Rule.RuleContext} context
51
+ * @returns {number | null}
52
+ */
53
+ const getAngularMajor = (context) => {
54
+ const settings = /** @type {any} */ (context.settings);
55
+ const override = settings?.ethlete?.angularMajor;
56
+ if (typeof override === 'number') return override;
57
+
58
+ return detectAngularMajor();
59
+ };
60
+
61
+ /**
62
+ * @param {import('estree').Property['key']} key
63
+ */
64
+ const getPropertyName = (key) => {
65
+ if (key.type === 'Identifier') return key.name;
66
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
67
+ return null;
68
+ };
69
+
70
+ /**
71
+ * @param {import('eslint').Rule.Node} node
72
+ */
73
+ const isComponentDecorator = (node) => {
74
+ const decorator = /** @type {any} */ (node);
75
+ if (decorator.type !== 'Decorator') return false;
76
+
77
+ const expression = decorator.expression;
78
+ if (expression.type === 'CallExpression') {
79
+ return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
80
+ }
81
+
82
+ return expression.type === 'Identifier' && expression.name === 'Component';
83
+ };
84
+
85
+ /**
86
+ * @param {any} value
87
+ */
88
+ const isOnPushValue = (value) =>
89
+ value.type === 'MemberExpression' &&
90
+ value.object.type === 'Identifier' &&
91
+ value.object.name === 'ChangeDetectionStrategy' &&
92
+ value.property.type === 'Identifier' &&
93
+ value.property.name === 'OnPush';
94
+
95
+ /**
96
+ * Range that removes a property together with the comma that joins it to its
97
+ * siblings (leading comma when it is the last property, otherwise trailing).
98
+ *
99
+ * @param {import('eslint').SourceCode} sourceCode
100
+ * @param {any} metadata
101
+ * @param {any} property
102
+ */
103
+ const getPropertyRemovalRange = (sourceCode, metadata, property) => {
104
+ const properties = metadata.properties.filter((entry) => entry.type === 'Property');
105
+ const index = properties.indexOf(property);
106
+ const openingBrace = sourceCode.getFirstToken(metadata);
107
+ const closingBrace = sourceCode.getLastToken(metadata);
108
+
109
+ if (!openingBrace || !closingBrace) return property.range;
110
+ if (properties.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
111
+ if (index < properties.length - 1) return [property.range[0], properties[index + 1].range[0]];
112
+
113
+ return [properties[index - 1].range[1], property.range[1]];
114
+ };
115
+
116
+ /**
117
+ * Range that removes a single named import specifier together with its comma.
118
+ *
119
+ * @param {import('eslint').SourceCode} sourceCode
120
+ * @param {any} importNode
121
+ * @param {any} specifier
122
+ */
123
+ const getSpecifierRemovalRange = (sourceCode, importNode, specifier) => {
124
+ const named = importNode.specifiers.filter((entry) => entry.type === 'ImportSpecifier');
125
+ const index = named.indexOf(specifier);
126
+
127
+ // Sole specifier of the whole import — drop the entire declaration.
128
+ if (importNode.specifiers.length === 1) return importNode.range;
129
+ if (index < named.length - 1) return [specifier.range[0], named[index + 1].range[0]];
130
+
131
+ return [named[index - 1].range[1], specifier.range[1]];
132
+ };
133
+
134
+ /**
135
+ * True when every reference of the `ChangeDetectionStrategy` binding is a
136
+ * `changeDetection: ChangeDetectionStrategy.OnPush` property value — i.e.
137
+ * removing those properties leaves the import unused and safe to drop.
138
+ *
139
+ * @param {import('eslint').Scope.Reference} reference
140
+ */
141
+ const isOnPushChangeDetectionReference = (reference) => {
142
+ const identifier = /** @type {any} */ (reference.identifier);
143
+ const member = identifier.parent;
144
+ if (!member || member.type !== 'MemberExpression' || member.object !== identifier) return false;
145
+ if (member.property.type !== 'Identifier' || member.property.name !== 'OnPush') return false;
146
+
147
+ const property = member.parent;
148
+ if (!property || property.type !== 'Property' || property.value !== member) return false;
149
+
150
+ return getPropertyName(property.key) === 'changeDetection';
151
+ };
152
+
153
+ /** @type {import('eslint').Rule.RuleModule} */
154
+ const noRedundantOnPushChangeDetection = {
155
+ meta: {
156
+ type: 'suggestion',
157
+ docs: {
158
+ description:
159
+ 'Disallow redundant `changeDetection: ChangeDetectionStrategy.OnPush`. OnPush is the default since Angular 22.',
160
+ },
161
+ fixable: 'code',
162
+ schema: [],
163
+ messages: {
164
+ redundant:
165
+ '`changeDetection: ChangeDetectionStrategy.OnPush` is redundant. OnPush is the default change detection strategy since Angular 22.',
166
+ redundantImport:
167
+ 'Remove the now-unused `ChangeDetectionStrategy` import. OnPush is the default change detection strategy since Angular 22.',
168
+ },
169
+ },
170
+ create(context) {
171
+ // Only enforce on Angular >= 22, where OnPush is the default. Stay silent on
172
+ // older versions (removing it there would change behaviour) — but enforce
173
+ // when the version is unknown, since this workspace targets modern Angular.
174
+ const angularMajor = getAngularMajor(context);
175
+ if (angularMajor !== null && angularMajor < REQUIRED_MAJOR) return {};
176
+
177
+ const sourceCode = context.sourceCode;
178
+ /** @type {any} */
179
+ let changeDetectionSpecifier = null;
180
+
181
+ return {
182
+ Decorator(node) {
183
+ if (!isComponentDecorator(node)) return;
184
+
185
+ const expression = /** @type {any} */ (node).expression;
186
+ if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
187
+
188
+ const metadata = expression.arguments[0];
189
+ if (!metadata || metadata.type !== 'ObjectExpression') return;
190
+
191
+ const property = metadata.properties.find(
192
+ (entry) => entry.type === 'Property' && getPropertyName(entry.key) === 'changeDetection',
193
+ );
194
+ if (!property || !isOnPushValue(property.value)) return;
195
+
196
+ context.report({
197
+ node: property,
198
+ messageId: 'redundant',
199
+ fix: (fixer) => fixer.replaceTextRange(getPropertyRemovalRange(sourceCode, metadata, property), ''),
200
+ });
201
+ },
202
+
203
+ ImportDeclaration(node) {
204
+ if (node.source.value !== '@angular/core') return;
205
+
206
+ for (const specifier of node.specifiers) {
207
+ if (
208
+ specifier.type === 'ImportSpecifier' &&
209
+ specifier.imported.type === 'Identifier' &&
210
+ specifier.imported.name === 'ChangeDetectionStrategy'
211
+ ) {
212
+ changeDetectionSpecifier = specifier;
213
+ return;
214
+ }
215
+ }
216
+ },
217
+
218
+ 'Program:exit'() {
219
+ if (!changeDetectionSpecifier) return;
220
+
221
+ const [variable] = sourceCode.getDeclaredVariables(changeDetectionSpecifier);
222
+ if (!variable || variable.references.length === 0) return;
223
+ if (!variable.references.every(isOnPushChangeDetectionReference)) return;
224
+
225
+ const importNode = changeDetectionSpecifier.parent;
226
+ context.report({
227
+ node: changeDetectionSpecifier,
228
+ messageId: 'redundantImport',
229
+ fix: (fixer) =>
230
+ fixer.replaceTextRange(getSpecifierRemovalRange(sourceCode, importNode, changeDetectionSpecifier), ''),
231
+ });
232
+ },
233
+ };
234
+ },
235
+ };
236
+
237
+ module.exports = noRedundantOnPushChangeDetection;
@@ -0,0 +1,88 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows calling .subscribe() inside Angular's effect() or computed() callbacks.
6
+ *
7
+ * This creates unmanaged subscriptions and causes memory leaks because neither
8
+ * effect() nor computed() provides a lifecycle-tied cleanup mechanism for subscriptions.
9
+ *
10
+ * BAD:
11
+ * effect(() => {
12
+ * const page = this.page();
13
+ * this.api.fetch(page).subscribe(res => this.data.set(res)); // ❌
14
+ * });
15
+ *
16
+ * GOOD:
17
+ * toObservable(this.page)
18
+ * .pipe(
19
+ * switchMap(page => this.api.fetch(page)),
20
+ * takeUntilDestroyed(),
21
+ * )
22
+ * .subscribe();
23
+ */
24
+
25
+ /** @type {import('eslint').Rule.RuleModule} */
26
+ const noRxjsInEffect = {
27
+ meta: {
28
+ type: 'problem',
29
+ docs: {
30
+ description: 'Disallow .subscribe() inside effect() or computed() callbacks.',
31
+ recommended: true,
32
+ },
33
+ messages: {
34
+ noSubscribeInEffect:
35
+ 'Avoid calling .subscribe() inside {{context}}(). Use toObservable() + switchMap() + takeUntilDestroyed() instead.',
36
+ },
37
+ schema: [],
38
+ },
39
+ create(context) {
40
+ /**
41
+ * Returns the name of the wrapping reactive context ('effect' | 'computed') if
42
+ * the given node is inside one, or null otherwise.
43
+ * @param {import('eslint').Rule.Node} node
44
+ * @returns {string | null}
45
+ */
46
+ const getReactiveContext = (node) => {
47
+ let crossedFunctionBoundary = false;
48
+ let current = node.parent;
49
+ while (current) {
50
+ if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
51
+ crossedFunctionBoundary = true;
52
+ }
53
+ if (
54
+ crossedFunctionBoundary &&
55
+ current.type === 'CallExpression' &&
56
+ current.callee.type === 'Identifier' &&
57
+ (current.callee.name === 'effect' || current.callee.name === 'computed')
58
+ ) {
59
+ return current.callee.name;
60
+ }
61
+ current = current.parent;
62
+ }
63
+ return null;
64
+ };
65
+
66
+ return {
67
+ CallExpression(node) {
68
+ const { callee } = node;
69
+ if (
70
+ callee.type === 'MemberExpression' &&
71
+ callee.property.type === 'Identifier' &&
72
+ callee.property.name === 'subscribe'
73
+ ) {
74
+ const reactiveContext = getReactiveContext(node);
75
+ if (reactiveContext) {
76
+ context.report({
77
+ node,
78
+ messageId: 'noSubscribeInEffect',
79
+ data: { context: reactiveContext },
80
+ });
81
+ }
82
+ }
83
+ },
84
+ };
85
+ },
86
+ };
87
+
88
+ module.exports = noRxjsInEffect;
@@ -0,0 +1,113 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows SCREAMING_CASE variable names inside function bodies.
6
+ *
7
+ * At module level, SCREAMING_CASE is fine for true constants. Inside functions,
8
+ * all local variables must be camelCase — there is no meaningful distinction
9
+ * between a "constant" and a regular local variable inside a function body.
10
+ *
11
+ * Also disallows SCREAMING_CASE names for function-valued variables at any scope,
12
+ * since a function is never a static constant.
13
+ *
14
+ * BAD:
15
+ * const myFn = () => {
16
+ * const RANDOM = Math.random(); // ❌
17
+ * const MY_LOCAL_FLAG = true; // ❌
18
+ * };
19
+ *
20
+ * const MY_FUNCTION = () => {}; // ❌ function name must be camelCase
21
+ *
22
+ * GOOD:
23
+ * const MY_GLOBAL = 'value'; // ✅ module level
24
+ *
25
+ * const myFn = () => {
26
+ * const random = Math.random(); // ✅
27
+ * const myLocalFlag = true; // ✅
28
+ * };
29
+ */
30
+
31
+ /** Matches ALL-CAPS identifiers: at least one char, all uppercase letters, digits, underscores */
32
+ const SCREAMING_CASE_RE = /^[A-Z][A-Z0-9_]*$/;
33
+
34
+ /**
35
+ * Returns true if the given node is inside a function body (arrow or regular).
36
+ * @param {import('eslint').Rule.Node} node
37
+ */
38
+ const isInsideFunction = (node) => {
39
+ let current = node.parent;
40
+ while (current) {
41
+ if (
42
+ current.type === 'ArrowFunctionExpression' ||
43
+ current.type === 'FunctionExpression' ||
44
+ current.type === 'FunctionDeclaration'
45
+ ) {
46
+ return true;
47
+ }
48
+ current = current.parent;
49
+ }
50
+ return false;
51
+ };
52
+
53
+ /** @type {import('eslint').Rule.RuleModule} */
54
+ const noScreamingCaseLocal = {
55
+ meta: {
56
+ type: 'suggestion',
57
+ docs: {
58
+ description:
59
+ 'Disallow SCREAMING_CASE variable names inside function bodies. Use camelCase for all local variables.',
60
+ recommended: true,
61
+ },
62
+ messages: {
63
+ noScreamingCaseLocal: "'{{name}}' uses SCREAMING_CASE inside a function. Use camelCase for local variables.",
64
+ noScreamingCaseFunctionName:
65
+ "'{{name}}' uses SCREAMING_CASE for a function name. Use camelCase for function names.",
66
+ },
67
+ schema: [],
68
+ },
69
+ create(context) {
70
+ return {
71
+ VariableDeclarator(node) {
72
+ // Only flag simple identifier bindings (not destructuring)
73
+ if (node.id.type !== 'Identifier') return;
74
+
75
+ const { name } = node.id;
76
+
77
+ // Must match the SCREAMING_CASE pattern
78
+ if (!SCREAMING_CASE_RE.test(name)) return;
79
+
80
+ // Only flag inside function bodies
81
+ if (!isInsideFunction(node)) return;
82
+
83
+ context.report({
84
+ node: node.id,
85
+ messageId: 'noScreamingCaseLocal',
86
+ data: { name },
87
+ });
88
+ },
89
+
90
+ // Catch SCREAMING_CASE names for function-valued variables at any scope:
91
+ // const MY_FUNCTION = () => {} ❌
92
+ // const MY_FUNCTION = function() {} ❌
93
+ /** @param {import('estree').VariableDeclarator & import('eslint').Rule.NodeParentExtension} node */
94
+ 'VariableDeclarator[init.type="ArrowFunctionExpression"], VariableDeclarator[init.type="FunctionExpression"]'(
95
+ node,
96
+ ) {
97
+ if (node.id.type !== 'Identifier') return;
98
+
99
+ const { name } = /** @type {import('estree').Identifier} */ (node.id);
100
+
101
+ if (!SCREAMING_CASE_RE.test(name)) return;
102
+
103
+ context.report({
104
+ node: /** @type {import('eslint').Rule.Node} */ (node.id),
105
+ messageId: 'noScreamingCaseFunctionName',
106
+ data: { name },
107
+ });
108
+ },
109
+ };
110
+ },
111
+ };
112
+
113
+ module.exports = noScreamingCaseLocal;
@@ -0,0 +1,95 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /** @typedef {'Component' | 'Directive' | 'Pipe'} TDecoratorName */
5
+
6
+ /**
7
+ * @param {import('estree').Property['key']} key
8
+ */
9
+ const getPropertyName = (key) => {
10
+ if (key.type === 'Identifier') return key.name;
11
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
12
+ return null;
13
+ };
14
+
15
+ /**
16
+ * @param {import('eslint').Rule.Node} node
17
+ * @returns {TDecoratorName | null}
18
+ */
19
+ const getDecoratorName = (node) => {
20
+ const decorator = /** @type {any} */ (node);
21
+ if (decorator.type !== 'Decorator') return null;
22
+
23
+ const expression = decorator.expression;
24
+ if (expression.type === 'CallExpression') {
25
+ return expression.callee.type === 'Identifier' ? expression.callee.name : null;
26
+ }
27
+
28
+ return expression.type === 'Identifier' ? expression.name : null;
29
+ };
30
+
31
+ /**
32
+ * @param {import('eslint').SourceCode} sourceCode
33
+ * @param {any} metadata
34
+ * @param {any} property
35
+ */
36
+ const getRemovalRange = (sourceCode, metadata, property) => {
37
+ const properties = metadata.properties.filter((entry) => entry.type === 'Property');
38
+ const propertyIndex = properties.indexOf(property);
39
+ const openingBrace = sourceCode.getFirstToken(metadata);
40
+ const closingBrace = sourceCode.getLastToken(metadata);
41
+
42
+ if (!openingBrace || !closingBrace) return property.range;
43
+ if (properties.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
44
+ if (propertyIndex < properties.length - 1) return [property.range[0], properties[propertyIndex + 1].range[0]];
45
+
46
+ return [properties[propertyIndex - 1].range[1], property.range[1]];
47
+ };
48
+
49
+ /** @type {import('eslint').Rule.RuleModule} */
50
+ const noStandaloneFlag = {
51
+ meta: {
52
+ type: 'suggestion',
53
+ docs: {
54
+ description:
55
+ 'Disallow standalone metadata on Angular decorators because standalone is the default and should be omitted.',
56
+ },
57
+ fixable: 'code',
58
+ schema: [],
59
+ messages: {
60
+ noStandalone: 'Remove standalone from Angular metadata. Standalone is implicit and should not be declared.',
61
+ },
62
+ },
63
+ create(context) {
64
+ const sourceCode = context.sourceCode;
65
+
66
+ return {
67
+ Decorator(node) {
68
+ const decoratorName = getDecoratorName(node);
69
+ if (decoratorName !== 'Component' && decoratorName !== 'Directive' && decoratorName !== 'Pipe') return;
70
+
71
+ const decorator = /** @type {any} */ (node);
72
+ const expression = decorator.expression;
73
+ if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
74
+
75
+ const metadata = expression.arguments[0];
76
+ if (!metadata || metadata.type !== 'ObjectExpression') return;
77
+
78
+ for (const property of metadata.properties) {
79
+ if (property.type !== 'Property') continue;
80
+ if (getPropertyName(property.key) !== 'standalone') continue;
81
+
82
+ context.report({
83
+ node: property,
84
+ messageId: 'noStandalone',
85
+ fix(fixer) {
86
+ return fixer.replaceTextRange(getRemovalRange(sourceCode, metadata, property), '');
87
+ },
88
+ });
89
+ }
90
+ },
91
+ };
92
+ },
93
+ };
94
+
95
+ module.exports = noStandaloneFlag;
@@ -0,0 +1,89 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows calling .subscribe() inside a .pipe() callback.
6
+ *
7
+ * BAD:
8
+ * obs$.pipe(
9
+ * tap(() => {
10
+ * other$.subscribe(); // ❌ creates an unmanaged subscription
11
+ * }),
12
+ * ).subscribe();
13
+ *
14
+ * GOOD:
15
+ * obs$.pipe(
16
+ * switchMap(() => other$), // ✅ compose instead
17
+ * ).subscribe();
18
+ *
19
+ * obs$.pipe(...operators).subscribe(); // ✅ subscribe on the result of pipe
20
+ */
21
+
22
+ /** @type {import('eslint').Rule.RuleModule} */
23
+ const noSubscribeInPipe = {
24
+ meta: {
25
+ type: 'problem',
26
+ docs: {
27
+ description: 'Disallow .subscribe() calls inside .pipe() callbacks.',
28
+ recommended: true,
29
+ },
30
+ messages: {
31
+ noSubscribeInPipe:
32
+ 'Avoid calling .subscribe() inside a .pipe() callback. Compose with operators (switchMap, mergeMap, etc.) instead.',
33
+ },
34
+ schema: [],
35
+ },
36
+ create(context) {
37
+ /**
38
+ * Returns true if the given node is nested inside a callback function that
39
+ * is itself (directly or indirectly) an argument to a .pipe() call.
40
+ * @param {import('eslint').Rule.Node} node
41
+ */
42
+ const isInsidePipeCallback = (node) => {
43
+ let crossedFunctionBoundary = false;
44
+ let current = node.parent;
45
+ while (current) {
46
+ // A `new Observable(factory)` is its own subscription boundary — subscribing
47
+ // inside it is intentional (inner subscription management) and should not
48
+ // be flagged even if the Observable itself is passed into a .pipe() operator.
49
+ if (
50
+ current.type === 'NewExpression' &&
51
+ current.callee.type === 'Identifier' &&
52
+ current.callee.name === 'Observable'
53
+ ) {
54
+ return false;
55
+ }
56
+ if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
57
+ crossedFunctionBoundary = true;
58
+ }
59
+ if (
60
+ crossedFunctionBoundary &&
61
+ current.type === 'CallExpression' &&
62
+ current.callee.type === 'MemberExpression' &&
63
+ current.callee.property.type === 'Identifier' &&
64
+ current.callee.property.name === 'pipe'
65
+ ) {
66
+ return true;
67
+ }
68
+ current = current.parent;
69
+ }
70
+ return false;
71
+ };
72
+
73
+ return {
74
+ CallExpression(node) {
75
+ const { callee } = node;
76
+ if (
77
+ callee.type === 'MemberExpression' &&
78
+ callee.property.type === 'Identifier' &&
79
+ callee.property.name === 'subscribe' &&
80
+ isInsidePipeCallback(node)
81
+ ) {
82
+ context.report({ node, messageId: 'noSubscribeInPipe' });
83
+ }
84
+ },
85
+ };
86
+ },
87
+ };
88
+
89
+ module.exports = noSubscribeInPipe;