@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,109 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows reading URL state from window.location properties.
6
+ *
7
+ * window.location reads are:
8
+ * - Not reactive — they return a stale snapshot at call time
9
+ * - Not SSR-safe — window is undefined during server-side rendering
10
+ *
11
+ * @ethlete/core ships reactive signal utilities that replace all URL-reading use cases.
12
+ *
13
+ * BAD:
14
+ * window.location.pathname → injectRoute()
15
+ * window.location.href → injectUrl()
16
+ * window.location.search → injectQueryParams() / injectQueryParam(key)
17
+ * window.location.hash → injectFragment()
18
+ * new URLSearchParams(window.location.search) → injectQueryParams() / injectQueryParam(key)
19
+ *
20
+ * GOOD:
21
+ * import {
22
+ * injectUrl, injectRoute, injectQueryParam, injectQueryParams, injectFragment
23
+ * } from '@ethlete/core';
24
+ *
25
+ * NOTE: window.location.href = url (assignment/navigation) and
26
+ * window.location.hostname / window.location.origin are not flagged.
27
+ */
28
+
29
+ /** URL state properties that have reactive signal equivalents. */
30
+ const LOCATION_STATE_PROPS = new Map([
31
+ ['href', "injectUrl() from '@ethlete/core'"],
32
+ ['pathname', "injectRoute() from '@ethlete/core'"],
33
+ ['search', "injectQueryParams() / injectQueryParam(key) from '@ethlete/core'"],
34
+ ['hash', "injectFragment() from '@ethlete/core'"],
35
+ ]);
36
+
37
+ /** @type {import('eslint').Rule.RuleModule} */
38
+ const noWindowLocation = {
39
+ meta: {
40
+ type: 'suggestion',
41
+ docs: {
42
+ description:
43
+ "Disallow reading URL state from 'window.location.*'. Use reactive signal utilities from '@ethlete/core' instead.",
44
+ recommended: true,
45
+ },
46
+ messages: {
47
+ noWindowLocation:
48
+ "Avoid reading 'window.location.{{prop}}'. Use {{replacement}} instead — it returns a reactive signal that is SSR-safe and updates on navigation.",
49
+ noURLSearchParams:
50
+ "Avoid 'new URLSearchParams(window.location.search)'. Use 'injectQueryParam(key)' or 'injectQueryParams()' from '@ethlete/core' instead — they return reactive signals and are SSR-safe.",
51
+ },
52
+ schema: [],
53
+ },
54
+ create(context) {
55
+ return {
56
+ // ── window.location.{prop} reads ────────────────────────────────────────
57
+ MemberExpression(node) {
58
+ if (node.property.type !== 'Identifier') return;
59
+ const prop = node.property.name;
60
+
61
+ if (!LOCATION_STATE_PROPS.has(prop)) return;
62
+
63
+ // Must be window.location.{prop} — check the object is window.location
64
+ const parent = node.object;
65
+ if (
66
+ parent.type !== 'MemberExpression' ||
67
+ parent.object.type !== 'Identifier' ||
68
+ parent.object.name !== 'window' ||
69
+ parent.property.type !== 'Identifier' ||
70
+ parent.property.name !== 'location'
71
+ ) {
72
+ return;
73
+ }
74
+
75
+ // Skip assignments TO window.location.href (navigation redirect)
76
+ if (prop === 'href' && node.parent.type === 'AssignmentExpression' && node.parent.left === node) {
77
+ return;
78
+ }
79
+
80
+ context.report({
81
+ node,
82
+ messageId: 'noWindowLocation',
83
+ data: { prop, replacement: LOCATION_STATE_PROPS.get(prop) },
84
+ });
85
+ },
86
+
87
+ // ── new URLSearchParams(window.location.search) ─────────────────────────
88
+ NewExpression(node) {
89
+ if (node.callee.type !== 'Identifier' || node.callee.name !== 'URLSearchParams') return;
90
+ const arg = node.arguments[0];
91
+ if (!arg) return;
92
+ if (
93
+ arg.type === 'MemberExpression' &&
94
+ arg.object.type === 'MemberExpression' &&
95
+ arg.object.object.type === 'Identifier' &&
96
+ arg.object.object.name === 'window' &&
97
+ arg.object.property.type === 'Identifier' &&
98
+ arg.object.property.name === 'location' &&
99
+ arg.property.type === 'Identifier' &&
100
+ arg.property.name === 'search'
101
+ ) {
102
+ context.report({ node, messageId: 'noURLSearchParams' });
103
+ }
104
+ },
105
+ };
106
+ },
107
+ };
108
+
109
+ module.exports = noWindowLocation;
@@ -0,0 +1,112 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Prefers the 'clone()' and 'equal()' utilities from '@ethlete/core' over
6
+ * ad-hoc deep-clone and deep-equality patterns.
7
+ *
8
+ * clone():
9
+ * - Handles all JS types (Date, RegExp, Map, Set, ArrayBuffer, typed arrays)
10
+ * - Preserves prototype chains
11
+ * - Unlike JSON round-trip: does not drop undefined, functions, or Dates become strings
12
+ * - Unlike structuredClone: no transfer semantics needed
13
+ *
14
+ * equal():
15
+ * - Full deep equality (Date, RegExp, Map, Set, ArrayBuffer, typed arrays)
16
+ * - No lodash dependency needed
17
+ *
18
+ * BAD:
19
+ * JSON.parse(JSON.stringify(obj)) // lossy, slow, no prototype
20
+ * structuredClone(obj) // for components: use clone()
21
+ * import { cloneDeep } from 'lodash'; // unnecessary dependency
22
+ * import { isEqual } from 'lodash'; // unnecessary dependency
23
+ *
24
+ * GOOD:
25
+ * import { clone, equal } from '@ethlete/core';
26
+ */
27
+
28
+ /** @type {import('eslint').Rule.RuleModule} */
29
+ const preferCloneEqual = {
30
+ meta: {
31
+ type: 'suggestion',
32
+ docs: {
33
+ description:
34
+ "Prefer 'clone()' / 'equal()' from '@ethlete/core' over JSON round-trip cloning, structuredClone, or lodash cloneDeep / isEqual.",
35
+ recommended: true,
36
+ },
37
+ messages: {
38
+ preferClone:
39
+ "Avoid '{{method}}' for deep cloning. Use 'clone()' from '@ethlete/core' instead — it handles all JS types, preserves prototypes, and does not lose undefined values or Dates.",
40
+ preferEqual:
41
+ "Avoid lodash '{{method}}' for deep equality. Use 'equal()' from '@ethlete/core' instead — no extra dependency needed.",
42
+ },
43
+ schema: [],
44
+ },
45
+ create(context) {
46
+ return {
47
+ CallExpression(node) {
48
+ const { callee } = node;
49
+
50
+ // ── JSON.parse(JSON.stringify(expr)) ─────────────────────────────────
51
+ if (
52
+ callee.type === 'MemberExpression' &&
53
+ callee.object.type === 'Identifier' &&
54
+ callee.object.name === 'JSON' &&
55
+ callee.property.type === 'Identifier' &&
56
+ callee.property.name === 'parse' &&
57
+ node.arguments[0]?.type === 'CallExpression'
58
+ ) {
59
+ const innerCall = node.arguments[0];
60
+ if (
61
+ innerCall.callee.type === 'MemberExpression' &&
62
+ innerCall.callee.object.type === 'Identifier' &&
63
+ innerCall.callee.object.name === 'JSON' &&
64
+ innerCall.callee.property.type === 'Identifier' &&
65
+ innerCall.callee.property.name === 'stringify'
66
+ ) {
67
+ context.report({ node, messageId: 'preferClone', data: { method: 'JSON.parse(JSON.stringify(...))' } });
68
+ return;
69
+ }
70
+ }
71
+
72
+ // ── structuredClone(expr) ─────────────────────────────────────────────
73
+ if (callee.type === 'Identifier' && callee.name === 'structuredClone') {
74
+ context.report({ node, messageId: 'preferClone', data: { method: 'structuredClone()' } });
75
+ }
76
+ },
77
+
78
+ ImportDeclaration(node) {
79
+ const src = node.source.value;
80
+
81
+ // ── lodash cloneDeep / isEqual ────────────────────────────────────────
82
+ // import { cloneDeep } from 'lodash' / 'lodash-es'
83
+ // import { isEqual } from 'lodash' / 'lodash-es'
84
+ if (src === 'lodash' || src === 'lodash-es') {
85
+ for (const specifier of node.specifiers) {
86
+ if (specifier.type !== 'ImportSpecifier') continue;
87
+ const name = specifier.imported.name;
88
+ if (name === 'cloneDeep') {
89
+ context.report({ node, messageId: 'preferClone', data: { method: 'lodash cloneDeep' } });
90
+ } else if (name === 'isEqual') {
91
+ context.report({ node, messageId: 'preferEqual', data: { method: 'isEqual' } });
92
+ }
93
+ }
94
+ return;
95
+ }
96
+
97
+ // import cloneDeep from 'lodash/cloneDeep'
98
+ if (src === 'lodash/cloneDeep' || src === 'lodash-es/cloneDeep') {
99
+ context.report({ node, messageId: 'preferClone', data: { method: 'lodash cloneDeep' } });
100
+ return;
101
+ }
102
+
103
+ // import isEqual from 'lodash/isEqual'
104
+ if (src === 'lodash/isEqual' || src === 'lodash-es/isEqual') {
105
+ context.report({ node, messageId: 'preferEqual', data: { method: 'isEqual' } });
106
+ }
107
+ },
108
+ };
109
+ },
110
+ };
111
+
112
+ module.exports = preferCloneEqual;
@@ -0,0 +1,225 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /** @typedef {'Component' | 'Directive'} TDecoratorName */
5
+ /** @typedef {import('estree').Property & { range: [number, number] }} TPropertyNode */
6
+ /** @typedef {import('estree').ArrayExpression & { elements: Array<import('estree').Expression | import('estree').SpreadElement | null> }} TArrayExpressionNode */
7
+ /** @typedef {import('estree').ObjectExpression & { properties: Array<TPropertyNode | import('estree').SpreadElement> }} TObjectExpressionNode */
8
+ /** @typedef {{ hasComma: boolean; originalIndex: number; orderIndex: number; property: TPropertyNode; segmentText: string }} TPropertyEntry */
9
+
10
+ const HOST_DIRECTIVE_PROPERTY_ORDER = ['directive', 'inputs', 'outputs'];
11
+
12
+ /**
13
+ * @param {import('eslint').Rule.Node} node
14
+ */
15
+ const getDecoratorName = (node) => {
16
+ const decorator = /** @type {any} */ (node);
17
+ if (decorator.type !== 'Decorator') return null;
18
+
19
+ const expression = decorator.expression;
20
+ if (expression.type === 'CallExpression') {
21
+ return expression.callee.type === 'Identifier' ? expression.callee.name : null;
22
+ }
23
+
24
+ return expression.type === 'Identifier' ? expression.name : null;
25
+ };
26
+
27
+ /**
28
+ * @param {import('estree').Property['key']} key
29
+ */
30
+ const getPropertyName = (key) => {
31
+ if (key.type === 'Identifier') return key.name;
32
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
33
+ return null;
34
+ };
35
+
36
+ /**
37
+ * @param {TDecoratorName} decoratorName
38
+ * @param {import('estree').Node} node
39
+ */
40
+ const getHostDirectivesArray = (decoratorName, node) => {
41
+ const decorator = /** @type {any} */ (node);
42
+ const expression = decorator.expression;
43
+ if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return null;
44
+
45
+ const metadata = expression.arguments[0];
46
+ if (!metadata || metadata.type !== 'ObjectExpression') return null;
47
+
48
+ for (const property of metadata.properties) {
49
+ if (!property || property.type !== 'Property') continue;
50
+ if (getPropertyName(property.key) !== 'hostDirectives') continue;
51
+ if (property.value.type !== 'ArrayExpression') return null;
52
+
53
+ return /** @type {TArrayExpressionNode} */ (property.value);
54
+ }
55
+
56
+ return null;
57
+ };
58
+
59
+ /**
60
+ * @param {TObjectExpressionNode} node
61
+ */
62
+ const getHostDirectiveConfigProperties = (node) => {
63
+ if (
64
+ node.properties.some(
65
+ /** @param {TPropertyNode | import('estree').SpreadElement} property */ (property) =>
66
+ property.type !== 'Property',
67
+ )
68
+ ) {
69
+ return null;
70
+ }
71
+
72
+ const properties = /** @type {TPropertyNode[]} */ (node.properties);
73
+ const names = properties.map((property) => getPropertyName(property.key));
74
+
75
+ if (names.some((name) => name === null || !HOST_DIRECTIVE_PROPERTY_ORDER.includes(name))) {
76
+ return null;
77
+ }
78
+
79
+ return properties;
80
+ };
81
+
82
+ /**
83
+ * @param {TObjectExpressionNode} node
84
+ * @param {import('eslint').SourceCode} sourceCode
85
+ */
86
+ const buildSortedObjectText = (node, sourceCode) => {
87
+ const properties = getHostDirectiveConfigProperties(node);
88
+ if (!properties || properties.length < 2) return null;
89
+
90
+ const openingBrace = sourceCode.getFirstToken(node);
91
+ const closingBrace = sourceCode.getLastToken(node);
92
+ if (!openingBrace || !closingBrace || openingBrace.value !== '{' || closingBrace.value !== '}') return null;
93
+
94
+ let segmentStart = openingBrace.range[1];
95
+ const orderIndexMap = new Map(HOST_DIRECTIVE_PROPERTY_ORDER.map((value, index) => [value, index]));
96
+
97
+ const entries = properties.map(
98
+ /** @param {TPropertyNode} property @param {number} originalIndex @returns {TPropertyEntry} */
99
+ (property, originalIndex) => {
100
+ const tokenAfter = sourceCode.getTokenAfter(property);
101
+ const hasComma = Boolean(tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',');
102
+ const segmentEnd = hasComma && tokenAfter ? tokenAfter.range[0] : property.range[1];
103
+ const segmentText = sourceCode.text.slice(segmentStart, segmentEnd);
104
+
105
+ if (hasComma && tokenAfter) {
106
+ segmentStart = tokenAfter.range[1];
107
+ } else {
108
+ segmentStart = property.range[1];
109
+ }
110
+
111
+ return {
112
+ hasComma,
113
+ originalIndex,
114
+ orderIndex: orderIndexMap.get(getPropertyName(property.key)) ?? HOST_DIRECTIVE_PROPERTY_ORDER.length,
115
+ property,
116
+ segmentText,
117
+ };
118
+ },
119
+ );
120
+
121
+ const trailingComma = entries[entries.length - 1].hasComma;
122
+ const suffix = sourceCode.text.slice(segmentStart, closingBrace.range[0]);
123
+ const sortedEntries = [...entries].sort((left, right) => {
124
+ if (left.orderIndex !== right.orderIndex) return left.orderIndex - right.orderIndex;
125
+ return left.originalIndex - right.originalIndex;
126
+ });
127
+
128
+ const isSorted = entries.every((entry, index) => entry.property === sortedEntries[index].property);
129
+ if (isSorted) return null;
130
+
131
+ return (
132
+ '{' +
133
+ sortedEntries
134
+ .map((entry, index) => `${entry.segmentText}${index < sortedEntries.length - 1 || trailingComma ? ',' : ''}`)
135
+ .join('') +
136
+ suffix +
137
+ '}'
138
+ );
139
+ };
140
+
141
+ /**
142
+ * @param {TObjectExpressionNode} node
143
+ */
144
+ const hasOnlyDirectiveProperty = (node) => {
145
+ const properties = getHostDirectiveConfigProperties(node);
146
+ if (!properties || properties.length !== 1) return false;
147
+
148
+ return getPropertyName(properties[0].key) === 'directive';
149
+ };
150
+
151
+ /**
152
+ * @param {TObjectExpressionNode} node
153
+ */
154
+ const getDirectiveValueNode = (node) => {
155
+ const properties = getHostDirectiveConfigProperties(node);
156
+ if (!properties || properties.length !== 1) return null;
157
+
158
+ const property = properties[0];
159
+ if (getPropertyName(property.key) !== 'directive') return null;
160
+
161
+ return property.value;
162
+ };
163
+
164
+ /** @type {import('eslint').Rule.RuleModule} */
165
+ const preferConciseAngularHostDirectives = {
166
+ meta: {
167
+ type: 'suggestion',
168
+ docs: {
169
+ description:
170
+ 'Prefer shorthand hostDirectives entries when only directive is needed, and keep extended host directive configs ordered as directive, inputs, outputs.',
171
+ recommended: true,
172
+ },
173
+ fixable: 'code',
174
+ schema: [],
175
+ messages: {
176
+ preferShorthand: 'Use the directive directly in hostDirectives when no inputs or outputs are forwarded.',
177
+ hostDirectiveOrder: 'Extended hostDirectives entries should order properties as directive, inputs, outputs.',
178
+ },
179
+ },
180
+ create(context) {
181
+ const sourceCode = context.sourceCode;
182
+
183
+ return {
184
+ Decorator(node) {
185
+ const decoratorName = getDecoratorName(node);
186
+ if (decoratorName !== 'Component' && decoratorName !== 'Directive') return;
187
+
188
+ const hostDirectives = getHostDirectivesArray(decoratorName, node);
189
+ if (!hostDirectives) return;
190
+
191
+ for (const element of hostDirectives.elements) {
192
+ if (!element || element.type !== 'ObjectExpression') continue;
193
+
194
+ const config = /** @type {TObjectExpressionNode} */ (element);
195
+ const properties = getHostDirectiveConfigProperties(config);
196
+ if (!properties) continue;
197
+
198
+ if (hasOnlyDirectiveProperty(config)) {
199
+ const directiveValue = getDirectiveValueNode(config);
200
+ if (!directiveValue) continue;
201
+
202
+ context.report({
203
+ node: config,
204
+ messageId: 'preferShorthand',
205
+ fix: (fixer) => fixer.replaceText(config, sourceCode.getText(directiveValue)),
206
+ });
207
+
208
+ continue;
209
+ }
210
+
211
+ const sortedObjectText = buildSortedObjectText(config, sourceCode);
212
+ if (!sortedObjectText) continue;
213
+
214
+ context.report({
215
+ node: config,
216
+ messageId: 'hostDirectiveOrder',
217
+ fix: (fixer) => fixer.replaceText(config, sortedObjectText),
218
+ });
219
+ }
220
+ },
221
+ };
222
+ },
223
+ };
224
+
225
+ module.exports = preferConciseAngularHostDirectives;
@@ -0,0 +1,108 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /** @typedef {import('estree').Property} TPropertyNode */
5
+ /** @typedef {import('estree').Expression | import('estree').SpreadElement | null} TArrayElement */
6
+
7
+ /**
8
+ * @param {import('eslint').Rule.Node} node
9
+ */
10
+ const isComponentDecorator = (node) => {
11
+ const decorator = /** @type {any} */ (node);
12
+ if (decorator.type !== 'Decorator') return false;
13
+
14
+ const expression = decorator.expression;
15
+ if (expression.type === 'CallExpression') {
16
+ return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
17
+ }
18
+
19
+ return expression.type === 'Identifier' && expression.name === 'Component';
20
+ };
21
+
22
+ /**
23
+ * @param {import('estree').Property['key']} key
24
+ */
25
+ const getPropertyName = (key) => {
26
+ if (key.type === 'Identifier') return key.name;
27
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
28
+ return null;
29
+ };
30
+
31
+ /**
32
+ * @param {TPropertyNode} property
33
+ * @returns {import('estree').Expression | null}
34
+ */
35
+ const getSingleArrayElement = (property) => {
36
+ if (property.value.type !== 'ArrayExpression' || property.value.elements.length !== 1) return null;
37
+
38
+ const firstElement = /** @type {TArrayElement} */ (property.value.elements[0]);
39
+ if (!firstElement || firstElement.type === 'SpreadElement') return null;
40
+
41
+ return firstElement;
42
+ };
43
+
44
+ /** @type {import('eslint').Rule.RuleModule} */
45
+ const preferConciseAngularStyleMetadata = {
46
+ meta: {
47
+ type: 'suggestion',
48
+ docs: {
49
+ description:
50
+ 'Prefer `styleUrl` over single-item `styleUrls`, and avoid wrapping a single `styles` entry in an array.',
51
+ },
52
+ fixable: 'code',
53
+ messages: {
54
+ preferStyleUrl: 'Use `styleUrl` instead of `styleUrls` when there is only one style path.',
55
+ preferSingleStyle: 'Use `styles: <value>` instead of a single-item `styles` array.',
56
+ },
57
+ schema: [],
58
+ },
59
+ create(context) {
60
+ const sourceCode = context.sourceCode;
61
+
62
+ return {
63
+ /** @param {import('eslint').Rule.Node} node */
64
+ Decorator(node) {
65
+ if (!isComponentDecorator(node)) return;
66
+
67
+ const decorator = /** @type {any} */ (node);
68
+ const expression = decorator.expression;
69
+ if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
70
+
71
+ const metadata = expression.arguments[0];
72
+ if (!metadata || metadata.type !== 'ObjectExpression') return;
73
+
74
+ for (const property of metadata.properties) {
75
+ if (property.type !== 'Property') continue;
76
+
77
+ const propertyName = getPropertyName(property.key);
78
+ if (propertyName !== 'styleUrls' && propertyName !== 'styles') continue;
79
+
80
+ const singleElement = getSingleArrayElement(property);
81
+ if (!singleElement) continue;
82
+
83
+ if (propertyName === 'styleUrls') {
84
+ context.report({
85
+ node: property,
86
+ messageId: 'preferStyleUrl',
87
+ fix(fixer) {
88
+ return fixer.replaceText(property, `styleUrl: ${sourceCode.getText(singleElement)}`);
89
+ },
90
+ });
91
+
92
+ continue;
93
+ }
94
+
95
+ context.report({
96
+ node: property,
97
+ messageId: 'preferSingleStyle',
98
+ fix(fixer) {
99
+ return fixer.replaceText(property, `styles: ${sourceCode.getText(singleElement)}`);
100
+ },
101
+ });
102
+ }
103
+ },
104
+ };
105
+ },
106
+ };
107
+
108
+ module.exports = preferConciseAngularStyleMetadata;
@@ -0,0 +1,128 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Prefers signalElementDimensions / signalHostElementDimensions from @ethlete/core
6
+ * over imperatively reading element size properties inside reactive contexts
7
+ * (effect(), computed(), or signal initializers).
8
+ *
9
+ * Why: reading `.getBoundingClientRect()`, `.offsetWidth`, `.clientWidth`, etc.
10
+ * inside a reactive context creates a one-shot snapshot that never updates. The
11
+ * signal utilities set up a ResizeObserver-backed reactive signal that stays in
12
+ * sync automatically and cleans itself up when the component is destroyed.
13
+ *
14
+ * NOTE: This rule is a WARNING (not an error) because imperative one-shot reads
15
+ * (e.g. inside animation callbacks, event handlers) are perfectly valid. The rule
16
+ * only fires when the read is inside a reactive context.
17
+ *
18
+ * BAD (reactive context):
19
+ * effect(() => {
20
+ * const w = this.el.nativeElement.offsetWidth; // stale after resize ❌
21
+ * });
22
+ *
23
+ * computed(() => someEl.getBoundingClientRect().width); // ❌
24
+ *
25
+ * GOOD:
26
+ * dimensions = signalHostElementDimensions(); // from @ethlete/core
27
+ * // or
28
+ * dimensions = signalElementDimensions(inject(ElementRef));
29
+ * // Then:
30
+ * effect(() => { const w = this.dimensions().rect.width; }); // ✅ reactive
31
+ */
32
+
33
+ /** Size-related properties read from DOM elements. */
34
+ const ELEMENT_SIZE_PROPS = new Set([
35
+ 'offsetWidth',
36
+ 'offsetHeight',
37
+ 'clientWidth',
38
+ 'clientHeight',
39
+ 'scrollWidth',
40
+ 'scrollHeight',
41
+ ]);
42
+
43
+ /** Size-related methods called on DOM elements. */
44
+ const ELEMENT_SIZE_METHODS = new Set(['getBoundingClientRect', 'getClientRects']);
45
+
46
+ /**
47
+ * Returns the name of the wrapping reactive context ('effect' | 'computed') if
48
+ * the node is inside one, crossing exactly one function boundary.
49
+ * @param {import('eslint').Rule.Node} node
50
+ * @returns {string | null}
51
+ */
52
+ const getReactiveContext = (node) => {
53
+ let crossedFunctionBoundary = false;
54
+ let current = node.parent;
55
+
56
+ while (current) {
57
+ if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
58
+ crossedFunctionBoundary = true;
59
+ }
60
+
61
+ if (
62
+ crossedFunctionBoundary &&
63
+ current.type === 'CallExpression' &&
64
+ current.callee.type === 'Identifier' &&
65
+ (current.callee.name === 'effect' || current.callee.name === 'computed')
66
+ ) {
67
+ return current.callee.name;
68
+ }
69
+
70
+ current = current.parent;
71
+ }
72
+
73
+ return null;
74
+ };
75
+
76
+ /** @type {import('eslint').Rule.RuleModule} */
77
+ const preferElementDimensions = {
78
+ meta: {
79
+ type: 'suggestion',
80
+ docs: {
81
+ description:
82
+ "Prefer 'signalElementDimensions()' or 'signalHostElementDimensions()' from '@ethlete/core' over reading element size properties inside reactive contexts.",
83
+ recommended: true,
84
+ },
85
+ messages: {
86
+ preferElementDimensions:
87
+ "Avoid reading '{{prop}}' inside {{context}}() — it creates a stale snapshot. Use 'signalElementDimensions(elementRef)' or 'signalHostElementDimensions()' from '@ethlete/core' instead — they provide a reactive signal backed by ResizeObserver.",
88
+ },
89
+ schema: [],
90
+ },
91
+ create(context) {
92
+ return {
93
+ // el.offsetWidth, el.clientWidth, etc.
94
+ MemberExpression(node) {
95
+ if (node.property.type !== 'Identifier') return;
96
+ if (!ELEMENT_SIZE_PROPS.has(node.property.name)) return;
97
+
98
+ const reactiveCtx = getReactiveContext(node);
99
+ if (!reactiveCtx) return;
100
+
101
+ context.report({
102
+ node,
103
+ messageId: 'preferElementDimensions',
104
+ data: { prop: node.property.name, context: reactiveCtx },
105
+ });
106
+ },
107
+
108
+ // el.getBoundingClientRect(), el.getClientRects()
109
+ CallExpression(node) {
110
+ const { callee } = node;
111
+ if (callee.type !== 'MemberExpression') return;
112
+ if (callee.property.type !== 'Identifier') return;
113
+ if (!ELEMENT_SIZE_METHODS.has(callee.property.name)) return;
114
+
115
+ const reactiveCtx = getReactiveContext(node);
116
+ if (!reactiveCtx) return;
117
+
118
+ context.report({
119
+ node,
120
+ messageId: 'preferElementDimensions',
121
+ data: { prop: callee.property.name + '()', context: reactiveCtx },
122
+ });
123
+ },
124
+ };
125
+ },
126
+ };
127
+
128
+ module.exports = preferElementDimensions;