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

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 (41) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/package.json +3 -4
  3. package/src/configs/recommended.js +362 -0
  4. package/src/index.js +116 -0
  5. package/src/rules/angular-decorator-property-order.js +217 -0
  6. package/src/rules/enforce-routing-view-naming.js +84 -0
  7. package/src/rules/guard-return-newline.js +81 -0
  8. package/src/rules/inject-member-accessibility.js +286 -0
  9. package/src/rules/no-angular-router-api.js +158 -0
  10. package/src/rules/no-angular-seo-services.js +82 -0
  11. package/src/rules/no-direct-dom-manipulation.js +160 -0
  12. package/src/rules/no-document-cookie.js +55 -0
  13. package/src/rules/no-dom-query.js +81 -0
  14. package/src/rules/no-inject-chain.js +44 -0
  15. package/src/rules/no-legacy-angular-decorators.js +210 -0
  16. package/src/rules/no-locale-id.js +57 -0
  17. package/src/rules/no-member-alias.js +151 -0
  18. package/src/rules/no-native-observers.js +102 -0
  19. package/src/rules/no-pipe-logic.js +76 -0
  20. package/src/rules/no-public-property.js +39 -0
  21. package/src/rules/no-readonly-signal.js +119 -0
  22. package/src/rules/no-rxjs-in-effect.js +88 -0
  23. package/src/rules/no-screaming-case-local.js +113 -0
  24. package/src/rules/no-subscribe-in-pipe.js +89 -0
  25. package/src/rules/no-subscribe-with-body.js +95 -0
  26. package/src/rules/no-trivial-return-type.js +104 -0
  27. package/src/rules/no-trivial-wrapper-method.js +120 -0
  28. package/src/rules/no-type-only-import.js +79 -0
  29. package/src/rules/no-unused-class-member.js +209 -0
  30. package/src/rules/no-window-location.js +109 -0
  31. package/src/rules/prefer-clone-equal.js +112 -0
  32. package/src/rules/prefer-concise-angular-host-directives.js +225 -0
  33. package/src/rules/prefer-concise-angular-style-metadata.js +108 -0
  34. package/src/rules/prefer-element-dimensions.js +128 -0
  35. package/src/rules/prefer-linked-signal.js +124 -0
  36. package/src/rules/prefer-match-media.js +101 -0
  37. package/src/rules/prefer-rxjs-timer.js +82 -0
  38. package/src/rules/prefer-scroll-state.js +158 -0
  39. package/src/rules/prefer-viewport-size.js +75 -0
  40. package/src/rules/require-dollar-suffix.js +123 -0
  41. package/src/rules/require-view-encapsulation-none.js +101 -0
@@ -0,0 +1,210 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows legacy Angular decorators in favour of their modern signal-based
6
+ * equivalents and the `host: {}` shorthand.
7
+ *
8
+ * Signal-based replacements:
9
+ * ❌ @Input() → ✅ input()
10
+ * ❌ @Output() → ✅ output()
11
+ * ❌ @ViewChild() → ✅ viewChild()
12
+ * ❌ @ViewChildren() → ✅ viewChildren()
13
+ * ❌ @ContentChild() → ✅ contentChild()
14
+ * ❌ @ContentChildren() → ✅ contentChildren()
15
+ *
16
+ * Pair detection — when @Input() x and @Output() xChange are declared in the
17
+ * same class they form a two-way binding pattern that must be replaced by
18
+ * model():
19
+ * ❌ @Input() value; @Output() valueChange = new EventEmitter();
20
+ * → ✅ value = model();
21
+ *
22
+ * Host bindings — use the host: {} property instead:
23
+ * ❌ @HostBinding('class.active') isActive = false;
24
+ * ❌ @HostListener('click') onClick() {}
25
+ * → ✅ host: { '[class.active]': 'isActive', '(click)': 'onClick()' }
26
+ */
27
+
28
+ /**
29
+ * Returns the identifier name of a decorator.
30
+ * Works for both `@Foo` (Identifier) and `@Foo(...)` (CallExpression).
31
+ *
32
+ * @param {any} decorator
33
+ * @returns {string | null}
34
+ */
35
+ const getDecoratorName = (decorator) => {
36
+ const expr = decorator.expression;
37
+ if (expr.type === 'Identifier') return expr.name;
38
+ if (expr.type === 'CallExpression' && expr.callee.type === 'Identifier') return expr.callee.name;
39
+ return null;
40
+ };
41
+
42
+ /**
43
+ * Returns the static string key of a class member, or null for computed keys.
44
+ *
45
+ * @param {any} node - PropertyDefinition or MethodDefinition
46
+ * @returns {string | null}
47
+ */
48
+ const getMemberName = (node) => {
49
+ if (!node.computed && node.key.type === 'Identifier') return node.key.name;
50
+ if (!node.computed && node.key.type === 'Literal') return String(node.key.value);
51
+ return null;
52
+ };
53
+
54
+ /** @type {import('eslint').Rule.RuleModule} */
55
+ const rule = {
56
+ meta: {
57
+ type: 'suggestion',
58
+ docs: {
59
+ description:
60
+ 'Disallow legacy Angular decorators (@Input, @Output, @ViewChild, etc.) in favour of signal-based APIs and host: {} bindings.',
61
+ },
62
+ schema: [],
63
+ messages: {
64
+ useInput: 'Use the input() signal function instead of @Input().',
65
+ useOutput: 'Use the output() signal function instead of @Output().',
66
+ useViewChild: 'Use the viewChild() signal function instead of @ViewChild().',
67
+ useViewChildren: 'Use the viewChildren() signal function instead of @ViewChildren().',
68
+ useContentChild: 'Use the contentChild() signal function instead of @ContentChild().',
69
+ useContentChildren: 'Use the contentChildren() signal function instead of @ContentChildren().',
70
+ useHostBinding: 'Use the host: {} property in @Component/@Directive instead of @HostBinding().',
71
+ useHostListener: 'Use the host: {} property in @Component/@Directive instead of @HostListener().',
72
+ useModel:
73
+ "Use model() instead of the @Input()/@Output() two-way binding pattern ('{{inputName}}' / '{{outputName}}').",
74
+ },
75
+ },
76
+ create(context) {
77
+ /**
78
+ * Stack of per-class contexts used to detect two-way binding pairs.
79
+ * We defer @Input / @Output reporting to class exit so we can check whether
80
+ * a `xChange` output has a matching `x` input (→ model()).
81
+ *
82
+ * @type {Array<{ inputs: Map<string, any>; outputs: Map<string, any> }>}
83
+ */
84
+ const classStack = [];
85
+
86
+ const currentClass = () => classStack[classStack.length - 1] ?? null;
87
+
88
+ const pushClass = () => classStack.push({ inputs: new Map(), outputs: new Map() });
89
+
90
+ const popClass = () => {
91
+ const ctx = classStack.pop();
92
+ if (!ctx) return;
93
+
94
+ const reportedInputNames = new Set();
95
+ const reportedOutputNames = new Set();
96
+
97
+ // Detect @Input() x + @Output() xChange pairs → suggest model()
98
+ for (const [outputName, outputDecoratorNode] of ctx.outputs) {
99
+ if (!outputName.endsWith('Change')) continue;
100
+ const inputName = outputName.slice(0, -'Change'.length);
101
+ if (!ctx.inputs.has(inputName)) continue;
102
+
103
+ reportedInputNames.add(inputName);
104
+ reportedOutputNames.add(outputName);
105
+
106
+ context.report({
107
+ node: ctx.inputs.get(inputName),
108
+ messageId: 'useModel',
109
+ data: { inputName, outputName },
110
+ });
111
+ context.report({
112
+ node: outputDecoratorNode,
113
+ messageId: 'useModel',
114
+ data: { inputName, outputName },
115
+ });
116
+ }
117
+
118
+ // Report remaining @Input() decorators that are not part of a pair
119
+ for (const [name, decoratorNode] of ctx.inputs) {
120
+ if (!reportedInputNames.has(name)) {
121
+ context.report({ node: decoratorNode, messageId: 'useInput' });
122
+ }
123
+ }
124
+
125
+ // Report remaining @Output() decorators that are not part of a pair
126
+ for (const [name, decoratorNode] of ctx.outputs) {
127
+ if (!reportedOutputNames.has(name)) {
128
+ context.report({ node: decoratorNode, messageId: 'useOutput' });
129
+ }
130
+ }
131
+ };
132
+
133
+ return {
134
+ ClassDeclaration: pushClass,
135
+ ClassExpression: pushClass,
136
+ 'ClassDeclaration:exit': popClass,
137
+ 'ClassExpression:exit': popClass,
138
+
139
+ Decorator(node) {
140
+ const anyNode = /** @type {any} */ (node);
141
+ const decoratorName = getDecoratorName(anyNode);
142
+ if (!decoratorName) return;
143
+
144
+ // Only act on decorators attached to class members, not to the class itself
145
+ const parent = anyNode.parent;
146
+ if (!parent) return;
147
+ if (parent.type !== 'PropertyDefinition' && parent.type !== 'MethodDefinition') return;
148
+
149
+ const ctx = currentClass();
150
+
151
+ switch (decoratorName) {
152
+ case 'Input': {
153
+ if (!ctx) {
154
+ context.report({ node, messageId: 'useInput' });
155
+ break;
156
+ }
157
+ const memberName = getMemberName(parent);
158
+ if (memberName !== null) {
159
+ ctx.inputs.set(memberName, node);
160
+ } else {
161
+ // Computed key — cannot be part of a two-way binding pair, report immediately
162
+ context.report({ node, messageId: 'useInput' });
163
+ }
164
+ break;
165
+ }
166
+
167
+ case 'Output': {
168
+ if (!ctx) {
169
+ context.report({ node, messageId: 'useOutput' });
170
+ break;
171
+ }
172
+ const memberName = getMemberName(parent);
173
+ if (memberName !== null) {
174
+ ctx.outputs.set(memberName, node);
175
+ } else {
176
+ context.report({ node, messageId: 'useOutput' });
177
+ }
178
+ break;
179
+ }
180
+
181
+ case 'ViewChild':
182
+ context.report({ node, messageId: 'useViewChild' });
183
+ break;
184
+
185
+ case 'ViewChildren':
186
+ context.report({ node, messageId: 'useViewChildren' });
187
+ break;
188
+
189
+ case 'ContentChild':
190
+ context.report({ node, messageId: 'useContentChild' });
191
+ break;
192
+
193
+ case 'ContentChildren':
194
+ context.report({ node, messageId: 'useContentChildren' });
195
+ break;
196
+
197
+ case 'HostBinding':
198
+ context.report({ node, messageId: 'useHostBinding' });
199
+ break;
200
+
201
+ case 'HostListener':
202
+ context.report({ node, messageId: 'useHostListener' });
203
+ break;
204
+ }
205
+ },
206
+ };
207
+ },
208
+ };
209
+
210
+ module.exports = rule;
@@ -0,0 +1,57 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows injecting Angular's LOCALE_ID token directly.
6
+ *
7
+ * @ethlete/core provides injectLocale() which wraps LOCALE_ID in a signal
8
+ * and exposes helper utilities for locale-aware formatting.
9
+ *
10
+ * BAD:
11
+ * inject(LOCALE_ID)
12
+ * import { LOCALE_ID } from '@angular/core';
13
+ *
14
+ * GOOD:
15
+ * import { injectLocale } from '@ethlete/core';
16
+ * const locale = injectLocale();
17
+ */
18
+
19
+ /** @type {import('eslint').Rule.RuleModule} */
20
+ const noLocaleId = {
21
+ meta: {
22
+ type: 'suggestion',
23
+ docs: {
24
+ description: "Disallow injecting 'LOCALE_ID' directly. Use 'injectLocale()' from '@ethlete/core' instead.",
25
+ recommended: true,
26
+ },
27
+ messages: {
28
+ noLocaleId:
29
+ "Do not inject 'LOCALE_ID' directly. Use 'injectLocale()' from '@ethlete/core' instead — it wraps LOCALE_ID and provides a clean, signal-compatible API.",
30
+ },
31
+ schema: [],
32
+ },
33
+ create(context) {
34
+ return {
35
+ CallExpression(node) {
36
+ const { callee } = node;
37
+ if (callee.type !== 'Identifier' || callee.name !== 'inject') return;
38
+ const arg = node.arguments[0];
39
+ if (arg?.type === 'Identifier' && arg.name === 'LOCALE_ID') {
40
+ context.report({ node, messageId: 'noLocaleId' });
41
+ }
42
+ },
43
+
44
+ ImportDeclaration(node) {
45
+ if (node.source.value !== '@angular/core') return;
46
+ for (const specifier of node.specifiers) {
47
+ if (specifier.type === 'ImportSpecifier' && specifier.imported.name === 'LOCALE_ID') {
48
+ context.report({ node, messageId: 'noLocaleId' });
49
+ return;
50
+ }
51
+ }
52
+ },
53
+ };
54
+ },
55
+ };
56
+
57
+ module.exports = noLocaleId;
@@ -0,0 +1,151 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows class members that exist solely as aliases for a nested property or
6
+ * zero-arg method of another class member accessed via `this`.
7
+ *
8
+ * These aliases add an extra layer of indirection without any benefit. Instead,
9
+ * the source member should be widened in accessibility so it can be referenced
10
+ * directly at the call/template site.
11
+ *
12
+ * BAD — property alias:
13
+ * private stackDirective = inject(NotificationStackDirective);
14
+ * protected displayRefs = this.stackDirective.displayRefs; // just an alias
15
+ *
16
+ * BAD — zero-arg method alias:
17
+ * context = inject(SomeContextToken);
18
+ * retry() { this.context.retry(); }
19
+ *
20
+ * GOOD:
21
+ * protected stackDirective = inject(NotificationStackDirective);
22
+ * // use stackDirective.displayRefs directly in the template
23
+ *
24
+ * context = inject(SomeContextToken);
25
+ * // callers use directive.context.retry() directly
26
+ *
27
+ * Property rule fires when the entire initializer is a single `this.a.b`
28
+ * expression — it will not flag deeper chains, computed accesses, or any
29
+ * expression that does more than a plain property read.
30
+ *
31
+ * Method rule fires when a zero-param method body contains exactly one
32
+ * statement that calls `this.a.b()` with zero arguments AND the method name
33
+ * matches the target method name exactly. When the names differ the method is
34
+ * an intentional API rename (e.g. retry → playerResource.reload) and is not
35
+ * flagged.
36
+ */
37
+
38
+ /**
39
+ * Checks whether a method node is a pure zero-arg forwarding alias of the form:
40
+ * method() { this.source.method(); }
41
+ * method() { return this.source.method(); }
42
+ *
43
+ * @param {any} node MethodDefinition
44
+ * @returns {{ sourceName: string, propName: string } | null}
45
+ */
46
+ const getMethodAliasInfo = (node) => {
47
+ if (node.kind === 'constructor' || node.kind === 'get' || node.kind === 'set') return null;
48
+
49
+ const fn = node.value;
50
+ if (!fn || fn.params.length !== 0) return null;
51
+
52
+ const body = fn.body;
53
+ if (!body || body.type !== 'BlockStatement' || body.body.length !== 1) return null;
54
+
55
+ const stmt = body.body[0];
56
+ let callExpr = null;
57
+ if (stmt.type === 'ExpressionStatement') {
58
+ callExpr = stmt.expression;
59
+ } else if (stmt.type === 'ReturnStatement' && stmt.argument) {
60
+ callExpr = stmt.argument;
61
+ }
62
+ if (!callExpr || callExpr.type !== 'CallExpression') return null;
63
+
64
+ // Must have zero arguments
65
+ if (callExpr.arguments.length !== 0) return null;
66
+
67
+ const callee = callExpr.callee;
68
+ // callee must be: this.something.method
69
+ if (callee.type !== 'MemberExpression') return null;
70
+ if (callee.computed) return null;
71
+ if (callee.property.type !== 'Identifier') return null;
72
+
73
+ const obj = callee.object;
74
+ if (obj.type !== 'MemberExpression') return null;
75
+ if (obj.computed) return null;
76
+ if (obj.object.type !== 'ThisExpression') return null;
77
+ if (obj.property.type !== 'Identifier') return null;
78
+
79
+ return { sourceName: obj.property.name, propName: callee.property.name };
80
+ };
81
+
82
+ /** @type {import('eslint').Rule.RuleModule} */
83
+ const noMemberAlias = {
84
+ meta: {
85
+ type: 'suggestion',
86
+ docs: {
87
+ description:
88
+ 'Disallow class members that are pure aliases for a nested property or zero-arg method of another member accessed via `this`.',
89
+ recommended: true,
90
+ },
91
+ messages: {
92
+ noAlias:
93
+ '`{{alias}}` is a pure alias for `this.{{source}}.{{prop}}`. ' +
94
+ 'Remove the alias and widen the accessibility of `{{source}}` so it can be used directly.',
95
+ },
96
+ schema: [],
97
+ },
98
+ create(context) {
99
+ return {
100
+ PropertyDefinition(node) {
101
+ const value = node.value;
102
+ if (!value) return;
103
+
104
+ // Must be: this.something.property
105
+ if (value.type !== 'MemberExpression') return;
106
+ // Outer property must be a plain identifier (not computed)
107
+ if (value.computed) return;
108
+ if (value.property.type !== 'Identifier') return;
109
+
110
+ const obj = value.object;
111
+ // Object must itself be: this.something
112
+ if (obj.type !== 'MemberExpression') return;
113
+ if (obj.computed) return;
114
+ if (obj.object.type !== 'ThisExpression') return;
115
+ if (obj.property.type !== 'Identifier') return;
116
+
117
+ const aliasName = node.key.type === 'Identifier' ? node.key.name : null;
118
+ if (!aliasName) return;
119
+
120
+ const sourceName = obj.property.name;
121
+ const propName = value.property.name;
122
+
123
+ context.report({
124
+ node,
125
+ messageId: 'noAlias',
126
+ data: { alias: aliasName, source: sourceName, prop: propName },
127
+ });
128
+ },
129
+
130
+ MethodDefinition(node) {
131
+ const aliasName = node.key.type === 'Identifier' ? node.key.name : null;
132
+ if (!aliasName) return;
133
+
134
+ const info = getMethodAliasInfo(node);
135
+ if (!info) return;
136
+
137
+ // Only flag when names match — different names indicate an intentional
138
+ // API rename, which is not a pure alias.
139
+ if (aliasName !== info.propName) return;
140
+
141
+ context.report({
142
+ node,
143
+ messageId: 'noAlias',
144
+ data: { alias: aliasName, source: info.sourceName, prop: info.propName },
145
+ });
146
+ },
147
+ };
148
+ },
149
+ };
150
+
151
+ module.exports = noMemberAlias;
@@ -0,0 +1,102 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows raw browser Observer APIs (IntersectionObserver, MutationObserver,
6
+ * ResizeObserver, PerformanceObserver). Use the signal-based utilities from
7
+ * @ethlete/core instead.
8
+ *
9
+ * The signal utilities:
10
+ * - Integrate automatically with Angular's reactivity model
11
+ * - Tear down the observer when the component is destroyed (no memory leaks)
12
+ * - Are composable with other signals and computed()
13
+ * - Require no manual disconnect() calls
14
+ *
15
+ * BAD:
16
+ * new IntersectionObserver(cb, options)
17
+ * new MutationObserver(cb)
18
+ * new ResizeObserver(cb)
19
+ *
20
+ * GOOD:
21
+ * // IntersectionObserver → from @ethlete/core
22
+ * signalElementIntersection(elementRef, options)
23
+ * signalHostElementIntersection(options) // shortcut inside a directive/component
24
+ *
25
+ * // MutationObserver → from @ethlete/core
26
+ * signalElementMutations(elementRef, options)
27
+ * signalHostElementMutations(options)
28
+ *
29
+ * // ResizeObserver → from @ethlete/core
30
+ * signalElementDimensions(elementRef)
31
+ * signalHostElementDimensions()
32
+ */
33
+
34
+ /**
35
+ * Maps Observer constructor names to their @ethlete/core signal equivalents.
36
+ * @type {Record<string, { alternatives: string[], from: string } | null>}
37
+ */
38
+ const OBSERVER_ALTERNATIVES = {
39
+ IntersectionObserver: {
40
+ alternatives: ['signalElementIntersection', 'signalHostElementIntersection'],
41
+ from: '@ethlete/core',
42
+ },
43
+ MutationObserver: {
44
+ alternatives: ['signalElementMutations', 'signalHostElementMutations'],
45
+ from: '@ethlete/core',
46
+ },
47
+ ResizeObserver: {
48
+ alternatives: ['signalElementDimensions', 'signalHostElementDimensions'],
49
+ from: '@ethlete/core',
50
+ },
51
+ PerformanceObserver: null,
52
+ };
53
+
54
+ /** @type {import('eslint').Rule.RuleModule} */
55
+ const noNativeObservers = {
56
+ meta: {
57
+ type: 'suggestion',
58
+ docs: {
59
+ description: "Disallow raw browser Observer APIs. Use signal-based utilities from '@ethlete/core' instead.",
60
+ recommended: true,
61
+ },
62
+ messages: {
63
+ useSignalUtil:
64
+ "Use '{{alternatives}}' from '{{from}}' instead of 'new {{observer}}()'. The signal utility auto-cleans up when the component is destroyed.",
65
+ avoidObserver:
66
+ "Avoid 'new {{observer}}()'. Prefer a reactive signal utility from '@ethlete/core' where possible, or ensure you call disconnect() in inject(DestroyRef).onDestroy().",
67
+ },
68
+ schema: [],
69
+ },
70
+ create(context) {
71
+ return {
72
+ NewExpression(node) {
73
+ if (node.callee.type !== 'Identifier') return;
74
+
75
+ const name = node.callee.name;
76
+ if (!(name in OBSERVER_ALTERNATIVES)) return;
77
+
78
+ const info = OBSERVER_ALTERNATIVES[name];
79
+
80
+ if (info) {
81
+ context.report({
82
+ node,
83
+ messageId: 'useSignalUtil',
84
+ data: {
85
+ observer: name,
86
+ alternatives: info.alternatives.join(' or '),
87
+ from: info.from,
88
+ },
89
+ });
90
+ } else {
91
+ context.report({
92
+ node,
93
+ messageId: 'avoidObserver',
94
+ data: { observer: name },
95
+ });
96
+ }
97
+ },
98
+ };
99
+ },
100
+ };
101
+
102
+ module.exports = noNativeObservers;
@@ -0,0 +1,76 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Checks whether a class node has a @Pipe decorator.
6
+ * @param {import('eslint').Rule.Node} classNode
7
+ */
8
+ const hasPipeDecorator = (classNode) => {
9
+ const decorators = /** @type {any} */ (classNode).decorators ?? [];
10
+ return decorators.some((/** @type {any} */ dec) => {
11
+ const expr = dec.expression;
12
+ if (expr.type === 'CallExpression') {
13
+ return expr.callee.type === 'Identifier' && expr.callee.name === 'Pipe';
14
+ }
15
+ if (expr.type === 'Identifier') {
16
+ return expr.name === 'Pipe';
17
+ }
18
+ return false;
19
+ });
20
+ };
21
+
22
+ /** @type {import('eslint').Rule.RuleModule} */
23
+ const noPipeLogic = {
24
+ meta: {
25
+ type: 'suggestion',
26
+ docs: {
27
+ description:
28
+ 'Pipes must not contain logic in the transform method. Extract to a utility function and assign it: `transform = myUtil;`',
29
+ },
30
+ messages: {
31
+ noLogicInTransform:
32
+ 'Pipes must not contain logic in transform. Extract to a utility function and assign it: `transform = myUtil;`',
33
+ },
34
+ schema: [],
35
+ },
36
+ create(context) {
37
+ return {
38
+ // Case 1: transform() { ... } — method with a body
39
+ MethodDefinition(node) {
40
+ const key = /** @type {any} */ (node).key;
41
+ if (key.type !== 'Identifier' || key.name !== 'transform') return;
42
+
43
+ const classBody = node.parent;
44
+ if (!classBody || classBody.type !== 'ClassBody') return;
45
+
46
+ const classNode = classBody.parent;
47
+ if (!classNode || (classNode.type !== 'ClassDeclaration' && classNode.type !== 'ClassExpression')) return;
48
+
49
+ if (!hasPipeDecorator(classNode)) return;
50
+
51
+ context.report({ node, messageId: 'noLogicInTransform' });
52
+ },
53
+
54
+ // Case 2: transform = () => { ... } or transform = function() { ... }
55
+ PropertyDefinition(node) {
56
+ const key = /** @type {any} */ (node).key;
57
+ if (key.type !== 'Identifier' || key.name !== 'transform') return;
58
+
59
+ const classBody = node.parent;
60
+ if (!classBody || classBody.type !== 'ClassBody') return;
61
+
62
+ const classNode = classBody.parent;
63
+ if (!classNode || (classNode.type !== 'ClassDeclaration' && classNode.type !== 'ClassExpression')) return;
64
+
65
+ if (!hasPipeDecorator(classNode)) return;
66
+
67
+ const value = /** @type {any} */ (node).value;
68
+ if (value && (value.type === 'ArrowFunctionExpression' || value.type === 'FunctionExpression')) {
69
+ context.report({ node, messageId: 'noLogicInTransform' });
70
+ }
71
+ },
72
+ };
73
+ },
74
+ };
75
+
76
+ module.exports = noPipeLogic;
@@ -0,0 +1,39 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * @param {any} node
6
+ */
7
+ const isInjectCall = (node) => {
8
+ if (!node || node.type !== 'CallExpression') return false;
9
+ return node.callee.type === 'Identifier' && node.callee.name === 'inject';
10
+ };
11
+
12
+ /** @type {import('eslint').Rule.RuleModule} */
13
+ const noPublicProperty = {
14
+ meta: {
15
+ type: 'problem',
16
+ docs: {
17
+ description:
18
+ 'Disallow explicit public on class properties, except for injected members that intentionally expose an external API.',
19
+ recommended: true,
20
+ },
21
+ schema: [],
22
+ messages: {
23
+ noPublicProperty:
24
+ 'No public keyword on class properties. The only exception is an injected member that intentionally exposes an external API.',
25
+ },
26
+ },
27
+ create(context) {
28
+ return {
29
+ PropertyDefinition(node) {
30
+ if (node.accessibility !== 'public') return;
31
+ if (isInjectCall(node.value)) return;
32
+
33
+ context.report({ node, messageId: 'noPublicProperty' });
34
+ },
35
+ };
36
+ },
37
+ };
38
+
39
+ module.exports = noPublicProperty;