@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,124 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Prefer linkedSignal over setting a signal inside an effect().
6
+ *
7
+ * When a signal is set inside an effect(), it usually means the new value is
8
+ * derived from other reactive state. That pattern is better expressed with
9
+ * linkedSignal(), which is declarative, avoids the scheduling overhead of
10
+ * effect(), and makes the dependency chain explicit.
11
+ *
12
+ * BAD:
13
+ * selectedItem = signal<Item | null>(null);
14
+ *
15
+ * constructor() {
16
+ * effect(() => {
17
+ * // Reset selection whenever the list changes
18
+ * this.selectedItem.set(this.items()[0] ?? null); // ❌
19
+ * });
20
+ * }
21
+ *
22
+ * GOOD:
23
+ * selectedItem = linkedSignal(() => this.items()[0] ?? null);
24
+ *
25
+ * NOTE: Only flag when .set() is called directly inside an effect() callback.
26
+ * Setting signals outside effects (in methods, event handlers, etc.) is fine.
27
+ */
28
+
29
+ /**
30
+ * Returns true only when the `.set()` call node is a *pure derivation* inside
31
+ * an effect() callback — meaning:
32
+ *
33
+ * 1. The IMMEDIATE enclosing function (the first function boundary walking up
34
+ * from the node) IS the first argument passed to `effect()`. This ensures
35
+ * that `.set()` calls nested inside `untracked(() => ...)` or other inner
36
+ * functions within the effect are NOT flagged — those are intentional
37
+ * write-back patterns, not derivations.
38
+ *
39
+ * 2. The `.set()` call is the SOLE expression in that callback:
40
+ * - Arrow with expression body: `effect(() => sig.set(x))`
41
+ * - Block body with a single ExpressionStatement: `effect(() => { sig.set(x); })`
42
+ *
43
+ * Multi-statement blocks (even if they end with a `.set()`) indicate that the
44
+ * effect is performing side effects alongside the signal write, so they are
45
+ * not flagged either.
46
+ *
47
+ * @param {import('eslint').Rule.Node} node The `.set()` CallExpression node
48
+ */
49
+ const isPureSetInDirectEffectCallback = (node) => {
50
+ // Find the immediate enclosing function (cross exactly one boundary).
51
+ let current = node.parent;
52
+ let immediateFunction = null;
53
+
54
+ while (current) {
55
+ if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
56
+ immediateFunction = current;
57
+ break;
58
+ }
59
+ current = current.parent;
60
+ }
61
+
62
+ if (!immediateFunction) return false;
63
+
64
+ // The immediate function must be the first argument to an effect() call.
65
+ const funcParent = immediateFunction.parent;
66
+ if (
67
+ !funcParent ||
68
+ funcParent.type !== 'CallExpression' ||
69
+ funcParent.callee.type !== 'Identifier' ||
70
+ funcParent.callee.name !== 'effect' ||
71
+ funcParent.arguments[0] !== immediateFunction
72
+ ) {
73
+ return false;
74
+ }
75
+
76
+ // The .set() must be the sole expression — no surrounding statements.
77
+ // Case A: arrow with expression body — the body IS the .set() call.
78
+ if (immediateFunction.type === 'ArrowFunctionExpression' && immediateFunction.body === node) {
79
+ return true;
80
+ }
81
+
82
+ // Case B: block body with exactly one ExpressionStatement wrapping the .set() call.
83
+ const body = immediateFunction.body;
84
+ return (
85
+ body.type === 'BlockStatement' &&
86
+ body.body.length === 1 &&
87
+ body.body[0].type === 'ExpressionStatement' &&
88
+ body.body[0].expression === node
89
+ );
90
+ };
91
+
92
+ /** @type {import('eslint').Rule.RuleModule} */
93
+ const preferLinkedSignal = {
94
+ meta: {
95
+ type: 'suggestion',
96
+ docs: {
97
+ description: "Prefer 'linkedSignal' over setting a signal inside effect().",
98
+ recommended: true,
99
+ },
100
+ messages: {
101
+ preferLinkedSignal:
102
+ "Setting a signal inside 'effect()' usually means the value is derived from reactive state. Use 'linkedSignal(() => ...)' instead — it is declarative and avoids effect scheduling overhead.",
103
+ },
104
+ schema: [],
105
+ },
106
+ create(context) {
107
+ return {
108
+ CallExpression(node) {
109
+ const { callee } = node;
110
+
111
+ if (
112
+ callee.type === 'MemberExpression' &&
113
+ callee.property.type === 'Identifier' &&
114
+ callee.property.name === 'set' &&
115
+ isPureSetInDirectEffectCallback(node)
116
+ ) {
117
+ context.report({ node, messageId: 'preferLinkedSignal' });
118
+ }
119
+ },
120
+ };
121
+ },
122
+ };
123
+
124
+ module.exports = preferLinkedSignal;
@@ -0,0 +1,101 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Prefers the typed media-query signal utilities from @ethlete/core over
6
+ * calling window.matchMedia() directly or using Angular CDK's BreakpointObserver.
7
+ *
8
+ * The utilities:
9
+ * - Return reactive signals that update when the media query result changes
10
+ * - Are SSR-safe (no window access during server rendering)
11
+ * - Are automatically cleaned up when the component is destroyed
12
+ *
13
+ * BAD:
14
+ * window.matchMedia('(prefers-color-scheme: dark)').matches
15
+ * const mq = window.matchMedia('(max-width: 768px)');
16
+ * mq.addEventListener('change', handler);
17
+ * inject(BreakpointObserver);
18
+ * import { BreakpointObserver } from '@angular/cdk/layout';
19
+ *
20
+ * GOOD:
21
+ * // For arbitrary media queries:
22
+ * const isDark = injectMediaQueryIsMatched('(prefers-color-scheme: dark)');
23
+ *
24
+ * // For standard breakpoints:
25
+ * const isMobile = injectBreakpointIsMatched({ max: 'sm' });
26
+ *
27
+ * // For reactive BreakpointObserver access:
28
+ * const bo = injectBreakpointObserver(); // from @ethlete/core
29
+ *
30
+ * // For common checks that are pre-built:
31
+ * const canHover = injectCanHover();
32
+ * const hasTouchInput = injectHasTouchInput();
33
+ * const isPortrait = injectIsPortrait();
34
+ * const isLandscape = injectIsLandscape();
35
+ * const isXs = injectIsXs();
36
+ * const isSm = injectIsSm();
37
+ * // … etc. All from @ethlete/core.
38
+ */
39
+
40
+ /** @type {import('eslint').Rule.RuleModule} */
41
+ const preferMatchMedia = {
42
+ meta: {
43
+ type: 'suggestion',
44
+ docs: {
45
+ description: "Prefer reactive media-query signal utilities from '@ethlete/core' over 'window.matchMedia()'.",
46
+ recommended: true,
47
+ },
48
+ messages: {
49
+ preferMatchMedia:
50
+ "Avoid 'window.matchMedia()' directly. Use 'injectMediaQueryIsMatched(query)' or a pre-built helper (injectCanHover, injectHasTouchInput, injectIsPortrait, injectBreakpointIsMatched, …) from '@ethlete/core' — they return reactive signals and are SSR-safe.",
51
+ noBreakpointObserver:
52
+ "Do not use Angular CDK's 'BreakpointObserver' directly. Use 'injectBreakpointObserver()' from '@ethlete/core' instead, which wraps it with a reactive signal API and proper DI scoping.",
53
+ },
54
+ schema: [],
55
+ },
56
+ create(context) {
57
+ return {
58
+ CallExpression(node) {
59
+ const { callee } = node;
60
+
61
+ // ── window.matchMedia() ───────────────────────────────────────────────
62
+ if (
63
+ callee.type === 'MemberExpression' &&
64
+ callee.property.type === 'Identifier' &&
65
+ callee.property.name === 'matchMedia'
66
+ ) {
67
+ // Flag any .matchMedia() call — the object is almost certainly window or
68
+ // document.defaultView, but even a forwarded reference should be replaced.
69
+ context.report({ node, messageId: 'preferMatchMedia' });
70
+ return;
71
+ }
72
+
73
+ // ── inject(BreakpointObserver) ────────────────────────────────────────
74
+ if (
75
+ callee.type === 'Identifier' &&
76
+ callee.name === 'inject' &&
77
+ node.arguments[0]?.type === 'Identifier' &&
78
+ node.arguments[0].name === 'BreakpointObserver'
79
+ ) {
80
+ context.report({ node, messageId: 'noBreakpointObserver' });
81
+ }
82
+ },
83
+
84
+ ImportDeclaration(node) {
85
+ // ── import { BreakpointObserver } from '@angular/cdk/layout' ─────────
86
+ if (node.source.value !== '@angular/cdk/layout') return;
87
+ const hasBreakpointObserver = node.specifiers.some(
88
+ (s) =>
89
+ s.type === 'ImportSpecifier' &&
90
+ s.imported.type === 'Identifier' &&
91
+ s.imported.name === 'BreakpointObserver',
92
+ );
93
+ if (hasBreakpointObserver) {
94
+ context.report({ node, messageId: 'noBreakpointObserver' });
95
+ }
96
+ },
97
+ };
98
+ },
99
+ };
100
+
101
+ module.exports = preferMatchMedia;
@@ -0,0 +1,82 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Prefer RxJS timer/interval/fromEvent operators over native browser APIs.
6
+ *
7
+ * RxJS operators integrate with takeUntilDestroyed() for automatic cleanup,
8
+ * whereas native APIs require manual cleanup and are easy to leak.
9
+ *
10
+ * BAD:
11
+ * setTimeout(() => this.refresh(), 500); // leaks if component destroyed first
12
+ * setInterval(() => this.poll(), 2000); // leaks
13
+ * element.addEventListener('click', handler); // must be removed manually
14
+ * element.removeEventListener('click', handler);
15
+ *
16
+ * GOOD:
17
+ * timer(500).pipe(takeUntilDestroyed()).subscribe(() => this.refresh());
18
+ * interval(2000).pipe(takeUntilDestroyed()).subscribe(() => this.poll());
19
+ * fromEvent(element, 'click').pipe(takeUntilDestroyed()).subscribe(handler);
20
+ */
21
+
22
+ /** @type {import('eslint').Rule.RuleModule} */
23
+ const preferRxjsTimer = {
24
+ meta: {
25
+ type: 'suggestion',
26
+ docs: {
27
+ description: 'Prefer RxJS timer / interval / fromEvent over native browser timer and event APIs.',
28
+ recommended: true,
29
+ },
30
+ messages: {
31
+ preferTimer:
32
+ "Use RxJS 'timer(delay)' from 'rxjs' instead of 'setTimeout()'. Combine with 'takeUntilDestroyed()' for automatic cleanup.",
33
+ preferInterval:
34
+ "Use RxJS 'interval(period)' from 'rxjs' instead of 'setInterval()'. Combine with 'takeUntilDestroyed()' for automatic cleanup.",
35
+ preferUnsubscribe:
36
+ "Remove '{{name}}()' — cancel the timer / interval by calling 'subscription.unsubscribe()' or use 'takeUntilDestroyed()' / 'take(1)'.",
37
+ preferFromEvent:
38
+ "Use RxJS 'fromEvent(target, event)' from 'rxjs' instead of '.addEventListener()'. Combine with 'takeUntilDestroyed()' for automatic cleanup.",
39
+ preferFromEventRemove:
40
+ "Remove '.removeEventListener()' — use 'fromEvent()' with 'takeUntilDestroyed()' to avoid ever needing to remove listeners manually.",
41
+ },
42
+ schema: [],
43
+ },
44
+ create(context) {
45
+ return {
46
+ CallExpression(node) {
47
+ const { callee } = node;
48
+
49
+ // ── Global timer calls ────────────────────────────────────────────────
50
+ if (callee.type === 'Identifier') {
51
+ const { name } = callee;
52
+ if (name === 'setTimeout') {
53
+ context.report({ node, messageId: 'preferTimer' });
54
+ return;
55
+ }
56
+ if (name === 'setInterval') {
57
+ context.report({ node, messageId: 'preferInterval' });
58
+ return;
59
+ }
60
+ if (name === 'clearTimeout' || name === 'clearInterval') {
61
+ context.report({ node, messageId: 'preferUnsubscribe', data: { name } });
62
+ return;
63
+ }
64
+ }
65
+
66
+ // ── Member calls: .addEventListener / .removeEventListener ────────────
67
+ if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') {
68
+ const methodName = callee.property.name;
69
+ if (methodName === 'addEventListener') {
70
+ context.report({ node, messageId: 'preferFromEvent' });
71
+ return;
72
+ }
73
+ if (methodName === 'removeEventListener') {
74
+ context.report({ node, messageId: 'preferFromEventRemove' });
75
+ }
76
+ }
77
+ },
78
+ };
79
+ },
80
+ };
81
+
82
+ module.exports = preferRxjsTimer;
@@ -0,0 +1,158 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Prefers signalElementScrollState / signalHostElementScrollState from @ethlete/core
6
+ * over imperatively reading scroll position properties inside reactive contexts,
7
+ * and disallows adding raw scroll event listeners in favour of the same signal utilities.
8
+ *
9
+ * The signal utilities:
10
+ * - Set up a scroll event listener that keeps a reactive signal in sync
11
+ * - Clean up automatically when the component is destroyed
12
+ * - Provide debouncing and direction tracking built in
13
+ *
14
+ * NOTE: The property-read check is a WARNING — imperative one-shot reads of
15
+ * scrollTop/scrollLeft are fine in event handlers and animations; only flag
16
+ * inside effect() / computed().
17
+ *
18
+ * BAD (reactive context):
19
+ * effect(() => {
20
+ * const top = this.el.nativeElement.scrollTop; // stale snapshot ❌
21
+ * });
22
+ *
23
+ * BAD (scroll listeners — always wrong):
24
+ * el.addEventListener('scroll', fn); // ❌
25
+ * fromEvent(el, 'scroll').subscribe(fn); // ❌
26
+ * el.onscroll = fn; // ❌
27
+ *
28
+ * GOOD:
29
+ * scrollState = signalHostElementScrollState(); // from @ethlete/core
30
+ * scrollDir = signalHostElementLastScrollDirection(); // from @ethlete/core
31
+ * // Or use <et-scrollable> / ScrollableComponent for list scrolling
32
+ */
33
+
34
+ /** Scroll position properties read from DOM elements. */
35
+ const SCROLL_PROPS = new Set(['scrollTop', 'scrollLeft', 'scrollY', 'scrollX']);
36
+
37
+ /**
38
+ * Returns the name of the wrapping reactive context ('effect' | 'computed') if
39
+ * the node is inside one, crossing at least one function boundary.
40
+ * @param {import('eslint').Rule.Node} node
41
+ * @returns {string | null}
42
+ */
43
+ const getReactiveContext = (node) => {
44
+ let crossedFunctionBoundary = false;
45
+ let current = node.parent;
46
+
47
+ while (current) {
48
+ if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
49
+ crossedFunctionBoundary = true;
50
+ }
51
+
52
+ if (
53
+ crossedFunctionBoundary &&
54
+ current.type === 'CallExpression' &&
55
+ current.callee.type === 'Identifier' &&
56
+ (current.callee.name === 'effect' || current.callee.name === 'computed')
57
+ ) {
58
+ return current.callee.name;
59
+ }
60
+
61
+ current = current.parent;
62
+ }
63
+
64
+ return null;
65
+ };
66
+
67
+ /** @type {import('eslint').Rule.RuleModule} */
68
+ const preferScrollState = {
69
+ meta: {
70
+ type: 'suggestion',
71
+ docs: {
72
+ description:
73
+ "Prefer 'signalElementScrollState()' / 'signalHostElementScrollState()' from '@ethlete/core' over reading scroll position properties inside reactive contexts.",
74
+ recommended: true,
75
+ },
76
+ messages: {
77
+ preferScrollState:
78
+ "Avoid reading '{{prop}}' inside {{context}}() — it creates a stale snapshot. Use 'signalElementScrollState(elementRef)' or 'signalHostElementScrollState()' from '@ethlete/core' instead. For scroll direction, use 'signalElementLastScrollDirection()' / 'signalHostElementLastScrollDirection()'.",
79
+ noScrollListener:
80
+ "Do not add 'scroll' event listeners directly. Use 'signalElementScrollState(elementRef)' / 'signalHostElementScrollState()' for scroll position or state, 'signalElementLastScrollDirection(elementRef)' / 'signalHostElementLastScrollDirection()' for scroll direction, or the 'ScrollableComponent' (et-scrollable) from '@ethlete/cdk' for scrollable lists. All are imported from '@ethlete/core' or '@ethlete/cdk'.",
81
+ },
82
+ schema: [],
83
+ },
84
+ create(context) {
85
+ return {
86
+ CallExpression(node) {
87
+ const { callee } = node;
88
+ const args = node.arguments;
89
+
90
+ // ── el.addEventListener('scroll', fn) / window.addEventListener('scroll', fn) ──
91
+ if (
92
+ callee.type === 'MemberExpression' &&
93
+ callee.property.type === 'Identifier' &&
94
+ callee.property.name === 'addEventListener' &&
95
+ args[0]?.type === 'Literal' &&
96
+ args[0].value === 'scroll'
97
+ ) {
98
+ context.report({ node, messageId: 'noScrollListener' });
99
+ return;
100
+ }
101
+
102
+ // ── fromEvent(el, 'scroll') ──────────────────────────────────────────
103
+ if (
104
+ callee.type === 'Identifier' &&
105
+ callee.name === 'fromEvent' &&
106
+ args[1]?.type === 'Literal' &&
107
+ args[1].value === 'scroll'
108
+ ) {
109
+ context.report({ node, messageId: 'noScrollListener' });
110
+ return;
111
+ }
112
+
113
+ // ── renderer.listen(el, 'scroll', fn) ───────────────────────────────
114
+ if (
115
+ callee.type === 'MemberExpression' &&
116
+ callee.property.type === 'Identifier' &&
117
+ callee.property.name === 'listen' &&
118
+ args[1]?.type === 'Literal' &&
119
+ args[1].value === 'scroll'
120
+ ) {
121
+ context.report({ node, messageId: 'noScrollListener' });
122
+ }
123
+ },
124
+
125
+ AssignmentExpression(node) {
126
+ // ── el.onscroll = fn ────────────────────────────────────────────────
127
+ const { left } = node;
128
+ if (
129
+ left.type === 'MemberExpression' &&
130
+ left.property.type === 'Identifier' &&
131
+ left.property.name === 'onscroll'
132
+ ) {
133
+ context.report({ node, messageId: 'noScrollListener' });
134
+ }
135
+ },
136
+
137
+ MemberExpression(node) {
138
+ if (node.property.type !== 'Identifier') return;
139
+ if (!SCROLL_PROPS.has(node.property.name)) return;
140
+
141
+ // Only flag reads (not assignments like el.scrollTop = 0).
142
+ const parent = node.parent;
143
+ if (parent && parent.type === 'AssignmentExpression' && parent.left === node) return;
144
+
145
+ const reactiveCtx = getReactiveContext(node);
146
+ if (!reactiveCtx) return;
147
+
148
+ context.report({
149
+ node,
150
+ messageId: 'preferScrollState',
151
+ data: { prop: node.property.name, context: reactiveCtx },
152
+ });
153
+ },
154
+ };
155
+ },
156
+ };
157
+
158
+ module.exports = preferScrollState;
@@ -0,0 +1,75 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Prefers injectViewportSize() from @ethlete/core over reading raw viewport
6
+ * dimensions from the global window object.
7
+ *
8
+ * injectViewportSize() returns a reactive signal that:
9
+ * - Updates automatically when the viewport is resized
10
+ * - Works on the server (SSR-safe)
11
+ * - Participates in Angular's change detection
12
+ *
13
+ * BAD:
14
+ * const w = window.innerWidth;
15
+ * document.defaultView?.innerHeight
16
+ *
17
+ * GOOD:
18
+ * const viewport = injectViewportSize(); // from @ethlete/core
19
+ * const w = viewport().width;
20
+ * const h = viewport().height;
21
+ */
22
+
23
+ /** Properties on window that report viewport dimensions. */
24
+ const VIEWPORT_PROPERTIES = new Set([
25
+ 'innerWidth',
26
+ 'innerHeight',
27
+ 'outerWidth',
28
+ 'outerHeight',
29
+ 'screen', // window.screen.width / window.screen.height
30
+ ]);
31
+
32
+ /** @type {import('eslint').Rule.RuleModule} */
33
+ const preferViewportSize = {
34
+ meta: {
35
+ type: 'suggestion',
36
+ docs: {
37
+ description: "Prefer 'injectViewportSize()' from '@ethlete/core' over direct window dimension properties.",
38
+ recommended: true,
39
+ },
40
+ messages: {
41
+ preferViewportSize:
42
+ "Avoid reading '{{prop}}' directly from window. Use 'injectViewportSize()' from '@ethlete/core' — it returns a reactive signal that stays up to date as the viewport resizes.",
43
+ },
44
+ schema: [],
45
+ },
46
+ create(context) {
47
+ return {
48
+ MemberExpression(node) {
49
+ if (node.property.type !== 'Identifier') return;
50
+ if (!VIEWPORT_PROPERTIES.has(node.property.name)) return;
51
+
52
+ // Only flag when the object is clearly a window-like reference.
53
+ const obj = node.object;
54
+ const objText = context.sourceCode.getText(obj).toLowerCase();
55
+
56
+ const isWindowRef =
57
+ objText === 'window' ||
58
+ objText.endsWith('.defaultview') ||
59
+ objText.endsWith('?.defaultview') ||
60
+ // document.defaultView without optional chain
61
+ /defaultview$/.test(objText);
62
+
63
+ if (!isWindowRef) return;
64
+
65
+ context.report({
66
+ node,
67
+ messageId: 'preferViewportSize',
68
+ data: { prop: node.property.name },
69
+ });
70
+ },
71
+ };
72
+ },
73
+ };
74
+
75
+ module.exports = preferViewportSize;
@@ -0,0 +1,123 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Requires observable variables and class properties to be suffixed with `$`.
6
+ *
7
+ * Detection is heuristic — a binding is assumed to be an Observable if its
8
+ * initializer matches one of:
9
+ * - a `.pipe(...)` call (most reliable signal)
10
+ * - `new Subject / BehaviorSubject / ReplaySubject / AsyncSubject / Observable()`
11
+ * - a call to a known RxJS creator: from, of, interval, timer, fromEvent,
12
+ * fromEventPattern, combineLatest, merge, concat, forkJoin, race, zip,
13
+ * defer, iif, throwError, using, partition
14
+ *
15
+ * ❌ const myData = someObs.pipe(map(...));
16
+ * ❌ const clicks = fromEvent(el, 'click');
17
+ * ❌ class Foo { stream = this.src.pipe(filter(...)); }
18
+ *
19
+ * ✅ const myData$ = someObs.pipe(map(...));
20
+ * ✅ const clicks$ = fromEvent(el, 'click');
21
+ * ✅ class Foo { stream$ = this.src.pipe(filter(...)); }
22
+ */
23
+
24
+ const RXJS_CREATORS = new Set([
25
+ 'from',
26
+ 'of',
27
+ 'interval',
28
+ 'timer',
29
+ 'fromEvent',
30
+ 'fromEventPattern',
31
+ 'combineLatest',
32
+ 'merge',
33
+ 'concat',
34
+ 'forkJoin',
35
+ 'race',
36
+ 'zip',
37
+ 'defer',
38
+ 'iif',
39
+ 'throwError',
40
+ 'using',
41
+ 'partition',
42
+ ]);
43
+
44
+ const RXJS_SUBJECT_CTORS = new Set(['Subject', 'BehaviorSubject', 'ReplaySubject', 'AsyncSubject', 'Observable']);
45
+
46
+ /**
47
+ * Returns true if the expression looks like an Observable.
48
+ * @param {any} node
49
+ */
50
+ const isObservableInit = (node) => {
51
+ if (!node) return false;
52
+
53
+ // someObs.pipe(...)
54
+ if (
55
+ node.type === 'CallExpression' &&
56
+ node.callee.type === 'MemberExpression' &&
57
+ node.callee.property.type === 'Identifier' &&
58
+ node.callee.property.name === 'pipe'
59
+ ) {
60
+ return true;
61
+ }
62
+
63
+ // from(...), of(...), interval(...), etc.
64
+ if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && RXJS_CREATORS.has(node.callee.name)) {
65
+ return true;
66
+ }
67
+
68
+ // new Subject(), new BehaviorSubject(...), etc.
69
+ if (node.type === 'NewExpression' && node.callee.type === 'Identifier' && RXJS_SUBJECT_CTORS.has(node.callee.name)) {
70
+ return true;
71
+ }
72
+
73
+ return false;
74
+ };
75
+
76
+ /** @type {import('eslint').Rule.RuleModule} */
77
+ const rule = {
78
+ meta: {
79
+ type: 'suggestion',
80
+ docs: {
81
+ description: 'Require observable variables and class properties to use the $ suffix.',
82
+ },
83
+ schema: [],
84
+ messages: {
85
+ missingSuffix: "Observable '{{ name }}' must end with '$' (e.g. '{{ name }}$').",
86
+ },
87
+ },
88
+ create(context) {
89
+ /**
90
+ * @param {string} name
91
+ * @param {any} reportNode
92
+ * @param {any} initNode
93
+ */
94
+ const checkBinding = (name, reportNode, initNode) => {
95
+ if (!isObservableInit(initNode)) return;
96
+ if (name.endsWith('$')) return;
97
+
98
+ context.report({
99
+ node: reportNode,
100
+ messageId: 'missingSuffix',
101
+ data: { name },
102
+ });
103
+ };
104
+
105
+ return {
106
+ // const myObs = ...
107
+ VariableDeclarator(node) {
108
+ const n = /** @type {any} */ (node);
109
+ if (n.id.type !== 'Identifier') return;
110
+ checkBinding(n.id.name, n.id, n.init);
111
+ },
112
+
113
+ // class Foo { myObs = ... }
114
+ PropertyDefinition(node) {
115
+ const n = /** @type {any} */ (node);
116
+ if (n.key.type !== 'Identifier') return;
117
+ checkBinding(n.key.name, n.key, n.value);
118
+ },
119
+ };
120
+ },
121
+ };
122
+
123
+ module.exports = rule;