@ethlete/eslint-plugin 0.1.0-next.2 → 0.1.0-next.3

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-next.3
4
+
5
+ ### Patch Changes
6
+
7
+ - [`5d1d3ac`](https://github.com/ethlete-io/ethdk/commit/5d1d3accbd4a7657bc50cdc0653a7ca24fe761e2) Thanks [@TomTomB](https://github.com/TomTomB)! - Fix minor lint edge cases
8
+
3
9
  ## 0.1.0-next.2
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/eslint-plugin",
3
- "version": "0.1.0-next.2",
3
+ "version": "0.1.0-next.3",
4
4
  "license": "MIT",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -8,6 +8,7 @@
8
8
  "@angular-eslint/eslint-plugin-template": ">=21.0.0",
9
9
  "@typescript-eslint/eslint-plugin": ">=8.0.0",
10
10
  "eslint": ">=9.0.0",
11
- "tslib": "2.8.1"
11
+ "tslib": "2.8.1",
12
+ "typescript": "5.9.3"
12
13
  }
13
14
  }
@@ -139,11 +139,6 @@ const recommendedTs = {
139
139
  selector: 'FunctionExpression[async=true]',
140
140
  message: 'No async/await. Use RxJS for all async operations.',
141
141
  },
142
- // No public keyword on class members
143
- {
144
- selector: "MethodDefinition[accessibility='public']",
145
- message: 'No public keyword on class members.',
146
- },
147
142
  // No static class members
148
143
  {
149
144
  selector: 'MethodDefinition[static=true]',
@@ -218,8 +213,18 @@ const recommendedTs = {
218
213
  // No inject(X).member chaining — assign to a const first
219
214
  'ethlete/no-inject-chain': 'error',
220
215
 
221
- // No public properties except explicit injected external APIs
222
- 'ethlete/no-public-property': 'error',
216
+ // inject(ElementRef) must use inject<ElementRef<HTMLElement>>(ElementRef) — not inject(ElementRef) or inject(ElementRef<...>)
217
+ 'ethlete/no-typed-injected-element-ref': 'error',
218
+
219
+ // No @internal on members already hidden by private/protected
220
+ 'ethlete/no-redundant-internal': 'error',
221
+
222
+ // Non-injected Angular members should use explicit accessibility when they
223
+ // participate in the reachable surface. Template/host members must be
224
+ // explicit, other implicit-public members must be public. @internal does
225
+ // not replace explicit public; protected remains valid for template-only
226
+ // members.
227
+ 'ethlete/template-member-accessibility': 'error',
223
228
 
224
229
  // No explicit trivial return types TypeScript can infer
225
230
  'ethlete/no-trivial-return-type': 'error',
@@ -349,6 +354,12 @@ const recommendedTs = {
349
354
  // Keep Angular @Component / @Directive metadata in a consistent order
350
355
  'ethlete/angular-decorator-property-order': 'error',
351
356
 
357
+ // Keep class members in the documented styleguide order
358
+ 'ethlete/class-member-order': 'error',
359
+
360
+ // True class constants should be readonly and use SCREAMING_CASE
361
+ 'ethlete/class-constant-property': 'error',
362
+
352
363
  // Prefer shorthand hostDirectives entries and keep extended host directive configs ordered
353
364
  'ethlete/prefer-concise-angular-host-directives': 'error',
354
365
 
@@ -377,4 +388,16 @@ const recommendedTemplate = {
377
388
  },
378
389
  };
379
390
 
380
- module.exports = { recommendedTs, recommendedTemplate };
391
+ /**
392
+ * Relaxed rules for test/spec files.
393
+ * Non-null assertions are common and intentional in tests.
394
+ * @type {import('eslint').Linter.Config}
395
+ */
396
+ const recommendedSpec = {
397
+ files: ['**/*.spec.ts'],
398
+ rules: {
399
+ '@typescript-eslint/no-non-null-assertion': 'off',
400
+ },
401
+ };
402
+
403
+ module.exports = { recommendedTs, recommendedTemplate, recommendedSpec };
package/src/index.js CHANGED
@@ -30,6 +30,8 @@ const preferCloneEqual = require('./rules/prefer-clone-equal');
30
30
  const noDocumentCookie = require('./rules/no-document-cookie');
31
31
  const injectMemberAccessibility = require('./rules/inject-member-accessibility');
32
32
  const noPublicProperty = require('./rules/no-public-property');
33
+ const noRedundantInternal = require('./rules/no-redundant-internal');
34
+ const templateMemberAccessibility = require('./rules/template-member-accessibility');
33
35
  const noAngularRouterApi = require('./rules/no-angular-router-api');
34
36
  const noWindowLocation = require('./rules/no-window-location');
35
37
  const noLocaleId = require('./rules/no-locale-id');
@@ -41,9 +43,12 @@ const noEmptyAngularMetadataArrays = require('./rules/no-empty-angular-metadata-
41
43
  const noStandaloneFlag = require('./rules/no-standalone-flag');
42
44
  const noEmptyNewlinesBetweenImports = require('./rules/no-empty-newlines-between-imports');
43
45
  const angularDecoratorPropertyOrder = require('./rules/angular-decorator-property-order');
46
+ const classMemberOrder = require('./rules/class-member-order');
47
+ const classConstantProperty = require('./rules/class-constant-property');
44
48
  const preferConciseAngularHostDirectives = require('./rules/prefer-concise-angular-host-directives');
45
49
  const preferConciseAngularStyleMetadata = require('./rules/prefer-concise-angular-style-metadata');
46
- const { recommendedTs, recommendedTemplate } = require('./configs/recommended');
50
+ const noTypedInjectedElementRef = require('./rules/no-typed-injected-element-ref');
51
+ const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
47
52
 
48
53
  /** @type {import('eslint').ESLint.Plugin} */
49
54
  const plugin = {
@@ -81,6 +86,8 @@ const plugin = {
81
86
  'no-document-cookie': noDocumentCookie,
82
87
  'inject-member-accessibility': injectMemberAccessibility,
83
88
  'no-public-property': noPublicProperty,
89
+ 'no-redundant-internal': noRedundantInternal,
90
+ 'template-member-accessibility': templateMemberAccessibility,
84
91
  'no-angular-router-api': noAngularRouterApi,
85
92
  'no-window-location': noWindowLocation,
86
93
  'no-locale-id': noLocaleId,
@@ -92,8 +99,11 @@ const plugin = {
92
99
  'no-standalone-flag': noStandaloneFlag,
93
100
  'no-empty-newlines-between-imports': noEmptyNewlinesBetweenImports,
94
101
  'angular-decorator-property-order': angularDecoratorPropertyOrder,
102
+ 'class-member-order': classMemberOrder,
103
+ 'class-constant-property': classConstantProperty,
95
104
  'prefer-concise-angular-host-directives': preferConciseAngularHostDirectives,
96
105
  'prefer-concise-angular-style-metadata': preferConciseAngularStyleMetadata,
106
+ 'no-typed-injected-element-ref': noTypedInjectedElementRef,
97
107
  },
98
108
  };
99
109
 
@@ -118,7 +128,7 @@ const configs = {
118
128
  * Both together as a flat array — the most common usage:
119
129
  * export default [...baseConfig, ...ethlete.configs.recommended]
120
130
  */
121
- recommended: [recommendedTsWithPlugin, recommendedTemplateWithPlugin],
131
+ recommended: [recommendedTsWithPlugin, recommendedTemplateWithPlugin, recommendedSpec],
122
132
  };
123
133
 
124
134
  const ethletePlugin = { ...plugin, configs };
@@ -0,0 +1,344 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const SCREAMING_CASE_RE = /^[A-Z][A-Z0-9_]*$/;
5
+
6
+ const REACTIVE_APIS = new Set([
7
+ 'signal',
8
+ 'computed',
9
+ 'linkedSignal',
10
+ 'input',
11
+ 'model',
12
+ 'output',
13
+ 'outputFromObservable',
14
+ 'viewChild',
15
+ 'viewChildren',
16
+ 'contentChild',
17
+ 'contentChildren',
18
+ 'inject',
19
+ 'effect',
20
+ 'afterNextRender',
21
+ 'afterRender',
22
+ 'toSignal',
23
+ 'toObservable',
24
+ ]);
25
+
26
+ /**
27
+ * @param {any} node
28
+ */
29
+ const getMemberName = (node) => {
30
+ if (!node || !node.key) return null;
31
+
32
+ if (node.key.type === 'Identifier' || node.key.type === 'PrivateIdentifier') {
33
+ return node.key.name;
34
+ }
35
+
36
+ if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
37
+ return node.key.value;
38
+ }
39
+
40
+ return null;
41
+ };
42
+
43
+ /**
44
+ * @param {any} node
45
+ */
46
+ const getCallRootName = (node) => {
47
+ if (!node || node.type !== 'CallExpression') return null;
48
+
49
+ const { callee } = node;
50
+
51
+ if (callee.type === 'Identifier') {
52
+ return callee.name;
53
+ }
54
+
55
+ if (callee.type === 'MemberExpression' && callee.object.type === 'Identifier') {
56
+ return callee.object.name;
57
+ }
58
+
59
+ return null;
60
+ };
61
+
62
+ /**
63
+ * @param {any} node
64
+ */
65
+ const isReactiveInitializer = (node) => {
66
+ const apiName = getCallRootName(node);
67
+ return apiName !== null && REACTIVE_APIS.has(apiName);
68
+ };
69
+
70
+ /**
71
+ * @param {any} node
72
+ */
73
+ const isThisReference = (node) => node && (node.type === 'ThisExpression' || node.type === 'Super');
74
+
75
+ /**
76
+ * @param {any} node
77
+ */
78
+ const isConstantExpression = (node) => {
79
+ if (!node) return false;
80
+
81
+ switch (node.type) {
82
+ case 'Literal':
83
+ case 'Identifier':
84
+ return true;
85
+ case 'TemplateLiteral':
86
+ return node.expressions.every((expression) => isConstantExpression(expression));
87
+ case 'UnaryExpression':
88
+ case 'UpdateExpression':
89
+ return isConstantExpression(node.argument);
90
+ case 'BinaryExpression':
91
+ case 'LogicalExpression':
92
+ return isConstantExpression(node.left) && isConstantExpression(node.right);
93
+ case 'ConditionalExpression':
94
+ return (
95
+ isConstantExpression(node.test) && isConstantExpression(node.consequent) && isConstantExpression(node.alternate)
96
+ );
97
+ case 'ArrayExpression':
98
+ return node.elements.every((element) => {
99
+ if (element === null) return true;
100
+
101
+ if (element.type === 'SpreadElement') {
102
+ return isConstantExpression(element.argument);
103
+ }
104
+
105
+ return isConstantExpression(element);
106
+ });
107
+ case 'ObjectExpression':
108
+ return node.properties.every((property) => {
109
+ if (property.type === 'SpreadElement') {
110
+ return isConstantExpression(property.argument);
111
+ }
112
+
113
+ if (property.type !== 'Property' || property.kind !== 'init') {
114
+ return false;
115
+ }
116
+
117
+ if (property.computed && !isConstantExpression(property.key)) {
118
+ return false;
119
+ }
120
+
121
+ return isConstantExpression(property.value);
122
+ });
123
+ case 'MemberExpression':
124
+ if (isThisReference(node.object)) {
125
+ return false;
126
+ }
127
+
128
+ if (!isConstantExpression(node.object)) {
129
+ return false;
130
+ }
131
+
132
+ return node.computed ? isConstantExpression(node.property) : true;
133
+ case 'TSAsExpression':
134
+ case 'TSTypeAssertion':
135
+ case 'TSNonNullExpression':
136
+ case 'ChainExpression':
137
+ return isConstantExpression(node.expression);
138
+ case 'ParenthesizedExpression':
139
+ return isConstantExpression(node.expression);
140
+ case 'SequenceExpression':
141
+ return node.expressions.every((expression) => isConstantExpression(expression));
142
+ default:
143
+ return false;
144
+ }
145
+ };
146
+
147
+ /**
148
+ * @param {any} node
149
+ */
150
+ const getWrittenMemberName = (node) => {
151
+ if (!node || node.type !== 'MemberExpression' || !isThisReference(node.object)) {
152
+ return null;
153
+ }
154
+
155
+ if (!node.computed && (node.property.type === 'Identifier' || node.property.type === 'PrivateIdentifier')) {
156
+ return node.property.name;
157
+ }
158
+
159
+ if (node.computed && node.property.type === 'Literal' && typeof node.property.value === 'string') {
160
+ return node.property.value;
161
+ }
162
+
163
+ return null;
164
+ };
165
+
166
+ /**
167
+ * @param {any} target
168
+ * @param {Set<string>} writtenMembers
169
+ */
170
+ const collectAssignedMemberNames = (target, writtenMembers) => {
171
+ if (!target) return;
172
+
173
+ if (target.type === 'MemberExpression') {
174
+ const memberName = getWrittenMemberName(target);
175
+
176
+ if (memberName !== null) {
177
+ writtenMembers.add(memberName);
178
+ }
179
+
180
+ return;
181
+ }
182
+
183
+ if (target.type === 'ArrayPattern') {
184
+ for (const element of target.elements) {
185
+ collectAssignedMemberNames(element, writtenMembers);
186
+ }
187
+
188
+ return;
189
+ }
190
+
191
+ if (target.type === 'ObjectPattern') {
192
+ for (const property of target.properties) {
193
+ if (property.type === 'Property') {
194
+ collectAssignedMemberNames(property.value, writtenMembers);
195
+ } else if (property.type === 'RestElement') {
196
+ collectAssignedMemberNames(property.argument, writtenMembers);
197
+ }
198
+ }
199
+
200
+ return;
201
+ }
202
+
203
+ if (target.type === 'AssignmentPattern') {
204
+ collectAssignedMemberNames(target.left, writtenMembers);
205
+
206
+ return;
207
+ }
208
+
209
+ if (target.type === 'RestElement') {
210
+ collectAssignedMemberNames(target.argument, writtenMembers);
211
+ }
212
+ };
213
+
214
+ /**
215
+ * @param {any} node
216
+ * @param {Set<string>} writtenMembers
217
+ */
218
+ const collectWrittenMembers = (node, writtenMembers) => {
219
+ if (!node || typeof node !== 'object') return;
220
+
221
+ if (Array.isArray(node)) {
222
+ for (const entry of node) {
223
+ collectWrittenMembers(entry, writtenMembers);
224
+ }
225
+
226
+ return;
227
+ }
228
+
229
+ if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
230
+ return;
231
+ }
232
+
233
+ if (node.type === 'AssignmentExpression') {
234
+ collectAssignedMemberNames(node.left, writtenMembers);
235
+ }
236
+
237
+ if (node.type === 'UpdateExpression') {
238
+ const memberName = getWrittenMemberName(node.argument);
239
+
240
+ if (memberName !== null) {
241
+ writtenMembers.add(memberName);
242
+ }
243
+ }
244
+
245
+ for (const [key, value] of Object.entries(node)) {
246
+ if (key === 'parent' || key === 'range' || key === 'loc') continue;
247
+ if (!value || typeof value !== 'object') continue;
248
+
249
+ collectWrittenMembers(value, writtenMembers);
250
+ }
251
+ };
252
+
253
+ /**
254
+ * @param {any} classBodyNode
255
+ */
256
+ const getWrittenMembers = (classBodyNode) => {
257
+ const writtenMembers = new Set();
258
+
259
+ for (const member of classBodyNode.body) {
260
+ collectWrittenMembers(member, writtenMembers);
261
+ }
262
+
263
+ return writtenMembers;
264
+ };
265
+
266
+ /**
267
+ * @param {any} node
268
+ */
269
+ const isConstantProperty = (node) => {
270
+ if (node.type !== 'PropertyDefinition' || node.static || node.computed || !node.value) {
271
+ return false;
272
+ }
273
+
274
+ if (isReactiveInitializer(node.value)) {
275
+ return false;
276
+ }
277
+
278
+ return isConstantExpression(node.value);
279
+ };
280
+
281
+ /** @type {import('eslint').Rule.RuleModule} */
282
+ const classConstantProperty = {
283
+ meta: {
284
+ type: 'suggestion',
285
+ fixable: 'code',
286
+ docs: {
287
+ description: 'Require true class constants to use readonly and SCREAMING_CASE.',
288
+ recommended: true,
289
+ },
290
+ schema: [],
291
+ messages: {
292
+ shouldBeReadonly: "'{{name}}' is a class constant and should be declared readonly.",
293
+ shouldUseScreamingCase: "Readonly class constant '{{name}}' should use SCREAMING_CASE.",
294
+ },
295
+ },
296
+ create(context) {
297
+ return {
298
+ ClassBody(node) {
299
+ const writtenMembers = getWrittenMembers(node);
300
+
301
+ for (const member of node.body) {
302
+ if (!isConstantProperty(member)) {
303
+ continue;
304
+ }
305
+
306
+ const memberName = getMemberName(member);
307
+
308
+ if (memberName === null || writtenMembers.has(memberName)) {
309
+ continue;
310
+ }
311
+
312
+ if (!member.readonly) {
313
+ context.report({
314
+ node: member,
315
+ messageId: 'shouldBeReadonly',
316
+ data: { name: memberName },
317
+ fix(fixer) {
318
+ const keyToken = context.sourceCode.getFirstToken(member.key);
319
+
320
+ if (!keyToken) {
321
+ return null;
322
+ }
323
+
324
+ return fixer.insertTextBefore(keyToken, 'readonly ');
325
+ },
326
+ });
327
+
328
+ continue;
329
+ }
330
+
331
+ if (!SCREAMING_CASE_RE.test(memberName)) {
332
+ context.report({
333
+ node: member.key,
334
+ messageId: 'shouldUseScreamingCase',
335
+ data: { name: memberName },
336
+ });
337
+ }
338
+ }
339
+ },
340
+ };
341
+ },
342
+ };
343
+
344
+ module.exports = classConstantProperty;