@ethlete/eslint-plugin 1.0.0-next.24 → 1.0.0-next.26
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 +32 -0
- package/migrations.json +12 -0
- package/package.json +13 -2
- package/src/configs/recommended.js +37 -27
- package/src/generators/generators.json +10 -0
- package/src/generators/restore-module-augmentation-interfaces/migration.js +50 -0
- package/src/generators/restore-module-augmentation-interfaces/restore-module-augmentation-interfaces.js +103 -0
- package/src/generators/restore-module-augmentation-interfaces/schema.json +14 -0
- package/src/index.js +8 -2
- package/src/rules/angular-decorator-property-order.js +24 -7
- package/src/rules/class-constant-property.js +1 -1
- package/src/rules/class-member-order.js +60 -8
- package/src/rules/consistent-type-definitions.js +60 -0
- package/src/rules/enforce-routing-view-naming.js +34 -23
- package/src/rules/guard-return-newline.js +4 -7
- package/src/rules/internals/angular-member-visibility.js +24 -2
- package/src/rules/internals/angular-metadata-fix.js +231 -1
- package/src/rules/internals/angular-version.js +50 -0
- package/src/rules/internals/implemented-contract-members.js +26 -11
- package/src/rules/no-angular-router-api.js +86 -33
- package/src/rules/no-angular-seo-services.js +1 -0
- package/src/rules/no-async-await.js +5 -1
- package/src/rules/no-cdk-import.js +32 -7
- package/src/rules/no-csp-unsafe.js +362 -0
- package/src/rules/no-direct-dom-manipulation.js +50 -28
- package/src/rules/no-document-cookie.js +51 -8
- package/src/rules/no-dom-query.js +5 -2
- package/src/rules/no-empty-angular-metadata-arrays.js +2 -0
- package/src/rules/no-empty-newlines-between-imports.js +4 -1
- package/src/rules/no-impure-top-level-provider.js +42 -7
- package/src/rules/no-leading-underscore-class-member.js +27 -2
- package/src/rules/no-legacy-angular-decorators.js +11 -6
- package/src/rules/no-legacy-prepare-without-injector.js +57 -12
- package/src/rules/no-legacy-query-import.js +61 -16
- package/src/rules/no-member-alias.js +0 -5
- package/src/rules/no-native-observers.js +89 -24
- package/src/rules/no-pipe-logic.js +1 -2
- package/src/rules/no-readonly-signal.js +1 -5
- package/src/rules/no-redundant-internal.js +5 -2
- package/src/rules/no-redundant-on-push-change-detection.js +3 -48
- package/src/rules/no-rxjs-in-effect.js +7 -5
- package/src/rules/no-screaming-case-local.js +3 -5
- package/src/rules/no-standalone-flag.js +1 -0
- package/src/rules/no-subscribe-with-body.js +30 -1
- package/src/rules/no-template-literal-before-inline-template.js +6 -0
- package/src/rules/no-trivial-return-type.js +18 -1
- package/src/rules/no-unused-class-member.js +1 -1
- package/src/rules/no-window-location.js +60 -22
- package/src/rules/prefer-clone-equal.js +61 -8
- package/src/rules/prefer-concise-angular-host-directives.js +39 -11
- package/src/rules/prefer-concise-angular-style-metadata.js +10 -2
- package/src/rules/prefer-element-dimensions.js +264 -11
- package/src/rules/prefer-linked-signal.js +37 -39
- package/src/rules/prefer-match-media.js +17 -3
- package/src/rules/prefer-rxjs-timer.js +26 -4
- package/src/rules/prefer-scroll-state.js +0 -4
- package/src/rules/prefer-static-boolean-properties.js +1 -0
- package/src/rules/prefer-viewport-size.js +153 -16
- package/src/rules/require-dollar-suffix.js +11 -3
- package/src/rules/require-form-submit.js +4 -1
- package/src/rules/require-on-push-change-detection.js +7 -259
- package/src/rules/require-view-encapsulation-none.js +6 -214
- package/src/rules/template-member-accessibility.js +12 -4
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { rules } = require('@typescript-eslint/eslint-plugin');
|
|
5
|
+
|
|
6
|
+
const upstream = rules['consistent-type-definitions'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `@typescript-eslint/consistent-type-definitions` with `'type'`, except that an interface inside a
|
|
10
|
+
* `declare module`, `declare global` or `namespace` block is left alone.
|
|
11
|
+
*
|
|
12
|
+
* Only an interface merges into an existing declaration. The upstream fix turns
|
|
13
|
+
* `declare module '@ethlete/core' { interface EthleteColorThemeNameRegistry { … } }` into a type
|
|
14
|
+
* alias, which merges into nothing, and under `skipLibCheck` the registration disappears without an
|
|
15
|
+
* error.
|
|
16
|
+
*
|
|
17
|
+
* BAD:
|
|
18
|
+
* interface User { name: string }
|
|
19
|
+
*
|
|
20
|
+
* GOOD:
|
|
21
|
+
* type User = { name: string };
|
|
22
|
+
*
|
|
23
|
+
* declare module '@ethlete/core' {
|
|
24
|
+
* interface EthleteColorThemeNameRegistry { name: 'brand' }
|
|
25
|
+
* }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const isInsideModuleDeclaration = (sourceCode, node) =>
|
|
29
|
+
sourceCode.getAncestors(node).some((ancestor) => ancestor.type === 'TSModuleDeclaration');
|
|
30
|
+
|
|
31
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
32
|
+
const consistentTypeDefinitions = {
|
|
33
|
+
meta: {
|
|
34
|
+
...upstream.meta,
|
|
35
|
+
docs: {
|
|
36
|
+
description:
|
|
37
|
+
'Use `type` instead of `interface`, except for an interface that augments a module or the global scope.',
|
|
38
|
+
recommended: true,
|
|
39
|
+
},
|
|
40
|
+
schema: [],
|
|
41
|
+
},
|
|
42
|
+
create(context) {
|
|
43
|
+
const { sourceCode } = context;
|
|
44
|
+
|
|
45
|
+
const filteredContext = Object.create(context, {
|
|
46
|
+
options: { value: ['type'] },
|
|
47
|
+
report: {
|
|
48
|
+
value: (descriptor) => {
|
|
49
|
+
if ('node' in descriptor && isInsideModuleDeclaration(sourceCode, descriptor.node)) return;
|
|
50
|
+
|
|
51
|
+
context.report(descriptor);
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return upstream.create(filteredContext);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
module.exports = consistentTypeDefinitions;
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* loadComponent: () => import('./items-list-view/items-list-view.component').then((m) => m.ItemsListViewComponent)
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
const VIEW_PATH = /-view(?![\w-])/;
|
|
14
|
+
|
|
13
15
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
14
16
|
const enforceRoutingViewNaming = {
|
|
15
17
|
meta: {
|
|
@@ -17,6 +19,7 @@ const enforceRoutingViewNaming = {
|
|
|
17
19
|
docs: {
|
|
18
20
|
description:
|
|
19
21
|
'Routing components must import from a path containing "-view" and use a class name ending in "ViewComponent".',
|
|
22
|
+
recommended: true,
|
|
20
23
|
},
|
|
21
24
|
messages: {
|
|
22
25
|
pathMustContainView: 'Routing import path must contain "-view". Got: "{{path}}".',
|
|
@@ -25,48 +28,56 @@ const enforceRoutingViewNaming = {
|
|
|
25
28
|
schema: [],
|
|
26
29
|
},
|
|
27
30
|
create(context) {
|
|
31
|
+
/** @param {any} importExpr */
|
|
32
|
+
const checkImportPath = (importExpr) => {
|
|
33
|
+
if (!importExpr || importExpr.type !== 'ImportExpression') return;
|
|
34
|
+
|
|
35
|
+
const source = importExpr.source;
|
|
36
|
+
if (source && source.type === 'Literal' && typeof source.value === 'string' && !VIEW_PATH.test(source.value)) {
|
|
37
|
+
context.report({
|
|
38
|
+
node: source,
|
|
39
|
+
messageId: 'pathMustContainView',
|
|
40
|
+
data: { path: source.value },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
28
45
|
return {
|
|
29
46
|
Property(node) {
|
|
30
47
|
const key = /** @type {any} */ (node).key;
|
|
31
48
|
if (key.type !== 'Identifier' || key.name !== 'loadComponent') return;
|
|
32
49
|
|
|
33
|
-
// Must be an arrow function: () => import(...).then(m => m.XxxViewComponent)
|
|
34
50
|
const arrow = /** @type {any} */ (node).value;
|
|
35
51
|
if (!arrow || arrow.type !== 'ArrowFunctionExpression') return;
|
|
36
52
|
|
|
37
53
|
const body = arrow.body;
|
|
38
|
-
if (!body
|
|
54
|
+
if (!body) return;
|
|
55
|
+
|
|
56
|
+
if (body.type === 'ImportExpression') {
|
|
57
|
+
checkImportPath(body);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (body.type !== 'CallExpression') return;
|
|
39
62
|
|
|
40
63
|
const callee = body.callee;
|
|
41
64
|
if (!callee || callee.type !== 'MemberExpression') return;
|
|
42
65
|
if (callee.property.type !== 'Identifier' || callee.property.name !== 'then') return;
|
|
43
66
|
|
|
44
|
-
|
|
45
|
-
const importExpr = callee.object;
|
|
46
|
-
if (importExpr && importExpr.type === 'ImportExpression') {
|
|
47
|
-
const source = importExpr.source;
|
|
48
|
-
if (source && source.type === 'Literal' && typeof source.value === 'string') {
|
|
49
|
-
if (!source.value.includes('-view')) {
|
|
50
|
-
context.report({
|
|
51
|
-
node: source,
|
|
52
|
-
messageId: 'pathMustContainView',
|
|
53
|
-
data: { path: source.value },
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Check the class name in .then(m => m.XxxViewComponent)
|
|
60
|
-
const args = body.arguments;
|
|
61
|
-
if (!args || args.length === 0) return;
|
|
67
|
+
checkImportPath(callee.object);
|
|
62
68
|
|
|
63
|
-
const callback =
|
|
69
|
+
const callback = body.arguments?.[0];
|
|
64
70
|
if (!callback || callback.type !== 'ArrowFunctionExpression') return;
|
|
65
71
|
|
|
66
72
|
const cbBody = callback.body;
|
|
67
|
-
|
|
73
|
+
const returned =
|
|
74
|
+
cbBody?.type === 'BlockStatement'
|
|
75
|
+
? cbBody.body.find(/** @param {any} statement */ (statement) => statement.type === 'ReturnStatement')
|
|
76
|
+
?.argument
|
|
77
|
+
: cbBody;
|
|
78
|
+
if (!returned || returned.type !== 'MemberExpression') return;
|
|
68
79
|
|
|
69
|
-
const prop =
|
|
80
|
+
const prop = returned.property;
|
|
70
81
|
if (!prop || prop.type !== 'Identifier') return;
|
|
71
82
|
|
|
72
83
|
if (!prop.name.endsWith('ViewComponent')) {
|
|
@@ -25,6 +25,7 @@ const rule = {
|
|
|
25
25
|
type: 'layout',
|
|
26
26
|
docs: {
|
|
27
27
|
description: 'Require an empty line before a return in a multi-statement if-block (guard clause).',
|
|
28
|
+
recommended: true,
|
|
28
29
|
},
|
|
29
30
|
fixable: 'whitespace',
|
|
30
31
|
schema: [],
|
|
@@ -37,18 +38,14 @@ const rule = {
|
|
|
37
38
|
ReturnStatement(node) {
|
|
38
39
|
const blockParent = node.parent;
|
|
39
40
|
|
|
40
|
-
// Must be directly inside a BlockStatement
|
|
41
41
|
if (!blockParent || blockParent.type !== 'BlockStatement') return;
|
|
42
42
|
|
|
43
|
-
// The block's parent must be an IfStatement's consequent
|
|
44
43
|
const ifStatement = blockParent.parent;
|
|
45
44
|
if (!ifStatement || ifStatement.type !== 'IfStatement') return;
|
|
46
45
|
if (ifStatement.consequent !== blockParent) return;
|
|
47
46
|
|
|
48
|
-
// Block must have more than one statement (multi-statement guard)
|
|
49
47
|
if (blockParent.body.length <= 1) return;
|
|
50
48
|
|
|
51
|
-
// The return must be the last statement in the block
|
|
52
49
|
if (blockParent.body[blockParent.body.length - 1] !== node) return;
|
|
53
50
|
|
|
54
51
|
const prevStatement = blockParent.body[blockParent.body.length - 2];
|
|
@@ -56,11 +53,11 @@ const rule = {
|
|
|
56
53
|
if (!node.loc || !prevStatement.loc) return;
|
|
57
54
|
|
|
58
55
|
const prevEndLine = prevStatement.loc.end.line;
|
|
59
|
-
const
|
|
56
|
+
const sourceCode = context.sourceCode;
|
|
60
57
|
|
|
61
|
-
|
|
58
|
+
const textBetween = sourceCode.text.slice(prevStatement.range[1], node.range[0]);
|
|
62
59
|
|
|
63
|
-
|
|
60
|
+
if (/\n[ \t]*\r?\n/.test(textBetween)) return;
|
|
64
61
|
|
|
65
62
|
context.report({
|
|
66
63
|
node,
|
|
@@ -92,6 +92,28 @@ const readFileIfExists = (filePath) => {
|
|
|
92
92
|
return null;
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
+
/** @type {WeakMap<import('eslint').SourceCode, Map<string, string | null>>} */
|
|
96
|
+
const TEMPLATE_TEXT_CACHE = new WeakMap();
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
100
|
+
* @param {string} templatePath
|
|
101
|
+
*/
|
|
102
|
+
const readTemplateText = (sourceCode, templatePath) => {
|
|
103
|
+
let templates = TEMPLATE_TEXT_CACHE.get(sourceCode);
|
|
104
|
+
|
|
105
|
+
if (!templates) {
|
|
106
|
+
templates = new Map();
|
|
107
|
+
TEMPLATE_TEXT_CACHE.set(sourceCode, templates);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!templates.has(templatePath)) {
|
|
111
|
+
templates.set(templatePath, readFileIfExists(templatePath));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return templates.get(templatePath) ?? null;
|
|
115
|
+
};
|
|
116
|
+
|
|
95
117
|
/**
|
|
96
118
|
* @param {import('eslint').Rule.RuleContext} context
|
|
97
119
|
*/
|
|
@@ -183,7 +205,7 @@ const isReferencedFromTemplateOrHostMetadata = (memberName, metadata, context) =
|
|
|
183
205
|
|
|
184
206
|
if (templateUrl && filename) {
|
|
185
207
|
const templatePath = path.resolve(path.dirname(filename), templateUrl);
|
|
186
|
-
const templateText =
|
|
208
|
+
const templateText = readTemplateText(context.sourceCode, templatePath);
|
|
187
209
|
if (templateReferencesMember(memberName, templateText)) {
|
|
188
210
|
return true;
|
|
189
211
|
}
|
|
@@ -209,7 +231,7 @@ const isReferencedFromTemplateOrHostMetadata = (memberName, metadata, context) =
|
|
|
209
231
|
* @param {any} node
|
|
210
232
|
*/
|
|
211
233
|
const getMemberName = (node) => {
|
|
212
|
-
if (node.key?.type === 'Identifier') return node.key.name;
|
|
234
|
+
if (node.key?.type === 'Identifier' && !node.computed) return node.key.name;
|
|
213
235
|
if (node.key?.type === 'Literal' && typeof node.key.value === 'string') return node.key.value;
|
|
214
236
|
|
|
215
237
|
return null;
|
|
@@ -1,6 +1,236 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { ANGULAR_CORE, getImportLocalName, resolveIdentifier } = require('./import-resolution');
|
|
5
|
+
|
|
6
|
+
/** @typedef {{ property: string; importName: string; member: string }} TRequiredEnumProperty */
|
|
7
|
+
|
|
8
|
+
const COMPONENT_ORDER = ['selector', 'template', 'styleUrl', 'encapsulation', 'changeDetection'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {import('estree').Property['key']} key
|
|
12
|
+
*/
|
|
13
|
+
const getPropertyName = (key) => {
|
|
14
|
+
if (key.type === 'Identifier') return key.name;
|
|
15
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
16
|
+
return null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string | null} propertyName
|
|
21
|
+
*/
|
|
22
|
+
const getOrderKey = (propertyName) => {
|
|
23
|
+
if (propertyName === 'template' || propertyName === 'templateUrl') return 'template';
|
|
24
|
+
if (propertyName === 'styleUrl' || propertyName === 'styleUrls') return 'styleUrl';
|
|
25
|
+
return propertyName;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
30
|
+
* @param {import('eslint').AST.Token | import('eslint').Rule.Node} node
|
|
31
|
+
*/
|
|
32
|
+
const getIndent = (sourceCode, node) => {
|
|
33
|
+
if (!node.loc) return '';
|
|
34
|
+
|
|
35
|
+
const line = sourceCode.lines[node.loc.start.line - 1] ?? '';
|
|
36
|
+
return line.slice(0, node.loc.start.column);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
41
|
+
* @param {any} property
|
|
42
|
+
*/
|
|
43
|
+
const hasTrailingComma = (sourceCode, property) => {
|
|
44
|
+
const tokenAfter = sourceCode.getTokenAfter(property);
|
|
45
|
+
return Boolean(tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',');
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
50
|
+
* @param {string} importName
|
|
51
|
+
*/
|
|
52
|
+
const findAngularCoreImport = (sourceCode, importName) => {
|
|
53
|
+
const imports = sourceCode.ast.body.filter(
|
|
54
|
+
(node) =>
|
|
55
|
+
node.type === 'ImportDeclaration' && node.source.type === 'Literal' && node.source.value === '@angular/core',
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
imports.find((node) =>
|
|
60
|
+
node.specifiers.some(
|
|
61
|
+
(specifier) =>
|
|
62
|
+
specifier.type === 'ImportSpecifier' &&
|
|
63
|
+
specifier.imported.type === 'Identifier' &&
|
|
64
|
+
specifier.imported.name === importName,
|
|
65
|
+
),
|
|
66
|
+
) ??
|
|
67
|
+
imports.find((node) => node.importKind !== 'type') ??
|
|
68
|
+
null
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
74
|
+
* @param {any} importNode
|
|
75
|
+
* @param {string} importName
|
|
76
|
+
*/
|
|
77
|
+
const buildAngularCoreImportFix = (sourceCode, importNode, importName) => {
|
|
78
|
+
const specifiers = importNode.specifiers ?? [];
|
|
79
|
+
const hasImport = specifiers.some(
|
|
80
|
+
(specifier) =>
|
|
81
|
+
specifier.type === 'ImportSpecifier' &&
|
|
82
|
+
specifier.imported.type === 'Identifier' &&
|
|
83
|
+
specifier.imported.name === importName,
|
|
84
|
+
);
|
|
85
|
+
if (hasImport) return null;
|
|
86
|
+
|
|
87
|
+
const defaultSpecifier = specifiers.find((specifier) => specifier.type === 'ImportDefaultSpecifier') ?? null;
|
|
88
|
+
const namespaceSpecifier = specifiers.find((specifier) => specifier.type === 'ImportNamespaceSpecifier') ?? null;
|
|
89
|
+
const namedSpecifiers = specifiers.filter((specifier) => specifier.type === 'ImportSpecifier');
|
|
90
|
+
|
|
91
|
+
if (namespaceSpecifier) {
|
|
92
|
+
return (fixer) => fixer.insertTextAfter(importNode, `\nimport { ${importName} } from '@angular/core';`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const lastNamed = namedSpecifiers.at(-1);
|
|
96
|
+
|
|
97
|
+
if (lastNamed) {
|
|
98
|
+
const isMultiline = importNode.loc.start.line !== importNode.loc.end.line;
|
|
99
|
+
const indent = /^\s*/.exec(sourceCode.lines[lastNamed.loc.start.line - 1] ?? '')?.[0] ?? '';
|
|
100
|
+
const startsLine = sourceCode.lines[lastNamed.loc.start.line - 1].trim().startsWith(sourceCode.getText(lastNamed));
|
|
101
|
+
|
|
102
|
+
return (fixer) =>
|
|
103
|
+
fixer.insertTextAfter(lastNamed, isMultiline && startsLine ? `,\n${indent}${importName}` : `, ${importName}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const importParts = [];
|
|
107
|
+
if (defaultSpecifier) {
|
|
108
|
+
importParts.push(sourceCode.getText(defaultSpecifier));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const namedTexts = namedSpecifiers.map((specifier) => sourceCode.getText(specifier));
|
|
112
|
+
importParts.push(`{ ${[...namedTexts, importName].join(', ')} }`);
|
|
113
|
+
|
|
114
|
+
return (fixer) => fixer.replaceText(importNode, `import ${importParts.join(', ')} from '@angular/core';`);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
119
|
+
* @param {string} importName
|
|
120
|
+
*/
|
|
121
|
+
const buildMissingAngularCoreImportFix = (sourceCode, importName) => {
|
|
122
|
+
const importDeclarations = sourceCode.ast.body.filter((node) => node.type === 'ImportDeclaration');
|
|
123
|
+
const lastImport = importDeclarations[importDeclarations.length - 1] ?? null;
|
|
124
|
+
|
|
125
|
+
if (lastImport) {
|
|
126
|
+
return (fixer) => fixer.insertTextAfter(lastImport, `\nimport { ${importName} } from '@angular/core';`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const firstNode = sourceCode.ast.body[0];
|
|
130
|
+
const lastComment = firstNode ? sourceCode.getCommentsBefore(firstNode).at(-1) : null;
|
|
131
|
+
|
|
132
|
+
return lastComment
|
|
133
|
+
? (fixer) => fixer.insertTextAfter(lastComment, `\nimport { ${importName} } from '@angular/core';\n`)
|
|
134
|
+
: (fixer) =>
|
|
135
|
+
fixer.replaceTextRange([0, firstNode?.range[0] ?? 0], `import { ${importName} } from '@angular/core';\n\n`);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {any} metadata
|
|
140
|
+
* @param {string} propertyKey
|
|
141
|
+
*/
|
|
142
|
+
const getInsertionTarget = (metadata, propertyKey) => {
|
|
143
|
+
if (metadata.properties.some((property) => property.type === 'SpreadElement')) return null;
|
|
144
|
+
|
|
145
|
+
const anchorIndex = COMPONENT_ORDER.indexOf(propertyKey);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
metadata.properties.find((property) => {
|
|
149
|
+
if (property.type !== 'Property') return false;
|
|
150
|
+
|
|
151
|
+
const propertyName = getOrderKey(getPropertyName(property.key));
|
|
152
|
+
const propertyIndex = propertyName === null ? COMPONENT_ORDER.length : COMPONENT_ORDER.indexOf(propertyName);
|
|
153
|
+
return propertyIndex > anchorIndex || propertyIndex === -1;
|
|
154
|
+
}) ?? null
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
160
|
+
* @param {any} metadata
|
|
161
|
+
* @param {TRequiredEnumProperty} spec
|
|
162
|
+
*/
|
|
163
|
+
const buildMetadataFix = (sourceCode, metadata, spec) => {
|
|
164
|
+
const propertyText = `${spec.property}: ${getImportLocalName(sourceCode, ANGULAR_CORE, spec.importName) ?? spec.importName}.${spec.member}`;
|
|
165
|
+
const properties = metadata.properties;
|
|
166
|
+
const insertionTarget = getInsertionTarget(metadata, spec.property);
|
|
167
|
+
const isMultiline = Boolean(metadata.loc && metadata.loc.start.line !== metadata.loc.end.line);
|
|
168
|
+
|
|
169
|
+
if (properties.length === 0) {
|
|
170
|
+
return (fixer) => fixer.replaceText(metadata, `{ ${propertyText} }`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (insertionTarget) {
|
|
174
|
+
if (isMultiline) {
|
|
175
|
+
const propertyIndent = getIndent(sourceCode, insertionTarget);
|
|
176
|
+
return (fixer) => fixer.insertTextBefore(insertionTarget, `${propertyText},\n${propertyIndent}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (fixer) => fixer.insertTextBefore(insertionTarget, `${propertyText}, `);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const lastProperty = properties[properties.length - 1];
|
|
183
|
+
const closingBrace = sourceCode.getLastToken(metadata);
|
|
184
|
+
if (!lastProperty || !closingBrace) return null;
|
|
185
|
+
|
|
186
|
+
if (isMultiline) {
|
|
187
|
+
const closingIndent = getIndent(sourceCode, closingBrace);
|
|
188
|
+
const propertyIndent = getIndent(sourceCode, lastProperty);
|
|
189
|
+
const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
|
|
190
|
+
const rangeStart =
|
|
191
|
+
separator === ''
|
|
192
|
+
? (sourceCode.getTokenAfter(lastProperty)?.range[1] ?? lastProperty.range[1])
|
|
193
|
+
: lastProperty.range[1];
|
|
194
|
+
|
|
195
|
+
return (fixer) =>
|
|
196
|
+
fixer.replaceTextRange(
|
|
197
|
+
[rangeStart, closingBrace.range[0]],
|
|
198
|
+
`${separator}\n${propertyIndent}${propertyText}\n${closingIndent}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
|
|
203
|
+
return (fixer) =>
|
|
204
|
+
fixer.replaceTextRange([lastProperty.range[1], closingBrace.range[0]], `${separator} ${propertyText} `);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
209
|
+
* @param {any} metadata
|
|
210
|
+
* @param {any} valueNode
|
|
211
|
+
* @param {TRequiredEnumProperty} spec
|
|
212
|
+
*/
|
|
213
|
+
const buildRequiredEnumFix = (sourceCode, metadata, valueNode, spec) => {
|
|
214
|
+
const valueFix =
|
|
215
|
+
valueNode &&
|
|
216
|
+
((/** @type {import('eslint').Rule.RuleFixer} */ fixer) => fixer.replaceText(valueNode.property, spec.member));
|
|
217
|
+
if (valueNode && valueNode.object.type !== 'Identifier') return valueFix;
|
|
218
|
+
if (valueNode && resolveIdentifier(sourceCode, valueNode.object)?.source) return valueFix;
|
|
219
|
+
|
|
220
|
+
const fixes = [];
|
|
221
|
+
const angularCoreImport = findAngularCoreImport(sourceCode, spec.importName);
|
|
222
|
+
const importFix = angularCoreImport
|
|
223
|
+
? buildAngularCoreImportFix(sourceCode, angularCoreImport, spec.importName)
|
|
224
|
+
: buildMissingAngularCoreImportFix(sourceCode, spec.importName);
|
|
225
|
+
|
|
226
|
+
if (importFix) fixes.push(importFix);
|
|
227
|
+
|
|
228
|
+
const metadataFix = valueFix ?? buildMetadataFix(sourceCode, metadata, spec);
|
|
229
|
+
if (metadataFix) fixes.push(metadataFix);
|
|
230
|
+
|
|
231
|
+
return (/** @type {import('eslint').Rule.RuleFixer} */ fixer) => fixes.map((applyFix) => applyFix(fixer));
|
|
232
|
+
};
|
|
233
|
+
|
|
4
234
|
/**
|
|
5
235
|
* @param {import('eslint').SourceCode} sourceCode
|
|
6
236
|
* @param {any} metadata
|
|
@@ -19,4 +249,4 @@ const getMetadataEntryRemovalRange = (sourceCode, metadata, property) => {
|
|
|
19
249
|
return [entries[index - 1].range[1], property.range[1]];
|
|
20
250
|
};
|
|
21
251
|
|
|
22
|
-
module.exports = { getMetadataEntryRemovalRange };
|
|
252
|
+
module.exports = { buildRequiredEnumFix, getMetadataEntryRemovalRange, getPropertyName };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
/** @type {Map<string, number | null>} */
|
|
7
|
+
const cachedByDirectory = new Map();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} directory
|
|
11
|
+
* @returns {number | null}
|
|
12
|
+
*/
|
|
13
|
+
const detectAngularMajor = (directory) => {
|
|
14
|
+
const cached = cachedByDirectory.get(directory);
|
|
15
|
+
if (cached !== undefined) return cached;
|
|
16
|
+
|
|
17
|
+
let result;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const pkg = require(require.resolve('@angular/core/package.json', { paths: [directory, __dirname] }));
|
|
21
|
+
const major = Number.parseInt(String(pkg.version).split('.')[0], 10);
|
|
22
|
+
result = Number.isNaN(major) ? null : major;
|
|
23
|
+
} catch {
|
|
24
|
+
result = null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
cachedByDirectory.set(directory, result);
|
|
28
|
+
|
|
29
|
+
return result;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The Angular major installed next to the linted file (falling back to the plugin's own resolution).
|
|
34
|
+
* `settings.ethlete.angularMajor` wins over detection.
|
|
35
|
+
*
|
|
36
|
+
* @param {import('eslint').Rule.RuleContext} context
|
|
37
|
+
* @returns {number | null}
|
|
38
|
+
*/
|
|
39
|
+
const getAngularMajor = (context) => {
|
|
40
|
+
const settings = /** @type {any} */ (context.settings);
|
|
41
|
+
const override = settings?.ethlete?.angularMajor;
|
|
42
|
+
if (typeof override === 'number') return override;
|
|
43
|
+
|
|
44
|
+
const filename = context.filename;
|
|
45
|
+
const directory = filename && path.isAbsolute(filename) ? path.dirname(filename) : process.cwd();
|
|
46
|
+
|
|
47
|
+
return detectAngularMajor(directory);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
module.exports = { getAngularMajor };
|
|
@@ -5,9 +5,21 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const ts = require('typescript');
|
|
7
7
|
|
|
8
|
-
/** @type {Map<string, import('typescript').SourceFile | null>} */
|
|
8
|
+
/** @type {Map<string, { stamp: string, sourceFile: import('typescript').SourceFile | null }>} */
|
|
9
9
|
const SOURCE_FILE_CACHE = new Map();
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} filePath
|
|
13
|
+
*/
|
|
14
|
+
const getFileStamp = (filePath) => {
|
|
15
|
+
try {
|
|
16
|
+
const stats = fs.statSync(filePath);
|
|
17
|
+
return `${stats.mtimeMs}:${stats.size}`;
|
|
18
|
+
} catch {
|
|
19
|
+
return 'missing';
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
11
23
|
/**
|
|
12
24
|
* @param {string} filePath
|
|
13
25
|
*/
|
|
@@ -31,19 +43,18 @@ const getSourceFile = ({ filePath, text = null }) => {
|
|
|
31
43
|
return ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
32
44
|
}
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
const stamp = getFileStamp(filePath);
|
|
47
|
+
const cached = SOURCE_FILE_CACHE.get(filePath);
|
|
48
|
+
|
|
49
|
+
if (cached && cached.stamp === stamp) {
|
|
50
|
+
return cached.sourceFile;
|
|
36
51
|
}
|
|
37
52
|
|
|
38
53
|
const fileText = readFileIfExists(filePath);
|
|
54
|
+
const sourceFile =
|
|
55
|
+
fileText === null ? null : ts.createSourceFile(filePath, fileText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
39
56
|
|
|
40
|
-
|
|
41
|
-
SOURCE_FILE_CACHE.set(filePath, null);
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const sourceFile = ts.createSourceFile(filePath, fileText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
46
|
-
SOURCE_FILE_CACHE.set(filePath, sourceFile);
|
|
57
|
+
SOURCE_FILE_CACHE.set(filePath, { stamp, sourceFile });
|
|
47
58
|
return sourceFile;
|
|
48
59
|
};
|
|
49
60
|
|
|
@@ -257,6 +268,10 @@ const collectMemberNamesForTypeByName = ({
|
|
|
257
268
|
* @param {{ classNode: any, context: import('eslint').Rule.RuleContext }} config
|
|
258
269
|
*/
|
|
259
270
|
const getImplementedContractMemberNames = ({ classNode, context }) => {
|
|
271
|
+
if (!classNode.implements?.length) {
|
|
272
|
+
return new Set();
|
|
273
|
+
}
|
|
274
|
+
|
|
260
275
|
const filename = context.physicalFilename || context.filename;
|
|
261
276
|
if (!filename || filename === '<input>' || filename === '<text>') {
|
|
262
277
|
return new Set();
|
|
@@ -270,7 +285,7 @@ const getImplementedContractMemberNames = ({ classNode, context }) => {
|
|
|
270
285
|
/** @type {Set<string>} */
|
|
271
286
|
const memberNames = new Set();
|
|
272
287
|
|
|
273
|
-
for (const implementedType of classNode.implements
|
|
288
|
+
for (const implementedType of classNode.implements) {
|
|
274
289
|
if (!implementedType.expression || implementedType.expression.type !== 'Identifier') continue;
|
|
275
290
|
|
|
276
291
|
const typeMemberNames = collectMemberNamesForTypeByName({
|