@ethlete/eslint-plugin 1.0.0-next.22 → 1.0.0-next.24
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 +23 -0
- package/package.json +4 -5
- package/src/configs/recommended.js +23 -10
- package/src/index.js +6 -1
- package/src/rules/angular-decorator-property-order.js +20 -20
- package/src/rules/class-constant-property.js +35 -14
- package/src/rules/class-member-order.js +77 -46
- package/src/rules/guard-return-newline.js +5 -4
- package/src/rules/inject-member-accessibility.js +22 -24
- package/src/rules/internals/angular-io.js +31 -14
- package/src/rules/internals/angular-member-visibility.js +16 -12
- package/src/rules/internals/angular-metadata-fix.js +22 -0
- package/src/rules/internals/import-resolution.js +191 -0
- package/src/rules/internals/member-accessibility-fix.js +26 -0
- package/src/rules/no-angular-router-api.js +23 -29
- package/src/rules/no-angular-seo-services.js +21 -16
- package/src/rules/no-cdk-import.js +7 -14
- package/src/rules/no-direct-dom-manipulation.js +17 -0
- package/src/rules/no-document-cookie.js +3 -0
- package/src/rules/no-effect-cleanup-return.js +15 -9
- package/src/rules/no-empty-angular-metadata-arrays.js +6 -36
- package/src/rules/no-enum.js +115 -0
- package/src/rules/no-inject-chain.js +3 -1
- package/src/rules/no-leading-underscore-class-member.js +33 -3
- package/src/rules/no-legacy-angular-decorators.js +17 -21
- package/src/rules/no-legacy-prepare-without-injector.js +42 -26
- package/src/rules/no-legacy-query-import.js +7 -16
- package/src/rules/no-locale-id.js +6 -18
- package/src/rules/no-member-alias.js +21 -0
- package/src/rules/no-native-html-input-name.js +1 -1
- package/src/rules/no-pipe-logic.js +11 -17
- 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 +32 -56
- package/src/rules/no-rxjs-in-effect.js +12 -8
- package/src/rules/no-screaming-case-local.js +1 -0
- package/src/rules/no-standalone-flag.js +7 -36
- package/src/rules/no-subscribe-in-pipe.js +3 -2
- package/src/rules/no-template-literal-before-inline-template.js +3 -14
- package/src/rules/no-trivial-return-type.js +2 -0
- package/src/rules/no-trivial-wrapper-method.js +13 -16
- package/src/rules/no-type-only-import.js +7 -2
- package/src/rules/no-typed-injected-element-ref.js +23 -19
- package/src/rules/no-unused-class-member.js +34 -14
- package/src/rules/prefer-clone-equal.js +8 -6
- package/src/rules/prefer-concise-angular-host-directives.js +23 -29
- package/src/rules/prefer-concise-angular-style-metadata.js +8 -16
- package/src/rules/prefer-element-dimensions.js +9 -10
- package/src/rules/prefer-linked-signal.js +7 -4
- package/src/rules/prefer-match-media.js +12 -16
- package/src/rules/prefer-present-tense-output.js +1 -1
- package/src/rules/prefer-rxjs-timer.js +3 -1
- 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-dollar-suffix.js +7 -4
- package/src/rules/require-form-submit.js +2 -28
- package/src/rules/require-on-push-change-detection.js +48 -36
- package/src/rules/require-view-encapsulation-none.js +48 -34
- package/src/rules/take-until-destroyed-last.js +64 -0
- package/src/rules/template-member-accessibility.js +12 -27
|
@@ -2,33 +2,17 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const { getMemberName, isReferencedFromTemplateOrHost } = require('./internals/angular-member-visibility');
|
|
5
|
+
const { isImportedAs } = require('./internals/import-resolution');
|
|
6
|
+
const { buildAccessibilityFix } = require('./internals/member-accessibility-fix');
|
|
5
7
|
|
|
6
8
|
/**
|
|
9
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
7
10
|
* @param {any} node
|
|
8
11
|
*/
|
|
9
|
-
const isInjectCall = (node) => {
|
|
12
|
+
const isInjectCall = (sourceCode, node) => {
|
|
10
13
|
if (!node || node.type !== 'PropertyDefinition' || !node.value) return false;
|
|
11
14
|
if (node.value.type !== 'CallExpression') return false;
|
|
12
|
-
return
|
|
13
|
-
};
|
|
14
|
-
|
|
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} `);
|
|
15
|
+
return isImportedAs(sourceCode, node.value.callee, 'inject');
|
|
32
16
|
};
|
|
33
17
|
|
|
34
18
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
@@ -54,7 +38,7 @@ const injectMemberAccessibility = {
|
|
|
54
38
|
|
|
55
39
|
return {
|
|
56
40
|
PropertyDefinition(node) {
|
|
57
|
-
if (!isInjectCall(node)) return;
|
|
41
|
+
if (!isInjectCall(context.sourceCode, node)) return;
|
|
58
42
|
|
|
59
43
|
const classNode = node.parent && node.parent.type === 'ClassBody' ? node.parent.parent : null;
|
|
60
44
|
if (!classNode || (classNode.type !== 'ClassDeclaration' && classNode.type !== 'ClassExpression')) return;
|
|
@@ -63,6 +47,7 @@ const injectMemberAccessibility = {
|
|
|
63
47
|
if (!memberName) return;
|
|
64
48
|
|
|
65
49
|
const isTemplateOrHostVisible = isReferencedFromTemplateOrHost(classNode, memberName, context);
|
|
50
|
+
const isAbstractClass = classNode.abstract === true;
|
|
66
51
|
|
|
67
52
|
if (isTemplateOrHostVisible) {
|
|
68
53
|
if (node.accessibility === 'protected' || node.accessibility === 'public') return;
|
|
@@ -71,7 +56,7 @@ const injectMemberAccessibility = {
|
|
|
71
56
|
node,
|
|
72
57
|
messageId: 'shouldBeProtected',
|
|
73
58
|
data: { name: memberName },
|
|
74
|
-
fix:
|
|
59
|
+
fix: buildAccessibilityFix(node, sourceCode, 'protected'),
|
|
75
60
|
});
|
|
76
61
|
|
|
77
62
|
return;
|
|
@@ -79,11 +64,24 @@ const injectMemberAccessibility = {
|
|
|
79
64
|
|
|
80
65
|
if (node.accessibility === 'private' || node.accessibility === 'public') return;
|
|
81
66
|
|
|
67
|
+
if (isAbstractClass) {
|
|
68
|
+
if (node.accessibility === 'protected') return;
|
|
69
|
+
|
|
70
|
+
context.report({
|
|
71
|
+
node,
|
|
72
|
+
messageId: 'shouldBeProtected',
|
|
73
|
+
data: { name: memberName },
|
|
74
|
+
fix: buildAccessibilityFix(node, sourceCode, 'protected'),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
82
80
|
context.report({
|
|
83
81
|
node,
|
|
84
82
|
messageId: 'shouldBePrivate',
|
|
85
83
|
data: { name: memberName },
|
|
86
|
-
fix:
|
|
84
|
+
fix: buildAccessibilityFix(node, sourceCode, 'private'),
|
|
87
85
|
});
|
|
88
86
|
},
|
|
89
87
|
};
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
* (or null when the property is not a reactive I/O declaration).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
const { ANGULAR_CORE, getImportedName } = require('./import-resolution');
|
|
22
|
+
|
|
23
|
+
const ANGULAR_IO_SOURCES = [ANGULAR_CORE, '@angular/core/rxjs-interop'];
|
|
24
|
+
|
|
21
25
|
/** Factory identifier → I/O kind. */
|
|
22
26
|
const IO_FACTORIES = {
|
|
23
27
|
input: 'input',
|
|
@@ -31,48 +35,61 @@ const IO_FACTORIES = {
|
|
|
31
35
|
* plain form (`input(...)`) and the `.required` member form
|
|
32
36
|
* (`input.required(...)`).
|
|
33
37
|
*
|
|
38
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
34
39
|
* @param {any} callee
|
|
35
40
|
* @returns {string | null}
|
|
36
41
|
*/
|
|
37
|
-
const getFactoryName = (callee) => {
|
|
42
|
+
const getFactoryName = (sourceCode, callee) => {
|
|
38
43
|
if (!callee) return null;
|
|
39
44
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
// input.required(...) / model.required(...)
|
|
43
|
-
if (
|
|
45
|
+
const isRequired =
|
|
44
46
|
callee.type === 'MemberExpression' &&
|
|
45
47
|
!callee.computed &&
|
|
46
|
-
callee.object.type === 'Identifier' &&
|
|
47
48
|
callee.property.type === 'Identifier' &&
|
|
48
|
-
callee.property.name === 'required'
|
|
49
|
-
) {
|
|
50
|
-
return callee.object.name;
|
|
51
|
-
}
|
|
49
|
+
callee.property.name === 'required';
|
|
52
50
|
|
|
53
|
-
return
|
|
51
|
+
return getImportedName(sourceCode, isRequired ? callee.object : callee, ANGULAR_IO_SOURCES);
|
|
54
52
|
};
|
|
55
53
|
|
|
56
54
|
/**
|
|
57
55
|
* Classifies a class property as an Angular signal input/model/output.
|
|
58
56
|
*
|
|
57
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
59
58
|
* @param {any} node PropertyDefinition
|
|
60
59
|
* @returns {{ kind: 'input' | 'model' | 'output', name: string, keyNode: any } | null}
|
|
61
60
|
*/
|
|
62
|
-
const getReactiveIo = (node) => {
|
|
61
|
+
const getReactiveIo = (sourceCode, node) => {
|
|
63
62
|
if (!node || node.type !== 'PropertyDefinition') return null;
|
|
64
63
|
if (node.computed || node.key.type !== 'Identifier') return null;
|
|
65
64
|
|
|
66
65
|
const value = node.value;
|
|
67
66
|
if (!value || value.type !== 'CallExpression') return null;
|
|
68
67
|
|
|
69
|
-
const factory = getFactoryName(value.callee);
|
|
68
|
+
const factory = getFactoryName(sourceCode, value.callee);
|
|
70
69
|
if (!factory) return null;
|
|
71
70
|
|
|
71
|
+
if (!Object.hasOwn(IO_FACTORIES, factory)) return null;
|
|
72
72
|
const kind = /** @type {Record<string, 'input' | 'model' | 'output'>} */ (IO_FACTORIES)[factory];
|
|
73
73
|
if (!kind) return null;
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
let name = node.key.name;
|
|
76
|
+
for (const argument of value.arguments) {
|
|
77
|
+
if (argument.type !== 'ObjectExpression') continue;
|
|
78
|
+
|
|
79
|
+
const aliasProperty = argument.properties.find(
|
|
80
|
+
(property) =>
|
|
81
|
+
property.type === 'Property' &&
|
|
82
|
+
!property.computed &&
|
|
83
|
+
((property.key.type === 'Identifier' && property.key.name === 'alias') ||
|
|
84
|
+
(property.key.type === 'Literal' && property.key.value === 'alias')),
|
|
85
|
+
);
|
|
86
|
+
if (aliasProperty?.value.type === 'Literal' && typeof aliasProperty.value.value === 'string') {
|
|
87
|
+
name = aliasProperty.value.value;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { kind, name, keyNode: node.key };
|
|
76
93
|
};
|
|
77
94
|
|
|
78
95
|
module.exports = { getReactiveIo };
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { getAngularDecoratorName } = require('./import-resolution');
|
|
5
|
+
|
|
4
6
|
const fs = require('fs');
|
|
5
7
|
const path = require('path');
|
|
6
8
|
|
|
@@ -10,21 +12,22 @@ const path = require('path');
|
|
|
10
12
|
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
11
13
|
|
|
12
14
|
/**
|
|
15
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
13
16
|
* @param {any} decorator
|
|
14
17
|
*/
|
|
15
|
-
const getDecoratorCall = (decorator) => {
|
|
16
|
-
|
|
18
|
+
const getDecoratorCall = (sourceCode, decorator) => {
|
|
19
|
+
const name = getAngularDecoratorName(sourceCode, decorator);
|
|
20
|
+
if (!name) return null;
|
|
17
21
|
|
|
18
22
|
const expression = decorator.expression;
|
|
19
23
|
if (expression.type !== 'CallExpression') return null;
|
|
20
|
-
if (expression.callee.type !== 'Identifier') return null;
|
|
21
24
|
if (expression.arguments.length === 0) return null;
|
|
22
25
|
|
|
23
26
|
const metadata = expression.arguments[0];
|
|
24
27
|
if (!metadata || metadata.type !== 'ObjectExpression') return null;
|
|
25
28
|
|
|
26
29
|
return {
|
|
27
|
-
name
|
|
30
|
+
name,
|
|
28
31
|
metadata,
|
|
29
32
|
};
|
|
30
33
|
};
|
|
@@ -67,8 +70,8 @@ const getStringValue = (node) => {
|
|
|
67
70
|
if (!node) return null;
|
|
68
71
|
if (node.type === 'Literal' && typeof node.value === 'string') return node.value;
|
|
69
72
|
|
|
70
|
-
if (node.type === 'TemplateLiteral'
|
|
71
|
-
return node.quasis
|
|
73
|
+
if (node.type === 'TemplateLiteral') {
|
|
74
|
+
return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join(' ');
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
return null;
|
|
@@ -111,9 +114,9 @@ const templateReferencesMember = (memberName, template) => {
|
|
|
111
114
|
|
|
112
115
|
const escapedName = escapeRegExp(memberName);
|
|
113
116
|
const patterns = [
|
|
114
|
-
new RegExp(`\\{\\{[
|
|
115
|
-
new RegExp(`@[a-zA-Z]
|
|
116
|
-
new RegExp(`@let\\s+[a-zA-Z_$][\\w$]*\\s*=\\s*[
|
|
117
|
+
new RegExp(`\\{\\{[^{}]*\\b${escapedName}\\b[^{}]*\\}\\}`, 'u'),
|
|
118
|
+
new RegExp(`@[a-zA-Z]+(?:\\s+if)?\\s*\\([^)]*\\b${escapedName}\\b[^)]*\\)`, 'u'),
|
|
119
|
+
new RegExp(`@let\\s+[a-zA-Z_$][\\w$]*\\s*=\\s*[^;]*\\b${escapedName}\\b`, 'u'),
|
|
117
120
|
new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
|
|
118
121
|
new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
|
|
119
122
|
new RegExp(`\\[[^\\]]+\\]\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
|
|
@@ -135,11 +138,12 @@ const isDynamicHostBindingKey = (keyName) => {
|
|
|
135
138
|
};
|
|
136
139
|
|
|
137
140
|
/**
|
|
141
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
138
142
|
* @param {any} classNode
|
|
139
143
|
*/
|
|
140
|
-
const getAngularMetadata = (classNode) => {
|
|
144
|
+
const getAngularMetadata = (sourceCode, classNode) => {
|
|
141
145
|
for (const decorator of classNode.decorators || []) {
|
|
142
|
-
const call = getDecoratorCall(decorator);
|
|
146
|
+
const call = getDecoratorCall(sourceCode, decorator);
|
|
143
147
|
if (!call) continue;
|
|
144
148
|
|
|
145
149
|
if (call.name === 'Component' || call.name === 'Directive') {
|
|
@@ -217,7 +221,7 @@ const getMemberName = (node) => {
|
|
|
217
221
|
* @param {import('eslint').Rule.RuleContext} context
|
|
218
222
|
*/
|
|
219
223
|
const isReferencedFromTemplateOrHost = (classNode, memberName, context) => {
|
|
220
|
-
const angularMetadata = getAngularMetadata(classNode);
|
|
224
|
+
const angularMetadata = getAngularMetadata(context.sourceCode, classNode);
|
|
221
225
|
|
|
222
226
|
if (!angularMetadata) {
|
|
223
227
|
return false;
|
|
@@ -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,191 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const ANGULAR_CORE = '@angular/core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
8
|
+
* @param {any} identifier
|
|
9
|
+
*/
|
|
10
|
+
const findVariable = (sourceCode, identifier) => {
|
|
11
|
+
/** @type {import('eslint').Scope.Scope | null} */
|
|
12
|
+
let scope = sourceCode.getScope(identifier);
|
|
13
|
+
|
|
14
|
+
while (scope) {
|
|
15
|
+
const variable = scope.set.get(identifier.name);
|
|
16
|
+
if (variable) return variable;
|
|
17
|
+
scope = scope.upper;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {any} specifier
|
|
25
|
+
*/
|
|
26
|
+
const getSpecifierImportedName = (specifier) => {
|
|
27
|
+
if (specifier.type === 'ImportDefaultSpecifier') return 'default';
|
|
28
|
+
if (specifier.type === 'ImportNamespaceSpecifier') return '*';
|
|
29
|
+
|
|
30
|
+
return specifier.imported.name ?? specifier.imported.value;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What an identifier is bound to: `{ source, name }` for an import (`name` is the exported
|
|
35
|
+
* name, `'*'` for a namespace), `{ source: null, name }` for an undeclared global, and `null`
|
|
36
|
+
* for a local binding.
|
|
37
|
+
*
|
|
38
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
39
|
+
* @param {any} identifier
|
|
40
|
+
* @returns {{ source: string | null; name: string } | null}
|
|
41
|
+
*/
|
|
42
|
+
const resolveIdentifier = (sourceCode, identifier) => {
|
|
43
|
+
const variable = findVariable(sourceCode, identifier);
|
|
44
|
+
if (!variable || variable.defs.length === 0) return { source: null, name: identifier.name };
|
|
45
|
+
|
|
46
|
+
const definition = /** @type {any} */ (variable.defs[0]);
|
|
47
|
+
if (definition.type !== 'ImportBinding') return null;
|
|
48
|
+
|
|
49
|
+
return { source: definition.parent.source.value, name: getSpecifierImportedName(definition.node) };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The exported name `node` refers to when it is imported from one of `sources`, directly, under
|
|
54
|
+
* an alias, or as `namespace.name`. An undeclared identifier falls back to its own name, so code
|
|
55
|
+
* without an import statement still matches; any other binding yields `null`.
|
|
56
|
+
*
|
|
57
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
58
|
+
* @param {any} node
|
|
59
|
+
* @param {string | readonly string[]} sources
|
|
60
|
+
* @returns {string | null}
|
|
61
|
+
*/
|
|
62
|
+
const getImportedName = (sourceCode, node, sources) => {
|
|
63
|
+
const sourceList = typeof sources === 'string' ? [sources] : sources;
|
|
64
|
+
|
|
65
|
+
if (node?.type === 'Identifier') {
|
|
66
|
+
const resolved = resolveIdentifier(sourceCode, node);
|
|
67
|
+
if (!resolved) return null;
|
|
68
|
+
if (resolved.source === null) return resolved.name;
|
|
69
|
+
|
|
70
|
+
return sourceList.includes(resolved.source) && resolved.name !== '*' ? resolved.name : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (
|
|
74
|
+
node?.type === 'MemberExpression' &&
|
|
75
|
+
!node.computed &&
|
|
76
|
+
node.object.type === 'Identifier' &&
|
|
77
|
+
node.property.type === 'Identifier'
|
|
78
|
+
) {
|
|
79
|
+
const resolved = resolveIdentifier(sourceCode, node.object);
|
|
80
|
+
if (!resolved || resolved.name !== '*' || resolved.source === null) return null;
|
|
81
|
+
|
|
82
|
+
return sourceList.includes(resolved.source) ? node.property.name : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return null;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
90
|
+
* @param {any} node
|
|
91
|
+
* @param {string} name
|
|
92
|
+
* @param {string | readonly string[]} [sources]
|
|
93
|
+
*/
|
|
94
|
+
const isImportedAs = (sourceCode, node, name, sources = ANGULAR_CORE) =>
|
|
95
|
+
getImportedName(sourceCode, node, sources) === name;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The `@angular/core` name of a decorator such as `@Component(...)`, `@Cmp(...)` or
|
|
99
|
+
* `@ng.Component(...)`.
|
|
100
|
+
*
|
|
101
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
102
|
+
* @param {any} decorator
|
|
103
|
+
*/
|
|
104
|
+
const getAngularDecoratorName = (sourceCode, decorator) => {
|
|
105
|
+
if (decorator?.type !== 'Decorator') return null;
|
|
106
|
+
|
|
107
|
+
const expression = decorator.expression;
|
|
108
|
+
const target = expression.type === 'CallExpression' ? expression.callee : expression;
|
|
109
|
+
|
|
110
|
+
return getImportedName(sourceCode, target, ANGULAR_CORE);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The local name of a value import `{ importedName as local } from source`, or `null` when the
|
|
115
|
+
* file does not import it as a value.
|
|
116
|
+
*
|
|
117
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
118
|
+
* @param {string} source
|
|
119
|
+
* @param {string} importedName
|
|
120
|
+
*/
|
|
121
|
+
const getImportLocalName = (sourceCode, source, importedName) => {
|
|
122
|
+
for (const node of sourceCode.ast.body) {
|
|
123
|
+
if (node.type !== 'ImportDeclaration' || node.source.value !== source) continue;
|
|
124
|
+
if (/** @type {any} */ (node).importKind === 'type') continue;
|
|
125
|
+
|
|
126
|
+
for (const specifier of node.specifiers) {
|
|
127
|
+
if (specifier.type !== 'ImportSpecifier' || /** @type {any} */ (specifier).importKind === 'type') continue;
|
|
128
|
+
if (getSpecifierImportedName(specifier) === importedName) return specifier.local.name;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whether `identifier` refers to a global such as `document` or `setTimeout`, rather than a local,
|
|
137
|
+
* parameter or import of the same name.
|
|
138
|
+
*
|
|
139
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
140
|
+
* @param {any} identifier
|
|
141
|
+
*/
|
|
142
|
+
const isGlobalReference = (sourceCode, identifier) =>
|
|
143
|
+
identifier?.type === 'Identifier' && resolveIdentifier(sourceCode, identifier)?.source === null;
|
|
144
|
+
|
|
145
|
+
const MODULE_REFERENCE_SELECTOR =
|
|
146
|
+
'ImportDeclaration, ExportNamedDeclaration[source], ExportAllDeclaration, ImportExpression';
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The module an import, re-export or dynamic `import('…')` references, with the names it takes by
|
|
150
|
+
* name. `export *`, a namespace or default import, and a dynamic import have no named entries.
|
|
151
|
+
*
|
|
152
|
+
* @param {any} node a node matched by `MODULE_REFERENCE_SELECTOR`
|
|
153
|
+
* @returns {{ source: string; named: { name: string; node: any }[] } | null}
|
|
154
|
+
*/
|
|
155
|
+
const getModuleReference = (node) => {
|
|
156
|
+
const sourceNode = node.source;
|
|
157
|
+
let source = null;
|
|
158
|
+
if (sourceNode?.type === 'Literal' && typeof sourceNode.value === 'string') source = sourceNode.value;
|
|
159
|
+
if (sourceNode?.type === 'TemplateLiteral' && sourceNode.expressions.length === 0) {
|
|
160
|
+
source = sourceNode.quasis[0].value.cooked;
|
|
161
|
+
}
|
|
162
|
+
if (source === null) return null;
|
|
163
|
+
|
|
164
|
+
/** @type {{ name: string; node: any }[]} */
|
|
165
|
+
const named = [];
|
|
166
|
+
if (node.type === 'ImportDeclaration') {
|
|
167
|
+
for (const specifier of node.specifiers) {
|
|
168
|
+
if (specifier.type !== 'ImportSpecifier') continue;
|
|
169
|
+
named.push({ name: getSpecifierImportedName(specifier), node: specifier });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (node.type === 'ExportNamedDeclaration') {
|
|
173
|
+
for (const specifier of node.specifiers) {
|
|
174
|
+
named.push({ name: specifier.local.name ?? specifier.local.value, node: specifier });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { source, named };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
ANGULAR_CORE,
|
|
183
|
+
MODULE_REFERENCE_SELECTOR,
|
|
184
|
+
getAngularDecoratorName,
|
|
185
|
+
getImportedName,
|
|
186
|
+
getImportLocalName,
|
|
187
|
+
getModuleReference,
|
|
188
|
+
isGlobalReference,
|
|
189
|
+
isImportedAs,
|
|
190
|
+
resolveIdentifier,
|
|
191
|
+
};
|
|
@@ -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 };
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
* injectQueryParamChanges(), injectPathParamChanges()
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
+
const { MODULE_REFERENCE_SELECTOR, getModuleReference, isImportedAs } = require('./internals/import-resolution');
|
|
39
|
+
|
|
40
|
+
const ANGULAR_ROUTER = '@angular/router';
|
|
41
|
+
|
|
38
42
|
/**
|
|
39
43
|
* Router properties that expose route state and must not be read directly.
|
|
40
44
|
* @type {Map<string, string>}
|
|
@@ -66,6 +70,17 @@ const noAngularRouterApi = {
|
|
|
66
70
|
schema: [],
|
|
67
71
|
},
|
|
68
72
|
create(context) {
|
|
73
|
+
const sourceCode = context.sourceCode;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {any} call
|
|
77
|
+
* @param {string} token
|
|
78
|
+
*/
|
|
79
|
+
const isInjectOf = (call, token) =>
|
|
80
|
+
call?.type === 'CallExpression' &&
|
|
81
|
+
isImportedAs(sourceCode, call.callee, 'inject') &&
|
|
82
|
+
isImportedAs(sourceCode, call.arguments[0], token, ANGULAR_ROUTER);
|
|
83
|
+
|
|
69
84
|
/**
|
|
70
85
|
* Names of variables/properties assigned from inject(Router).
|
|
71
86
|
* We track identifiers whose initializer is inject(Router) so we can
|
|
@@ -78,28 +93,14 @@ const noAngularRouterApi = {
|
|
|
78
93
|
// ── Track inject(Router) bindings ──────────────────────────────────────
|
|
79
94
|
// const router = inject(Router);
|
|
80
95
|
VariableDeclarator(node) {
|
|
81
|
-
if (
|
|
82
|
-
node.init?.type === 'CallExpression' &&
|
|
83
|
-
node.init.callee.type === 'Identifier' &&
|
|
84
|
-
node.init.callee.name === 'inject' &&
|
|
85
|
-
node.init.arguments[0]?.type === 'Identifier' &&
|
|
86
|
-
node.init.arguments[0].name === 'Router' &&
|
|
87
|
-
node.id.type === 'Identifier'
|
|
88
|
-
) {
|
|
96
|
+
if (isInjectOf(node.init, 'Router') && node.id.type === 'Identifier') {
|
|
89
97
|
routerBindings.add(node.id.name);
|
|
90
98
|
}
|
|
91
99
|
},
|
|
92
100
|
|
|
93
101
|
// ── Track class property assignments: router = inject(Router) ──────────
|
|
94
102
|
PropertyDefinition(node) {
|
|
95
|
-
if (
|
|
96
|
-
node.value?.type === 'CallExpression' &&
|
|
97
|
-
node.value.callee.type === 'Identifier' &&
|
|
98
|
-
node.value.callee.name === 'inject' &&
|
|
99
|
-
node.value.arguments[0]?.type === 'Identifier' &&
|
|
100
|
-
node.value.arguments[0].name === 'Router' &&
|
|
101
|
-
node.key.type === 'Identifier'
|
|
102
|
-
) {
|
|
103
|
+
if (isInjectOf(node.value, 'Router') && node.key.type === 'Identifier') {
|
|
103
104
|
routerBindings.add(node.key.name);
|
|
104
105
|
}
|
|
105
106
|
},
|
|
@@ -130,24 +131,17 @@ const noAngularRouterApi = {
|
|
|
130
131
|
},
|
|
131
132
|
|
|
132
133
|
// ── ActivatedRoute import ───────────────────────────────────────────────
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
context.report({ node, messageId: 'noActivatedRoute' });
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
134
|
+
[MODULE_REFERENCE_SELECTOR](node) {
|
|
135
|
+
const reference = getModuleReference(node);
|
|
136
|
+
if (reference?.source !== ANGULAR_ROUTER) return;
|
|
137
|
+
if (reference.named.some((entry) => entry.name === 'ActivatedRoute')) {
|
|
138
|
+
context.report({ node, messageId: 'noActivatedRoute' });
|
|
141
139
|
}
|
|
142
140
|
},
|
|
143
141
|
|
|
144
142
|
// ── inject(ActivatedRoute) ──────────────────────────────────────────────
|
|
145
143
|
CallExpression(node) {
|
|
146
|
-
|
|
147
|
-
if (callee.type !== 'Identifier' || callee.name !== 'inject') return;
|
|
148
|
-
const arg = node.arguments[0];
|
|
149
|
-
if (arg?.type !== 'Identifier') return;
|
|
150
|
-
if (arg.name === 'ActivatedRoute') {
|
|
144
|
+
if (isInjectOf(node, 'ActivatedRoute')) {
|
|
151
145
|
context.report({ node, messageId: 'noActivatedRoute' });
|
|
152
146
|
}
|
|
153
147
|
},
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const {
|
|
5
|
+
MODULE_REFERENCE_SELECTOR,
|
|
6
|
+
getImportedName,
|
|
7
|
+
getModuleReference,
|
|
8
|
+
isImportedAs,
|
|
9
|
+
} = require('./internals/import-resolution');
|
|
10
|
+
|
|
4
11
|
/**
|
|
5
12
|
* Disallows injecting Angular's `Title` and `Meta` services from
|
|
6
13
|
* `@angular/platform-browser` and importing them from that module.
|
|
@@ -18,9 +25,9 @@
|
|
|
18
25
|
* import { Title, Meta } from '@angular/platform-browser';
|
|
19
26
|
*
|
|
20
27
|
* ✅ Use the @ethlete/core SEO utilities:
|
|
21
|
-
* import {
|
|
22
|
-
*
|
|
23
|
-
*
|
|
28
|
+
* import { applyHeadTitleBinding, applyMetaBinding } from '@ethlete/core';
|
|
29
|
+
* applyHeadTitleBinding(title);
|
|
30
|
+
* applyMetaBinding(meta);
|
|
24
31
|
*/
|
|
25
32
|
|
|
26
33
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
@@ -32,12 +39,12 @@ const rule = {
|
|
|
32
39
|
},
|
|
33
40
|
schema: [],
|
|
34
41
|
messages: {
|
|
35
|
-
noInjectTitle: "Do not inject Angular's Title service. Use
|
|
36
|
-
noInjectMeta: "Do not inject Angular's Meta service. Use
|
|
42
|
+
noInjectTitle: "Do not inject Angular's Title service. Use applyHeadTitleBinding() from '@ethlete/core' instead.",
|
|
43
|
+
noInjectMeta: "Do not inject Angular's Meta service. Use applyMetaBinding() from '@ethlete/core' instead.",
|
|
37
44
|
noImportTitle:
|
|
38
|
-
"Do not import Title from '@angular/platform-browser'. Use
|
|
45
|
+
"Do not import Title from '@angular/platform-browser'. Use applyHeadTitleBinding() from '@ethlete/core' instead.",
|
|
39
46
|
noImportMeta:
|
|
40
|
-
"Do not import Meta from '@angular/platform-browser'. Use
|
|
47
|
+
"Do not import Meta from '@angular/platform-browser'. Use applyMetaBinding() from '@ethlete/core' instead.",
|
|
41
48
|
},
|
|
42
49
|
},
|
|
43
50
|
create(context) {
|
|
@@ -48,11 +55,11 @@ const rule = {
|
|
|
48
55
|
*/
|
|
49
56
|
const injectTokenName = (node) => {
|
|
50
57
|
if (node.type !== 'CallExpression') return null;
|
|
51
|
-
if (
|
|
58
|
+
if (!isImportedAs(context.sourceCode, node.callee, 'inject')) return null;
|
|
52
59
|
const firstArg = node.arguments[0];
|
|
53
60
|
if (!firstArg) return null;
|
|
54
|
-
|
|
55
|
-
return
|
|
61
|
+
|
|
62
|
+
return getImportedName(context.sourceCode, firstArg, '@angular/platform-browser');
|
|
56
63
|
};
|
|
57
64
|
|
|
58
65
|
return {
|
|
@@ -64,13 +71,11 @@ const rule = {
|
|
|
64
71
|
},
|
|
65
72
|
|
|
66
73
|
// Detect: import { Title, Meta } from '@angular/platform-browser'
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
74
|
+
[MODULE_REFERENCE_SELECTOR](node) {
|
|
75
|
+
const reference = getModuleReference(node);
|
|
76
|
+
if (reference?.source !== '@angular/platform-browser') return;
|
|
70
77
|
|
|
71
|
-
for (const specifier of
|
|
72
|
-
if (specifier.type !== 'ImportSpecifier') continue;
|
|
73
|
-
const imported = specifier.imported.name ?? specifier.imported.value;
|
|
78
|
+
for (const { name: imported, node: specifier } of reference.named) {
|
|
74
79
|
if (imported === 'Title') context.report({ node: specifier, messageId: 'noImportTitle' });
|
|
75
80
|
if (imported === 'Meta') context.report({ node: specifier, messageId: 'noImportMeta' });
|
|
76
81
|
}
|