@ethlete/eslint-plugin 1.0.0-next.22 → 1.0.0-next.23
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 +11 -0
- package/package.json +3 -4
- package/src/configs/recommended.js +20 -10
- package/src/index.js +4 -1
- package/src/rules/class-constant-property.js +27 -0
- package/src/rules/guard-return-newline.js +5 -4
- package/src/rules/inject-member-accessibility.js +17 -21
- package/src/rules/internals/angular-io.js +19 -1
- package/src/rules/internals/angular-member-visibility.js +5 -5
- package/src/rules/internals/angular-metadata-fix.js +22 -0
- package/src/rules/internals/member-accessibility-fix.js +26 -0
- package/src/rules/no-angular-seo-services.js +7 -7
- package/src/rules/no-direct-dom-manipulation.js +17 -0
- package/src/rules/no-effect-cleanup-return.js +8 -6
- package/src/rules/no-empty-angular-metadata-arrays.js +3 -19
- package/src/rules/no-enum.js +115 -0
- package/src/rules/no-leading-underscore-class-member.js +7 -0
- package/src/rules/no-legacy-angular-decorators.js +8 -0
- package/src/rules/no-legacy-prepare-without-injector.js +42 -26
- package/src/rules/no-legacy-query-import.js +1 -1
- package/src/rules/no-locale-id.js +2 -14
- package/src/rules/no-member-alias.js +17 -0
- package/src/rules/no-pipe-logic.js +1 -0
- package/src/rules/no-readonly-signal.js +13 -5
- package/src/rules/no-redundant-internal.js +21 -2
- package/src/rules/no-redundant-on-push-change-detection.js +28 -38
- package/src/rules/no-standalone-flag.js +4 -19
- package/src/rules/no-trivial-return-type.js +1 -0
- package/src/rules/no-trivial-wrapper-method.js +3 -0
- package/src/rules/no-type-only-import.js +7 -2
- package/src/rules/no-typed-injected-element-ref.js +16 -9
- package/src/rules/no-unused-class-member.js +28 -5
- package/src/rules/prefer-match-media.js +2 -2
- package/src/rules/prefer-scroll-state.js +3 -67
- package/src/rules/prefer-static-boolean-properties.js +30 -0
- package/src/rules/prefer-viewport-size.js +1 -7
- package/src/rules/require-form-submit.js +2 -28
- package/src/rules/require-on-push-change-detection.js +31 -11
- package/src/rules/require-view-encapsulation-none.js +31 -9
- package/src/rules/template-member-accessibility.js +4 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0-next.23
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#3069](https://github.com/ethlete-io/ethdk/pull/3069) [`a9113cb`](https://github.com/ethlete-io/ethdk/commit/a9113cb6ce63942ffce0e26ca7df7f0556a14838) Thanks [@github-actions](https://github.com/apps/github-actions)! - New rule `ethlete/no-enum`. It bans `enum` and `const enum`, and auto-fixes a string enum into a
|
|
8
|
+
const object plus a derived union type.
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- [#3069](https://github.com/ethlete-io/ethdk/pull/3069) [`a9113cb`](https://github.com/ethlete-io/ethdk/commit/a9113cb6ce63942ffce0e26ca7df7f0556a14838) Thanks [@github-actions](https://github.com/apps/github-actions)! - Harden unsafe fixers, reduce contract and test false positives, and correct replacement guidance across the recommended rules.
|
|
13
|
+
|
|
3
14
|
## 1.0.0-next.22
|
|
4
15
|
|
|
5
16
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ethlete/eslint-plugin",
|
|
3
|
-
"version": "1.0.0-next.
|
|
3
|
+
"version": "1.0.0-next.23",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"peerDependencies": {
|
|
8
8
|
"@angular-eslint/eslint-plugin-template": ">=21.0.0",
|
|
9
|
-
"@angular/core": "
|
|
9
|
+
"@angular/core": ">=21.0.0",
|
|
10
10
|
"@typescript-eslint/eslint-plugin": ">=8.0.0",
|
|
11
11
|
"eslint": ">=9.0.0",
|
|
12
|
-
"
|
|
13
|
-
"typescript": "6.0.3"
|
|
12
|
+
"typescript": ">=5.9.0"
|
|
14
13
|
}
|
|
15
14
|
}
|
|
@@ -107,11 +107,8 @@ const recommendedTs = {
|
|
|
107
107
|
|
|
108
108
|
'no-restricted-syntax': [
|
|
109
109
|
'error',
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
selector: 'TSEnumDeclaration',
|
|
113
|
-
message: 'No enums. Use a const object + derived union type instead.',
|
|
114
|
-
},
|
|
110
|
+
// enum / const enum is owned by ethlete/no-enum, so a lib can keep it on while it works
|
|
111
|
+
// through the rest of this rule's backlog.
|
|
115
112
|
// No standalone function declarations / expressions — use arrow functions
|
|
116
113
|
{
|
|
117
114
|
selector: 'FunctionDeclaration',
|
|
@@ -245,6 +242,9 @@ const recommendedTs = {
|
|
|
245
242
|
// No async/await — asynchronous work is modelled as cold Observables
|
|
246
243
|
'ethlete/no-async-await': 'error',
|
|
247
244
|
|
|
245
|
+
// No enum / const enum — use a const object with `as const` plus a derived union type
|
|
246
|
+
'ethlete/no-enum': 'error',
|
|
247
|
+
|
|
248
248
|
// No .subscribe() inside effect() or computed()
|
|
249
249
|
'ethlete/no-rxjs-in-effect': 'error',
|
|
250
250
|
|
|
@@ -272,7 +272,7 @@ const recommendedTs = {
|
|
|
272
272
|
// No raw IntersectionObserver / MutationObserver / ResizeObserver — use @ethlete/core signal utils
|
|
273
273
|
'ethlete/no-native-observers': 'error',
|
|
274
274
|
|
|
275
|
-
// Prefer injectViewportSize() over window.innerWidth / innerHeight
|
|
275
|
+
// Prefer injectViewportSize() over window.innerWidth / innerHeight
|
|
276
276
|
'ethlete/prefer-viewport-size': 'warn',
|
|
277
277
|
|
|
278
278
|
// Prefer injectMediaQueryIsMatched() / injectBreakpointIsMatched() etc. over .matchMedia()
|
|
@@ -297,7 +297,7 @@ const recommendedTs = {
|
|
|
297
297
|
'ethlete/no-legacy-angular-decorators': 'error',
|
|
298
298
|
|
|
299
299
|
// No Angular Title/Meta services — use @ethlete/core SEO utilities instead
|
|
300
|
-
// (
|
|
300
|
+
// (applyHeadTitleBinding, applyMetaBinding, applyLinkBinding, etc.)
|
|
301
301
|
'ethlete/no-angular-seo-services': 'error',
|
|
302
302
|
|
|
303
303
|
// No JSON.parse(JSON.stringify()), structuredClone, or lodash cloneDeep / isEqual
|
|
@@ -414,20 +414,30 @@ const recommendedTemplate = {
|
|
|
414
414
|
// (warn + suggestion-only: the rewrite is only safe when the input has a booleanAttribute transform,
|
|
415
415
|
// which a template rule cannot verify)
|
|
416
416
|
'ethlete/prefer-static-boolean-properties': 'warn',
|
|
417
|
-
// A form must handle its own submission
|
|
417
|
+
// A form must handle its own submission
|
|
418
418
|
'ethlete/require-form-submit': 'error',
|
|
419
419
|
},
|
|
420
420
|
};
|
|
421
421
|
|
|
422
422
|
/**
|
|
423
|
-
* Relaxed rules for test/spec files.
|
|
424
|
-
* Non-null assertions are common and intentional in tests.
|
|
423
|
+
* Relaxed rules for test/spec files, including common DOM assertions and async test orchestration.
|
|
425
424
|
* @type {import('eslint').Linter.Config}
|
|
426
425
|
*/
|
|
427
426
|
const recommendedSpec = {
|
|
428
427
|
files: ['**/*.spec.ts'],
|
|
429
428
|
rules: {
|
|
430
429
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
|
430
|
+
'no-restricted-globals': 'off',
|
|
431
|
+
'ethlete/no-async-await': 'off',
|
|
432
|
+
'ethlete/no-direct-dom-manipulation': 'off',
|
|
433
|
+
'ethlete/no-document-cookie': 'off',
|
|
434
|
+
'ethlete/no-dom-query': 'off',
|
|
435
|
+
'ethlete/no-native-observers': 'off',
|
|
436
|
+
'ethlete/no-window-location': 'off',
|
|
437
|
+
'ethlete/prefer-element-dimensions': 'off',
|
|
438
|
+
'ethlete/prefer-match-media': 'off',
|
|
439
|
+
'ethlete/prefer-scroll-state': 'off',
|
|
440
|
+
'ethlete/prefer-viewport-size': 'off',
|
|
431
441
|
},
|
|
432
442
|
};
|
|
433
443
|
|
package/src/index.js
CHANGED
|
@@ -59,13 +59,15 @@ const requireFormSubmit = require('./rules/require-form-submit');
|
|
|
59
59
|
const noCdkImport = require('./rules/no-cdk-import');
|
|
60
60
|
const noLegacyQueryImport = require('./rules/no-legacy-query-import');
|
|
61
61
|
const noAsyncAwait = require('./rules/no-async-await');
|
|
62
|
+
const noEnum = require('./rules/no-enum');
|
|
62
63
|
const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
|
|
64
|
+
const { version } = require('../package.json');
|
|
63
65
|
|
|
64
66
|
/** @type {import('eslint').ESLint.Plugin} */
|
|
65
67
|
const plugin = {
|
|
66
68
|
meta: {
|
|
67
69
|
name: '@ethlete/eslint-plugin',
|
|
68
|
-
version
|
|
70
|
+
version,
|
|
69
71
|
},
|
|
70
72
|
rules: {
|
|
71
73
|
'no-inject-chain': noInjectChain,
|
|
@@ -126,6 +128,7 @@ const plugin = {
|
|
|
126
128
|
'no-cdk-import': noCdkImport,
|
|
127
129
|
'no-legacy-query-import': noLegacyQueryImport,
|
|
128
130
|
'no-async-await': noAsyncAwait,
|
|
131
|
+
'no-enum': noEnum,
|
|
129
132
|
},
|
|
130
133
|
};
|
|
131
134
|
|
|
@@ -249,6 +249,11 @@ const collectWrittenMembers = (node, writtenMembers) => {
|
|
|
249
249
|
|
|
250
250
|
if (node.type === 'AssignmentExpression') {
|
|
251
251
|
collectAssignedMemberNames(node.left, writtenMembers);
|
|
252
|
+
|
|
253
|
+
if (node.left.type === 'MemberExpression' && node.left.object.type === 'MemberExpression') {
|
|
254
|
+
const memberName = getWrittenMemberName(node.left.object);
|
|
255
|
+
if (memberName !== null) writtenMembers.add(memberName);
|
|
256
|
+
}
|
|
252
257
|
}
|
|
253
258
|
|
|
254
259
|
if (node.type === 'UpdateExpression') {
|
|
@@ -259,6 +264,20 @@ const collectWrittenMembers = (node, writtenMembers) => {
|
|
|
259
264
|
}
|
|
260
265
|
}
|
|
261
266
|
|
|
267
|
+
if (
|
|
268
|
+
node.type === 'CallExpression' &&
|
|
269
|
+
node.callee.type === 'MemberExpression' &&
|
|
270
|
+
node.callee.object.type === 'MemberExpression' &&
|
|
271
|
+
!node.callee.computed &&
|
|
272
|
+
node.callee.property.type === 'Identifier' &&
|
|
273
|
+
['add', 'clear', 'delete', 'pop', 'push', 'reverse', 'set', 'shift', 'sort', 'splice', 'unshift'].includes(
|
|
274
|
+
node.callee.property.name,
|
|
275
|
+
)
|
|
276
|
+
) {
|
|
277
|
+
const memberName = getWrittenMemberName(node.callee.object);
|
|
278
|
+
if (memberName !== null) writtenMembers.add(memberName);
|
|
279
|
+
}
|
|
280
|
+
|
|
262
281
|
for (const [key, value] of Object.entries(node)) {
|
|
263
282
|
if (key === 'parent' || key === 'range' || key === 'loc') continue;
|
|
264
283
|
if (!value || typeof value !== 'object') continue;
|
|
@@ -326,6 +345,14 @@ const classConstantProperty = {
|
|
|
326
345
|
continue;
|
|
327
346
|
}
|
|
328
347
|
|
|
348
|
+
if (
|
|
349
|
+
!member.readonly &&
|
|
350
|
+
((member.value.type === 'Literal' && member.value.value === null) ||
|
|
351
|
+
(member.value.type === 'Identifier' && member.value.name === 'undefined'))
|
|
352
|
+
) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
329
356
|
if (memberName === 'transform' && hasPipeDecorator(node.parent)) {
|
|
330
357
|
continue;
|
|
331
358
|
}
|
|
@@ -58,7 +58,6 @@ const rule = {
|
|
|
58
58
|
const prevEndLine = prevStatement.loc.end.line;
|
|
59
59
|
const returnStartLine = node.loc.start.line;
|
|
60
60
|
|
|
61
|
-
// Need at least one empty line (gap of 2+ lines) between prev statement and return
|
|
62
61
|
if (returnStartLine - prevEndLine >= 2) return;
|
|
63
62
|
|
|
64
63
|
const sourceCode = context.sourceCode;
|
|
@@ -67,10 +66,12 @@ const rule = {
|
|
|
67
66
|
node,
|
|
68
67
|
messageId: 'missingEmptyLine',
|
|
69
68
|
fix(fixer) {
|
|
70
|
-
const
|
|
71
|
-
|
|
69
|
+
const trailingComment = sourceCode
|
|
70
|
+
.getCommentsAfter(/** @type {import('eslint').Rule.Node} */ (prevStatement))
|
|
71
|
+
.find((comment) => comment.loc.start.line === prevEndLine && comment.range[1] < node.range[0]);
|
|
72
|
+
const insertionNode = trailingComment ?? prevStatement;
|
|
72
73
|
|
|
73
|
-
return fixer.insertTextAfter(
|
|
74
|
+
return fixer.insertTextAfter(insertionNode, '\n');
|
|
74
75
|
},
|
|
75
76
|
});
|
|
76
77
|
},
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const { getMemberName, isReferencedFromTemplateOrHost } = require('./internals/angular-member-visibility');
|
|
5
|
+
const { buildAccessibilityFix } = require('./internals/member-accessibility-fix');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* @param {any} node
|
|
@@ -12,25 +13,6 @@ const isInjectCall = (node) => {
|
|
|
12
13
|
return node.value.callee.type === 'Identifier' && node.value.callee.name === 'inject';
|
|
13
14
|
};
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
-
* @param {any} node
|
|
17
|
-
* @param {import('eslint').SourceCode} sourceCode
|
|
18
|
-
* @param {'private' | 'protected'} expectedAccessibility
|
|
19
|
-
*/
|
|
20
|
-
const buildFix = (node, sourceCode, expectedAccessibility) => {
|
|
21
|
-
const keyToken = sourceCode.getFirstToken(node.key);
|
|
22
|
-
if (!keyToken) return null;
|
|
23
|
-
|
|
24
|
-
if (node.accessibility) {
|
|
25
|
-
const firstToken = sourceCode.getFirstToken(node);
|
|
26
|
-
if (!firstToken) return null;
|
|
27
|
-
|
|
28
|
-
return (fixer) => fixer.replaceText(firstToken, expectedAccessibility);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return (fixer) => fixer.insertTextBefore(keyToken, `${expectedAccessibility} `);
|
|
32
|
-
};
|
|
33
|
-
|
|
34
16
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
35
17
|
const injectMemberAccessibility = {
|
|
36
18
|
meta: {
|
|
@@ -63,6 +45,7 @@ const injectMemberAccessibility = {
|
|
|
63
45
|
if (!memberName) return;
|
|
64
46
|
|
|
65
47
|
const isTemplateOrHostVisible = isReferencedFromTemplateOrHost(classNode, memberName, context);
|
|
48
|
+
const isAbstractClass = classNode.abstract === true;
|
|
66
49
|
|
|
67
50
|
if (isTemplateOrHostVisible) {
|
|
68
51
|
if (node.accessibility === 'protected' || node.accessibility === 'public') return;
|
|
@@ -71,7 +54,7 @@ const injectMemberAccessibility = {
|
|
|
71
54
|
node,
|
|
72
55
|
messageId: 'shouldBeProtected',
|
|
73
56
|
data: { name: memberName },
|
|
74
|
-
fix:
|
|
57
|
+
fix: buildAccessibilityFix(node, sourceCode, 'protected'),
|
|
75
58
|
});
|
|
76
59
|
|
|
77
60
|
return;
|
|
@@ -79,11 +62,24 @@ const injectMemberAccessibility = {
|
|
|
79
62
|
|
|
80
63
|
if (node.accessibility === 'private' || node.accessibility === 'public') return;
|
|
81
64
|
|
|
65
|
+
if (isAbstractClass) {
|
|
66
|
+
if (node.accessibility === 'protected') return;
|
|
67
|
+
|
|
68
|
+
context.report({
|
|
69
|
+
node,
|
|
70
|
+
messageId: 'shouldBeProtected',
|
|
71
|
+
data: { name: memberName },
|
|
72
|
+
fix: buildAccessibilityFix(node, sourceCode, 'protected'),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
82
78
|
context.report({
|
|
83
79
|
node,
|
|
84
80
|
messageId: 'shouldBePrivate',
|
|
85
81
|
data: { name: memberName },
|
|
86
|
-
fix:
|
|
82
|
+
fix: buildAccessibilityFix(node, sourceCode, 'private'),
|
|
87
83
|
});
|
|
88
84
|
},
|
|
89
85
|
};
|
|
@@ -69,10 +69,28 @@ const getReactiveIo = (node) => {
|
|
|
69
69
|
const factory = getFactoryName(value.callee);
|
|
70
70
|
if (!factory) return null;
|
|
71
71
|
|
|
72
|
+
if (!Object.hasOwn(IO_FACTORIES, factory)) return null;
|
|
72
73
|
const kind = /** @type {Record<string, 'input' | 'model' | 'output'>} */ (IO_FACTORIES)[factory];
|
|
73
74
|
if (!kind) return null;
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
let name = node.key.name;
|
|
77
|
+
for (const argument of value.arguments) {
|
|
78
|
+
if (argument.type !== 'ObjectExpression') continue;
|
|
79
|
+
|
|
80
|
+
const aliasProperty = argument.properties.find(
|
|
81
|
+
(property) =>
|
|
82
|
+
property.type === 'Property' &&
|
|
83
|
+
!property.computed &&
|
|
84
|
+
((property.key.type === 'Identifier' && property.key.name === 'alias') ||
|
|
85
|
+
(property.key.type === 'Literal' && property.key.value === 'alias')),
|
|
86
|
+
);
|
|
87
|
+
if (aliasProperty?.value.type === 'Literal' && typeof aliasProperty.value.value === 'string') {
|
|
88
|
+
name = aliasProperty.value.value;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { kind, name, keyNode: node.key };
|
|
76
94
|
};
|
|
77
95
|
|
|
78
96
|
module.exports = { getReactiveIo };
|
|
@@ -67,8 +67,8 @@ const getStringValue = (node) => {
|
|
|
67
67
|
if (!node) return null;
|
|
68
68
|
if (node.type === 'Literal' && typeof node.value === 'string') return node.value;
|
|
69
69
|
|
|
70
|
-
if (node.type === 'TemplateLiteral'
|
|
71
|
-
return node.quasis
|
|
70
|
+
if (node.type === 'TemplateLiteral') {
|
|
71
|
+
return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join(' ');
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
return null;
|
|
@@ -111,9 +111,9 @@ const templateReferencesMember = (memberName, template) => {
|
|
|
111
111
|
|
|
112
112
|
const escapedName = escapeRegExp(memberName);
|
|
113
113
|
const patterns = [
|
|
114
|
-
new RegExp(`\\{\\{[
|
|
115
|
-
new RegExp(`@[a-zA-Z]
|
|
116
|
-
new RegExp(`@let\\s+[a-zA-Z_$][\\w$]*\\s*=\\s*[
|
|
114
|
+
new RegExp(`\\{\\{[^{}]*\\b${escapedName}\\b[^{}]*\\}\\}`, 'u'),
|
|
115
|
+
new RegExp(`@[a-zA-Z]+(?:\\s+if)?\\s*\\([^)]*\\b${escapedName}\\b[^)]*\\)`, 'u'),
|
|
116
|
+
new RegExp(`@let\\s+[a-zA-Z_$][\\w$]*\\s*=\\s*[^;]*\\b${escapedName}\\b`, 'u'),
|
|
117
117
|
new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
|
|
118
118
|
new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
|
|
119
119
|
new RegExp(`\\[[^\\]]+\\]\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
6
|
+
* @param {any} metadata
|
|
7
|
+
* @param {any} property
|
|
8
|
+
*/
|
|
9
|
+
const getMetadataEntryRemovalRange = (sourceCode, metadata, property) => {
|
|
10
|
+
const entries = metadata.properties;
|
|
11
|
+
const index = entries.indexOf(property);
|
|
12
|
+
const openingBrace = sourceCode.getFirstToken(metadata);
|
|
13
|
+
const closingBrace = sourceCode.getLastToken(metadata);
|
|
14
|
+
|
|
15
|
+
if (!openingBrace || !closingBrace || index === -1) return property.range;
|
|
16
|
+
if (entries.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
|
|
17
|
+
if (index < entries.length - 1) return [property.range[0], entries[index + 1].range[0]];
|
|
18
|
+
|
|
19
|
+
return [entries[index - 1].range[1], property.range[1]];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
module.exports = { getMetadataEntryRemovalRange };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {any} node
|
|
6
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
7
|
+
* @param {'private' | 'protected' | 'public'} accessibility
|
|
8
|
+
*/
|
|
9
|
+
const buildAccessibilityFix = (node, sourceCode, accessibility) => {
|
|
10
|
+
const decoratorEnd = node.decorators?.at(-1)?.range[1] ?? node.range[0];
|
|
11
|
+
const tokens = sourceCode.getTokens(node);
|
|
12
|
+
const accessibilityToken = tokens.find(
|
|
13
|
+
(token) => token.range[0] >= decoratorEnd && token.value === node.accessibility,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
if (accessibilityToken) {
|
|
17
|
+
return (fixer) => fixer.replaceText(accessibilityToken, accessibility);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const insertionToken = tokens.find((token) => token.range[0] >= decoratorEnd);
|
|
21
|
+
if (!insertionToken) return null;
|
|
22
|
+
|
|
23
|
+
return (fixer) => fixer.insertTextBefore(insertionToken, `${accessibility} `);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
module.exports = { buildAccessibilityFix };
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
* import { Title, Meta } from '@angular/platform-browser';
|
|
19
19
|
*
|
|
20
20
|
* ✅ Use the @ethlete/core SEO utilities:
|
|
21
|
-
* import {
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* import { applyHeadTitleBinding, applyMetaBinding } from '@ethlete/core';
|
|
22
|
+
* applyHeadTitleBinding(title);
|
|
23
|
+
* applyMetaBinding(meta);
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
@@ -32,12 +32,12 @@ const rule = {
|
|
|
32
32
|
},
|
|
33
33
|
schema: [],
|
|
34
34
|
messages: {
|
|
35
|
-
noInjectTitle: "Do not inject Angular's Title service. Use
|
|
36
|
-
noInjectMeta: "Do not inject Angular's Meta service. Use
|
|
35
|
+
noInjectTitle: "Do not inject Angular's Title service. Use applyHeadTitleBinding() from '@ethlete/core' instead.",
|
|
36
|
+
noInjectMeta: "Do not inject Angular's Meta service. Use applyMetaBinding() from '@ethlete/core' instead.",
|
|
37
37
|
noImportTitle:
|
|
38
|
-
"Do not import Title from '@angular/platform-browser'. Use
|
|
38
|
+
"Do not import Title from '@angular/platform-browser'. Use applyHeadTitleBinding() from '@ethlete/core' instead.",
|
|
39
39
|
noImportMeta:
|
|
40
|
-
"Do not import Meta from '@angular/platform-browser'. Use
|
|
40
|
+
"Do not import Meta from '@angular/platform-browser'. Use applyMetaBinding() from '@ethlete/core' instead.",
|
|
41
41
|
},
|
|
42
42
|
},
|
|
43
43
|
create(context) {
|
|
@@ -100,6 +100,23 @@ const noDirectDomManipulation = {
|
|
|
100
100
|
|
|
101
101
|
const methodName = callee.property.name;
|
|
102
102
|
|
|
103
|
+
if (
|
|
104
|
+
methodName === 'setProperty' &&
|
|
105
|
+
callee.object.type === 'MemberExpression' &&
|
|
106
|
+
callee.object.property.type === 'Identifier' &&
|
|
107
|
+
callee.object.property.name === 'style'
|
|
108
|
+
) {
|
|
109
|
+
context.report({
|
|
110
|
+
node,
|
|
111
|
+
messageId: 'domMutation',
|
|
112
|
+
data: {
|
|
113
|
+
method: methodName,
|
|
114
|
+
alternative: 'renderer.setStyle(el, prop, value, RendererStyleFlags2.DashCase)',
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
103
120
|
// ── classList.add / .remove / .toggle / .replace ─────────────────────
|
|
104
121
|
if (
|
|
105
122
|
callee.object.type === 'MemberExpression' &&
|
|
@@ -147,15 +147,17 @@ const noEffectCleanupReturn = {
|
|
|
147
147
|
return;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
// Only the mechanical shape is auto-fixed: a zero-parameter callback whose single `return` hands
|
|
151
|
-
// back a function literal. With an existing parameter, an early `return`, or a returned reference,
|
|
152
|
-
// the intent needs a human.
|
|
153
|
-
// The parameter list's `(` — the first one before the body, so this works for `() => {}` and
|
|
154
|
-
// `function () {}` alike (their first token differs).
|
|
155
150
|
const openParen = sourceCode
|
|
156
151
|
.getTokens(callback)
|
|
157
152
|
.find((token) => token.value === '(' && token.range[1] <= callback.body.range[0]);
|
|
158
|
-
const
|
|
153
|
+
const callbackScope = sourceCode.getScope(callback);
|
|
154
|
+
const hasOnCleanupBinding = callbackScope.variables.some((variable) => variable.name === 'onCleanup');
|
|
155
|
+
const fixable =
|
|
156
|
+
callback.params.length === 0 &&
|
|
157
|
+
returns.length === 1 &&
|
|
158
|
+
cleanupReturns.length === 1 &&
|
|
159
|
+
callback.body.body.at(-1) === cleanupReturns[0] &&
|
|
160
|
+
!hasOnCleanupBinding;
|
|
159
161
|
|
|
160
162
|
for (const statement of cleanupReturns) {
|
|
161
163
|
context.report({
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { getMetadataEntryRemovalRange } = require('./internals/angular-metadata-fix');
|
|
5
|
+
|
|
4
6
|
/** @typedef {'Component' | 'Directive'} TDecoratorName */
|
|
5
7
|
|
|
6
8
|
/**
|
|
@@ -28,24 +30,6 @@ const getDecoratorName = (node) => {
|
|
|
28
30
|
return expression.type === 'Identifier' ? expression.name : null;
|
|
29
31
|
};
|
|
30
32
|
|
|
31
|
-
/**
|
|
32
|
-
* @param {import('eslint').SourceCode} sourceCode
|
|
33
|
-
* @param {any} metadata
|
|
34
|
-
* @param {any} property
|
|
35
|
-
*/
|
|
36
|
-
const getRemovalRange = (sourceCode, metadata, property) => {
|
|
37
|
-
const properties = metadata.properties.filter((entry) => entry.type === 'Property');
|
|
38
|
-
const propertyIndex = properties.indexOf(property);
|
|
39
|
-
const openingBrace = sourceCode.getFirstToken(metadata);
|
|
40
|
-
const closingBrace = sourceCode.getLastToken(metadata);
|
|
41
|
-
|
|
42
|
-
if (!openingBrace || !closingBrace) return property.range;
|
|
43
|
-
if (properties.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
|
|
44
|
-
if (propertyIndex < properties.length - 1) return [property.range[0], properties[propertyIndex + 1].range[0]];
|
|
45
|
-
|
|
46
|
-
return [properties[propertyIndex - 1].range[1], property.range[1]];
|
|
47
|
-
};
|
|
48
|
-
|
|
49
33
|
const MESSAGE_IDS = {
|
|
50
34
|
hostDirectives: 'noEmptyHostDirectives',
|
|
51
35
|
imports: 'noEmptyImports',
|
|
@@ -92,7 +76,7 @@ const noEmptyAngularMetadataArrays = {
|
|
|
92
76
|
node: property,
|
|
93
77
|
messageId: MESSAGE_IDS[propertyName],
|
|
94
78
|
fix(fixer) {
|
|
95
|
-
return fixer.replaceTextRange(
|
|
79
|
+
return fixer.replaceTextRange(getMetadataEntryRemovalRange(sourceCode, metadata, property), '');
|
|
96
80
|
},
|
|
97
81
|
});
|
|
98
82
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallow `enum` and `const enum` — use a const object plus a derived union type.
|
|
6
|
+
*
|
|
7
|
+
* An enum is the only TypeScript construct that emits runtime code from a type declaration, so it
|
|
8
|
+
* cannot be erased and it cannot be tree-shaken. Its members are nominal, so a plain string is not
|
|
9
|
+
* assignable to an enum-typed parameter even when the value matches. A `const enum` is worse: a
|
|
10
|
+
* transpile-only consumer (esbuild, SWC, Babel) has no cross-file type information, so it cannot
|
|
11
|
+
* inline the member and the build fails or emits a missing binding.
|
|
12
|
+
*
|
|
13
|
+
* A const object with `as const` plus a derived union gives the same dotted access, keeps the name
|
|
14
|
+
* usable in a type position, accepts a matching literal, and tree-shakes.
|
|
15
|
+
*
|
|
16
|
+
* BAD:
|
|
17
|
+
* export enum MatchState {
|
|
18
|
+
* Live = 'live',
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* GOOD:
|
|
22
|
+
* export const MatchState = {
|
|
23
|
+
* Live: 'live',
|
|
24
|
+
* } as const;
|
|
25
|
+
*
|
|
26
|
+
* export type MatchState = (typeof MatchState)[keyof typeof MatchState];
|
|
27
|
+
*
|
|
28
|
+
* The fixer runs only on an enum whose members all have a string literal initializer. It widens a
|
|
29
|
+
* member used in a type position (`state: MatchState.Live`), which then needs `typeof`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** typescript-eslint v8 nests members under a TSEnumBody; earlier versions put them on the declaration. */
|
|
33
|
+
const membersOf = (node) => node.body?.members ?? node.members ?? [];
|
|
34
|
+
|
|
35
|
+
const isStringLiteral = (node) => node?.type === 'Literal' && typeof node.value === 'string';
|
|
36
|
+
|
|
37
|
+
/** Only a plain string enum converts to a const object without changing what a member holds. */
|
|
38
|
+
const isFixable = (node) => {
|
|
39
|
+
if (node.declare) return false;
|
|
40
|
+
|
|
41
|
+
const members = membersOf(node);
|
|
42
|
+
|
|
43
|
+
if (!members.length) return false;
|
|
44
|
+
|
|
45
|
+
return members.every(
|
|
46
|
+
(member) => isStringLiteral(member.initializer) && (member.id.type === 'Identifier' || isStringLiteral(member.id)),
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
51
|
+
const noEnum = {
|
|
52
|
+
meta: {
|
|
53
|
+
type: 'problem',
|
|
54
|
+
docs: {
|
|
55
|
+
description: 'Disallow enum and const enum. Use a const object with `as const` plus a derived union type.',
|
|
56
|
+
recommended: true,
|
|
57
|
+
},
|
|
58
|
+
fixable: 'code',
|
|
59
|
+
messages: {
|
|
60
|
+
noEnum:
|
|
61
|
+
"No enums. '{{name}}' emits runtime code that cannot be tree-shaken, and its members are nominal. Use a const object with 'as const' plus a derived union type.",
|
|
62
|
+
noConstEnum:
|
|
63
|
+
"No 'const enum'. A transpile-only consumer (esbuild, SWC, Babel) cannot inline '{{name}}' and its build fails. Use a const object with 'as const' plus a derived union type.",
|
|
64
|
+
},
|
|
65
|
+
schema: [],
|
|
66
|
+
},
|
|
67
|
+
create(context) {
|
|
68
|
+
const { sourceCode } = context;
|
|
69
|
+
|
|
70
|
+
const buildReplacement = (node, target) => {
|
|
71
|
+
const name = node.id.name;
|
|
72
|
+
const indent = ' '.repeat(target.loc.start.column);
|
|
73
|
+
const memberIndent = `${indent} `;
|
|
74
|
+
const exported = target.type === 'ExportNamedDeclaration' ? 'export ' : '';
|
|
75
|
+
|
|
76
|
+
const lines = membersOf(node).flatMap((member) => {
|
|
77
|
+
const comments = sourceCode.getCommentsBefore(member).flatMap((comment) =>
|
|
78
|
+
sourceCode
|
|
79
|
+
.getText(comment)
|
|
80
|
+
.split('\n')
|
|
81
|
+
// A block comment's continuation lines carry one more space so the leading `*` stays aligned.
|
|
82
|
+
.map((line, index) => `${memberIndent}${index === 0 ? '' : ' '}${line.trim()}`),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const key = sourceCode.getText(member.id);
|
|
86
|
+
const value = sourceCode.getText(member.initializer);
|
|
87
|
+
|
|
88
|
+
return [...comments, `${memberIndent}${key}: ${value},`];
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return [
|
|
92
|
+
`${exported}const ${name} = {`,
|
|
93
|
+
...lines,
|
|
94
|
+
`${indent}} as const;`,
|
|
95
|
+
'',
|
|
96
|
+
`${indent}${exported}type ${name} = (typeof ${name})[keyof typeof ${name}];`,
|
|
97
|
+
].join('\n');
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
TSEnumDeclaration(node) {
|
|
102
|
+
const target = node.parent?.type === 'ExportNamedDeclaration' ? node.parent : node;
|
|
103
|
+
|
|
104
|
+
context.report({
|
|
105
|
+
node: node.id,
|
|
106
|
+
messageId: node.const ? 'noConstEnum' : 'noEnum',
|
|
107
|
+
data: { name: node.id.name },
|
|
108
|
+
fix: isFixable(node) ? (fixer) => fixer.replaceTextRange(target.range, buildReplacement(node, target)) : null,
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
module.exports = noEnum;
|
|
@@ -73,11 +73,18 @@ const noLeadingUnderscoreClassMember = {
|
|
|
73
73
|
const frame = classStack.pop();
|
|
74
74
|
if (!frame) return;
|
|
75
75
|
|
|
76
|
+
const targetNameCounts = new Map();
|
|
77
|
+
for (const oldName of frame.membersByName.keys()) {
|
|
78
|
+
const newName = getFixedName(oldName);
|
|
79
|
+
targetNameCounts.set(newName, (targetNameCounts.get(newName) ?? 0) + 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
76
82
|
for (const [oldName, memberNodes] of frame.membersByName) {
|
|
77
83
|
const newName = getFixedName(oldName);
|
|
78
84
|
const canFix =
|
|
79
85
|
newName.length > 0 &&
|
|
80
86
|
!frame.declaredNames.has(newName) &&
|
|
87
|
+
targetNameCounts.get(newName) === 1 &&
|
|
81
88
|
memberNodes.every((memberNode) => memberNode.accessibility === 'private');
|
|
82
89
|
|
|
83
90
|
for (const memberNode of memberNodes) {
|