@ethlete/eslint-plugin 0.1.0-next.0 → 0.1.0-next.1
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 +6 -0
- package/package.json +3 -4
- package/src/configs/recommended.js +362 -0
- package/src/index.js +116 -0
- package/src/rules/angular-decorator-property-order.js +217 -0
- package/src/rules/enforce-routing-view-naming.js +84 -0
- package/src/rules/guard-return-newline.js +81 -0
- package/src/rules/inject-member-accessibility.js +286 -0
- package/src/rules/no-angular-router-api.js +158 -0
- package/src/rules/no-angular-seo-services.js +82 -0
- package/src/rules/no-direct-dom-manipulation.js +160 -0
- package/src/rules/no-document-cookie.js +55 -0
- package/src/rules/no-dom-query.js +81 -0
- package/src/rules/no-inject-chain.js +44 -0
- package/src/rules/no-legacy-angular-decorators.js +210 -0
- package/src/rules/no-locale-id.js +57 -0
- package/src/rules/no-member-alias.js +151 -0
- package/src/rules/no-native-observers.js +102 -0
- package/src/rules/no-pipe-logic.js +76 -0
- package/src/rules/no-public-property.js +39 -0
- package/src/rules/no-readonly-signal.js +119 -0
- package/src/rules/no-rxjs-in-effect.js +88 -0
- package/src/rules/no-screaming-case-local.js +113 -0
- package/src/rules/no-subscribe-in-pipe.js +89 -0
- package/src/rules/no-subscribe-with-body.js +95 -0
- package/src/rules/no-trivial-return-type.js +104 -0
- package/src/rules/no-trivial-wrapper-method.js +120 -0
- package/src/rules/no-type-only-import.js +79 -0
- package/src/rules/no-unused-class-member.js +209 -0
- package/src/rules/no-window-location.js +109 -0
- package/src/rules/prefer-clone-equal.js +112 -0
- package/src/rules/prefer-concise-angular-host-directives.js +225 -0
- package/src/rules/prefer-concise-angular-style-metadata.js +108 -0
- package/src/rules/prefer-element-dimensions.js +128 -0
- package/src/rules/prefer-linked-signal.js +124 -0
- package/src/rules/prefer-match-media.js +101 -0
- package/src/rules/prefer-rxjs-timer.js +82 -0
- package/src/rules/prefer-scroll-state.js +158 -0
- package/src/rules/prefer-viewport-size.js +75 -0
- package/src/rules/require-dollar-suffix.js +123 -0
- package/src/rules/require-view-encapsulation-none.js +101 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** @typedef {'Component' | 'Directive'} TDecoratorName */
|
|
5
|
+
/** @typedef {{ order: string[]; orderText: string }} TDecoratorConfig */
|
|
6
|
+
/** @typedef {{ range: [number, number] }} TNodeWithRange */
|
|
7
|
+
/** @typedef {import('estree').Property & TNodeWithRange} TPropertyNode */
|
|
8
|
+
/** @typedef {import('estree').SpreadElement & TNodeWithRange} TSpreadElementNode */
|
|
9
|
+
/** @typedef {import('estree').ObjectExpression & TNodeWithRange & { properties: Array<TPropertyNode | TSpreadElementNode> }} TObjectExpressionNode */
|
|
10
|
+
/** @typedef {{ hasComma: boolean; originalIndex: number; orderIndex: number; property: TPropertyNode; segmentText: string }} TPropertyEntry */
|
|
11
|
+
|
|
12
|
+
/** @type {Record<TDecoratorName, TDecoratorConfig>} */
|
|
13
|
+
const DECORATOR_CONFIG = {
|
|
14
|
+
Component: {
|
|
15
|
+
order: [
|
|
16
|
+
'selector',
|
|
17
|
+
'template',
|
|
18
|
+
'styleUrl',
|
|
19
|
+
'encapsulation',
|
|
20
|
+
'changeDetection',
|
|
21
|
+
'imports',
|
|
22
|
+
'providers',
|
|
23
|
+
'viewProviders',
|
|
24
|
+
'animations',
|
|
25
|
+
'hostDirectives',
|
|
26
|
+
'host',
|
|
27
|
+
'styles',
|
|
28
|
+
'inputs',
|
|
29
|
+
'outputs',
|
|
30
|
+
'queries',
|
|
31
|
+
'preserveWhitespaces',
|
|
32
|
+
'standalone',
|
|
33
|
+
'schemas',
|
|
34
|
+
'jit',
|
|
35
|
+
'exportAs',
|
|
36
|
+
],
|
|
37
|
+
orderText:
|
|
38
|
+
'selector, template/templateUrl, styleUrl/styleUrls, encapsulation, changeDetection, imports, providers, viewProviders, animations, hostDirectives, host, styles, inputs, outputs, queries, preserveWhitespaces, standalone, schemas, jit, exportAs',
|
|
39
|
+
},
|
|
40
|
+
Directive: {
|
|
41
|
+
order: [
|
|
42
|
+
'selector',
|
|
43
|
+
'exportAs',
|
|
44
|
+
'providers',
|
|
45
|
+
'inputs',
|
|
46
|
+
'outputs',
|
|
47
|
+
'queries',
|
|
48
|
+
'hostDirectives',
|
|
49
|
+
'host',
|
|
50
|
+
'standalone',
|
|
51
|
+
'jit',
|
|
52
|
+
],
|
|
53
|
+
orderText: 'selector, exportAs, providers, inputs, outputs, queries, hostDirectives, host, standalone, jit',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {import('estree').Property['key']} key
|
|
59
|
+
*/
|
|
60
|
+
const getPropertyName = (key) => {
|
|
61
|
+
if (key.type === 'Identifier') return key.name;
|
|
62
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {TDecoratorName} decoratorName
|
|
68
|
+
* @param {string | null} propertyName
|
|
69
|
+
*/
|
|
70
|
+
const getOrderKey = (decoratorName, propertyName) => {
|
|
71
|
+
if (propertyName === null) return null;
|
|
72
|
+
|
|
73
|
+
if (decoratorName === 'Component') {
|
|
74
|
+
if (propertyName === 'template' || propertyName === 'templateUrl') return 'template';
|
|
75
|
+
if (propertyName === 'styleUrl' || propertyName === 'styleUrls') return 'styleUrl';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return propertyName;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {import('eslint').Rule.Node} node
|
|
83
|
+
* @returns {TDecoratorName | null}
|
|
84
|
+
*/
|
|
85
|
+
const getDecoratorName = (node) => {
|
|
86
|
+
const decorator = /** @type {any} */ (node);
|
|
87
|
+
if (decorator.type !== 'Decorator') return null;
|
|
88
|
+
|
|
89
|
+
const expression = decorator.expression;
|
|
90
|
+
if (expression.type === 'CallExpression') {
|
|
91
|
+
return expression.callee.type === 'Identifier' ? expression.callee.name : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return expression.type === 'Identifier' ? expression.name : null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
98
|
+
const angularDecoratorPropertyOrder = {
|
|
99
|
+
meta: {
|
|
100
|
+
type: 'layout',
|
|
101
|
+
docs: {
|
|
102
|
+
description: 'Require a consistent property order in Angular @Component and @Directive metadata objects.',
|
|
103
|
+
},
|
|
104
|
+
fixable: 'code',
|
|
105
|
+
messages: {
|
|
106
|
+
outOfOrder: '{{decorator}} metadata properties should be ordered as: {{order}}.',
|
|
107
|
+
},
|
|
108
|
+
schema: [],
|
|
109
|
+
},
|
|
110
|
+
create(context) {
|
|
111
|
+
const sourceCode = context.sourceCode;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
/** @param {import('eslint').Rule.Node} node */
|
|
115
|
+
Decorator(node) {
|
|
116
|
+
const decoratorName = getDecoratorName(node);
|
|
117
|
+
if (decoratorName !== 'Component' && decoratorName !== 'Directive') return;
|
|
118
|
+
|
|
119
|
+
const decorator = /** @type {any} */ (node);
|
|
120
|
+
const expression = decorator.expression;
|
|
121
|
+
if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
|
|
122
|
+
|
|
123
|
+
const metadata = /** @type {TObjectExpressionNode | undefined} */ (expression.arguments[0]);
|
|
124
|
+
if (!metadata || metadata.type !== 'ObjectExpression' || metadata.properties.length < 2) return;
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
metadata.properties.some(
|
|
128
|
+
/** @param {TPropertyNode | TSpreadElementNode} property */ (property) => property.type !== 'Property',
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return;
|
|
132
|
+
|
|
133
|
+
const config = DECORATOR_CONFIG[decoratorName];
|
|
134
|
+
const orderIndexMap = new Map(
|
|
135
|
+
config.order.map(/** @param {string} value @param {number} index */ (value, index) => [value, index]),
|
|
136
|
+
);
|
|
137
|
+
const openingBrace = sourceCode.getFirstToken(metadata);
|
|
138
|
+
const closingBrace = sourceCode.getLastToken(metadata);
|
|
139
|
+
|
|
140
|
+
if (!openingBrace || !closingBrace || openingBrace.value !== '{' || closingBrace.value !== '}') return;
|
|
141
|
+
|
|
142
|
+
let segmentStart = openingBrace.range[1];
|
|
143
|
+
|
|
144
|
+
const entries = metadata.properties.map(
|
|
145
|
+
/** @param {TPropertyNode | TSpreadElementNode} property @param {number} originalIndex @returns {TPropertyEntry} */
|
|
146
|
+
(property, originalIndex) => {
|
|
147
|
+
const propertyNode = /** @type {TPropertyNode} */ (property);
|
|
148
|
+
const propertyName = getPropertyName(propertyNode.key);
|
|
149
|
+
const orderKey = getOrderKey(decoratorName, propertyName);
|
|
150
|
+
const orderIndex =
|
|
151
|
+
orderKey === null ? config.order.length : (orderIndexMap.get(orderKey) ?? config.order.length);
|
|
152
|
+
const tokenAfter = sourceCode.getTokenAfter(propertyNode);
|
|
153
|
+
const hasComma = Boolean(tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',');
|
|
154
|
+
const segmentEnd = hasComma && tokenAfter ? tokenAfter.range[0] : propertyNode.range[1];
|
|
155
|
+
const segmentText = sourceCode.text.slice(segmentStart, segmentEnd);
|
|
156
|
+
|
|
157
|
+
if (hasComma && tokenAfter) {
|
|
158
|
+
segmentStart = tokenAfter.range[1];
|
|
159
|
+
} else {
|
|
160
|
+
segmentStart = propertyNode.range[1];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
hasComma,
|
|
165
|
+
originalIndex,
|
|
166
|
+
orderIndex,
|
|
167
|
+
property: propertyNode,
|
|
168
|
+
segmentText,
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const trailingComma = entries[entries.length - 1].hasComma;
|
|
174
|
+
const suffix = sourceCode.text.slice(segmentStart, closingBrace.range[0]);
|
|
175
|
+
const sortedEntries = [...entries].sort(
|
|
176
|
+
/** @param {TPropertyEntry} left @param {TPropertyEntry} right */ (left, right) => {
|
|
177
|
+
if (left.orderIndex !== right.orderIndex) return left.orderIndex - right.orderIndex;
|
|
178
|
+
return left.originalIndex - right.originalIndex;
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const isSorted = entries.every(
|
|
183
|
+
/** @param {TPropertyEntry} entry @param {number} index */ (entry, index) =>
|
|
184
|
+
entry.property === sortedEntries[index].property,
|
|
185
|
+
);
|
|
186
|
+
if (isSorted) return;
|
|
187
|
+
|
|
188
|
+
const firstMismatch = entries.find(
|
|
189
|
+
/** @param {TPropertyEntry} entry @param {number} index */ (entry, index) =>
|
|
190
|
+
entry.property !== sortedEntries[index].property,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
context.report({
|
|
194
|
+
node: firstMismatch ? firstMismatch.property : metadata,
|
|
195
|
+
messageId: 'outOfOrder',
|
|
196
|
+
data: {
|
|
197
|
+
decorator: `@${decoratorName}`,
|
|
198
|
+
order: config.orderText,
|
|
199
|
+
},
|
|
200
|
+
fix(fixer) {
|
|
201
|
+
const reorderedBody =
|
|
202
|
+
sortedEntries
|
|
203
|
+
.map(
|
|
204
|
+
(entry, index) =>
|
|
205
|
+
`${entry.segmentText}${index < sortedEntries.length - 1 || trailingComma ? ',' : ''}`,
|
|
206
|
+
)
|
|
207
|
+
.join('') + suffix;
|
|
208
|
+
|
|
209
|
+
return fixer.replaceTextRange([openingBrace.range[1], closingBrace.range[0]], reorderedBody);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
module.exports = angularDecoratorPropertyOrder;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks that a `loadComponent:` route property:
|
|
6
|
+
* 1. Imports from a path containing "-view"
|
|
7
|
+
* 2. Accesses a class name ending in "ViewComponent" in the .then() callback
|
|
8
|
+
*
|
|
9
|
+
* Valid pattern:
|
|
10
|
+
* loadComponent: () => import('./items-list-view/items-list-view.component').then((m) => m.ItemsListViewComponent)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
14
|
+
const enforceRoutingViewNaming = {
|
|
15
|
+
meta: {
|
|
16
|
+
type: 'suggestion',
|
|
17
|
+
docs: {
|
|
18
|
+
description:
|
|
19
|
+
'Routing components must import from a path containing "-view" and use a class name ending in "ViewComponent".',
|
|
20
|
+
},
|
|
21
|
+
messages: {
|
|
22
|
+
pathMustContainView: 'Routing import path must contain "-view". Got: "{{path}}".',
|
|
23
|
+
classMustEndWithViewComponent: 'Routing component class must end with "ViewComponent". Got: "{{name}}".',
|
|
24
|
+
},
|
|
25
|
+
schema: [],
|
|
26
|
+
},
|
|
27
|
+
create(context) {
|
|
28
|
+
return {
|
|
29
|
+
Property(node) {
|
|
30
|
+
const key = /** @type {any} */ (node).key;
|
|
31
|
+
if (key.type !== 'Identifier' || key.name !== 'loadComponent') return;
|
|
32
|
+
|
|
33
|
+
// Must be an arrow function: () => import(...).then(m => m.XxxViewComponent)
|
|
34
|
+
const arrow = /** @type {any} */ (node).value;
|
|
35
|
+
if (!arrow || arrow.type !== 'ArrowFunctionExpression') return;
|
|
36
|
+
|
|
37
|
+
const body = arrow.body;
|
|
38
|
+
if (!body || body.type !== 'CallExpression') return;
|
|
39
|
+
|
|
40
|
+
const callee = body.callee;
|
|
41
|
+
if (!callee || callee.type !== 'MemberExpression') return;
|
|
42
|
+
if (callee.property.type !== 'Identifier' || callee.property.name !== 'then') return;
|
|
43
|
+
|
|
44
|
+
// Check the import path contains "-view"
|
|
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;
|
|
62
|
+
|
|
63
|
+
const callback = args[0];
|
|
64
|
+
if (!callback || callback.type !== 'ArrowFunctionExpression') return;
|
|
65
|
+
|
|
66
|
+
const cbBody = callback.body;
|
|
67
|
+
if (!cbBody || cbBody.type !== 'MemberExpression') return;
|
|
68
|
+
|
|
69
|
+
const prop = cbBody.property;
|
|
70
|
+
if (!prop || prop.type !== 'Identifier') return;
|
|
71
|
+
|
|
72
|
+
if (!prop.name.endsWith('ViewComponent')) {
|
|
73
|
+
context.report({
|
|
74
|
+
node: prop,
|
|
75
|
+
messageId: 'classMustEndWithViewComponent',
|
|
76
|
+
data: { name: prop.name },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
module.exports = enforceRoutingViewNaming;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Enforces an empty line before a `return` statement that is the last statement
|
|
6
|
+
* in a multi-statement if-block (guard clause pattern).
|
|
7
|
+
*
|
|
8
|
+
* ❌ if (!allFilled) {
|
|
9
|
+
* this.selectFirstUnfilledGuidedSlot();
|
|
10
|
+
* return;
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* ✅ if (!allFilled) {
|
|
14
|
+
* this.selectFirstUnfilledGuidedSlot();
|
|
15
|
+
*
|
|
16
|
+
* return;
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* ✅ Single-line guards are exempt: if (!slot) return;
|
|
20
|
+
*
|
|
21
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
22
|
+
*/
|
|
23
|
+
const rule = {
|
|
24
|
+
meta: {
|
|
25
|
+
type: 'layout',
|
|
26
|
+
docs: {
|
|
27
|
+
description: 'Require an empty line before a return in a multi-statement if-block (guard clause).',
|
|
28
|
+
},
|
|
29
|
+
fixable: 'whitespace',
|
|
30
|
+
schema: [],
|
|
31
|
+
messages: {
|
|
32
|
+
missingEmptyLine: 'Add an empty line before the return statement inside a multi-statement if-block.',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
create(context) {
|
|
36
|
+
return {
|
|
37
|
+
ReturnStatement(node) {
|
|
38
|
+
const blockParent = node.parent;
|
|
39
|
+
|
|
40
|
+
// Must be directly inside a BlockStatement
|
|
41
|
+
if (!blockParent || blockParent.type !== 'BlockStatement') return;
|
|
42
|
+
|
|
43
|
+
// The block's parent must be an IfStatement's consequent
|
|
44
|
+
const ifStatement = blockParent.parent;
|
|
45
|
+
if (!ifStatement || ifStatement.type !== 'IfStatement') return;
|
|
46
|
+
if (ifStatement.consequent !== blockParent) return;
|
|
47
|
+
|
|
48
|
+
// Block must have more than one statement (multi-statement guard)
|
|
49
|
+
if (blockParent.body.length <= 1) return;
|
|
50
|
+
|
|
51
|
+
// The return must be the last statement in the block
|
|
52
|
+
if (blockParent.body[blockParent.body.length - 1] !== node) return;
|
|
53
|
+
|
|
54
|
+
const prevStatement = blockParent.body[blockParent.body.length - 2];
|
|
55
|
+
|
|
56
|
+
if (!node.loc || !prevStatement.loc) return;
|
|
57
|
+
|
|
58
|
+
const prevEndLine = prevStatement.loc.end.line;
|
|
59
|
+
const returnStartLine = node.loc.start.line;
|
|
60
|
+
|
|
61
|
+
// Need at least one empty line (gap of 2+ lines) between prev statement and return
|
|
62
|
+
if (returnStartLine - prevEndLine >= 2) return;
|
|
63
|
+
|
|
64
|
+
const sourceCode = context.sourceCode;
|
|
65
|
+
|
|
66
|
+
context.report({
|
|
67
|
+
node,
|
|
68
|
+
messageId: 'missingEmptyLine',
|
|
69
|
+
fix(fixer) {
|
|
70
|
+
const prevToken = sourceCode.getLastToken(/** @type {import('eslint').Rule.Node} */ (prevStatement));
|
|
71
|
+
if (!prevToken) return null;
|
|
72
|
+
|
|
73
|
+
return fixer.insertTextAfter(prevToken, '\n');
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
module.exports = rule;
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} value
|
|
9
|
+
*/
|
|
10
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {any} decorator
|
|
14
|
+
*/
|
|
15
|
+
const getDecoratorCall = (decorator) => {
|
|
16
|
+
if (!decorator || decorator.type !== 'Decorator') return null;
|
|
17
|
+
|
|
18
|
+
const expression = decorator.expression;
|
|
19
|
+
if (expression.type !== 'CallExpression') return null;
|
|
20
|
+
if (expression.callee.type !== 'Identifier') return null;
|
|
21
|
+
if (expression.arguments.length === 0) return null;
|
|
22
|
+
|
|
23
|
+
const metadata = expression.arguments[0];
|
|
24
|
+
if (!metadata || metadata.type !== 'ObjectExpression') return null;
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
name: expression.callee.name,
|
|
28
|
+
metadata,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {any} property
|
|
34
|
+
*/
|
|
35
|
+
const getPropertyKeyName = (property) => {
|
|
36
|
+
if (!property || property.computed) return null;
|
|
37
|
+
|
|
38
|
+
const key = property.key;
|
|
39
|
+
if (key.type === 'Identifier') return key.name;
|
|
40
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {any} metadata
|
|
47
|
+
* @param {string} propertyName
|
|
48
|
+
*/
|
|
49
|
+
const getMetadataProperty = (metadata, propertyName) => {
|
|
50
|
+
for (const property of metadata.properties) {
|
|
51
|
+
if (!property || property.type !== 'Property' || property.computed) continue;
|
|
52
|
+
|
|
53
|
+
const keyName = getPropertyKeyName(property);
|
|
54
|
+
|
|
55
|
+
if (keyName === propertyName) {
|
|
56
|
+
return property;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {any} node
|
|
65
|
+
*/
|
|
66
|
+
const getStringValue = (node) => {
|
|
67
|
+
if (!node) return null;
|
|
68
|
+
if (node.type === 'Literal' && typeof node.value === 'string') return node.value;
|
|
69
|
+
|
|
70
|
+
if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
|
|
71
|
+
return node.quasis[0].value.cooked || '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} filePath
|
|
79
|
+
*/
|
|
80
|
+
const readFileIfExists = (filePath) => {
|
|
81
|
+
try {
|
|
82
|
+
if (fs.existsSync(filePath)) {
|
|
83
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} memberName
|
|
94
|
+
* @param {string | null} template
|
|
95
|
+
*/
|
|
96
|
+
const templateReferencesMember = (memberName, template) => {
|
|
97
|
+
if (!template) return false;
|
|
98
|
+
|
|
99
|
+
const escapedName = escapeRegExp(memberName);
|
|
100
|
+
const patterns = [
|
|
101
|
+
new RegExp(`\\{\\{[\\s\\S]*?\\b${escapedName}\\b[\\s\\S]*?\\}\\}`, 'u'),
|
|
102
|
+
new RegExp(`@[a-zA-Z]+\\s*\\([\\s\\S]*?\\b${escapedName}\\b[\\s\\S]*?\\)`, 'u'),
|
|
103
|
+
new RegExp(`\\[[^\\]]+\\]\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
|
|
104
|
+
new RegExp(`\\[[^\\]]+\\]\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
|
|
105
|
+
new RegExp(`\\([^\\)]+\\)\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
|
|
106
|
+
new RegExp(`\\([^\\)]+\\)\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
return patterns.some((pattern) => pattern.test(template));
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {string | null} keyName
|
|
114
|
+
*/
|
|
115
|
+
const isDynamicHostBindingKey = (keyName) => {
|
|
116
|
+
if (!keyName) return false;
|
|
117
|
+
|
|
118
|
+
return keyName.startsWith('[') || keyName.startsWith('(') || keyName.startsWith('@');
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {any} classNode
|
|
123
|
+
* @param {import('eslint').Rule.RuleContext} context
|
|
124
|
+
*/
|
|
125
|
+
const getAngularMetadata = (classNode, context) => {
|
|
126
|
+
for (const decorator of classNode.decorators || []) {
|
|
127
|
+
const call = getDecoratorCall(decorator);
|
|
128
|
+
if (!call) continue;
|
|
129
|
+
|
|
130
|
+
if (call.name === 'Component' || call.name === 'Directive') {
|
|
131
|
+
return call;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {string} memberName
|
|
140
|
+
* @param {string | null} text
|
|
141
|
+
*/
|
|
142
|
+
const textReferencesMember = (memberName, text) => {
|
|
143
|
+
if (!text) return false;
|
|
144
|
+
|
|
145
|
+
const pattern = new RegExp(`\\b${escapeRegExp(memberName)}\\b`, 'u');
|
|
146
|
+
return pattern.test(text);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {string} memberName
|
|
151
|
+
* @param {any} metadata
|
|
152
|
+
* @param {import('eslint').Rule.RuleContext} context
|
|
153
|
+
*/
|
|
154
|
+
const isReferencedFromTemplateOrHost = (memberName, metadata, context) => {
|
|
155
|
+
const templateProperty = getMetadataProperty(metadata, 'template');
|
|
156
|
+
if (templateProperty && templateReferencesMember(memberName, getStringValue(templateProperty.value))) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const templateUrlProperty = getMetadataProperty(metadata, 'templateUrl');
|
|
161
|
+
if (templateUrlProperty) {
|
|
162
|
+
const templateUrl = getStringValue(templateUrlProperty.value);
|
|
163
|
+
if (templateUrl) {
|
|
164
|
+
const templatePath = path.resolve(path.dirname(context.filename), templateUrl);
|
|
165
|
+
const templateText = readFileIfExists(templatePath);
|
|
166
|
+
if (templateReferencesMember(memberName, templateText)) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const hostProperty = getMetadataProperty(metadata, 'host');
|
|
173
|
+
if (hostProperty && hostProperty.value.type === 'ObjectExpression') {
|
|
174
|
+
for (const property of hostProperty.value.properties) {
|
|
175
|
+
if (!property || property.type !== 'Property') continue;
|
|
176
|
+
if (!isDynamicHostBindingKey(getPropertyKeyName(property))) continue;
|
|
177
|
+
|
|
178
|
+
if (textReferencesMember(memberName, getStringValue(property.value))) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return false;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @param {any} node
|
|
189
|
+
*/
|
|
190
|
+
const isInjectCall = (node) => {
|
|
191
|
+
if (!node || node.type !== 'PropertyDefinition' || !node.value) return false;
|
|
192
|
+
if (node.value.type !== 'CallExpression') return false;
|
|
193
|
+
return node.value.callee.type === 'Identifier' && node.value.callee.name === 'inject';
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param {any} node
|
|
198
|
+
*/
|
|
199
|
+
const getMemberName = (node) => {
|
|
200
|
+
if (node.key.type === 'Identifier') return node.key.name;
|
|
201
|
+
if (node.key.type === 'Literal' && typeof node.key.value === 'string') return node.key.value;
|
|
202
|
+
return null;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @param {any} node
|
|
207
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
208
|
+
* @param {'private' | 'protected'} expectedAccessibility
|
|
209
|
+
*/
|
|
210
|
+
const buildFix = (node, sourceCode, expectedAccessibility) => {
|
|
211
|
+
const keyToken = sourceCode.getFirstToken(node.key);
|
|
212
|
+
if (!keyToken) return null;
|
|
213
|
+
|
|
214
|
+
if (node.accessibility) {
|
|
215
|
+
const firstToken = sourceCode.getFirstToken(node);
|
|
216
|
+
if (!firstToken) return null;
|
|
217
|
+
|
|
218
|
+
return (fixer) => fixer.replaceText(firstToken, expectedAccessibility);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return (fixer) => fixer.insertTextBefore(keyToken, `${expectedAccessibility} `);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
225
|
+
const injectMemberAccessibility = {
|
|
226
|
+
meta: {
|
|
227
|
+
type: 'problem',
|
|
228
|
+
docs: {
|
|
229
|
+
description:
|
|
230
|
+
'Require injected class members to be private by default, and protected when referenced from an Angular template or host binding.',
|
|
231
|
+
recommended: true,
|
|
232
|
+
},
|
|
233
|
+
fixable: 'code',
|
|
234
|
+
schema: [],
|
|
235
|
+
messages: {
|
|
236
|
+
shouldBePrivate:
|
|
237
|
+
'Injected member `{{name}}` should be private. Use protected only when it is referenced from a template or host binding.',
|
|
238
|
+
shouldBeProtected:
|
|
239
|
+
'Injected member `{{name}}` should be protected because it is referenced from a template or host binding.',
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
create(context) {
|
|
243
|
+
const sourceCode = context.sourceCode;
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
PropertyDefinition(node) {
|
|
247
|
+
if (!isInjectCall(node)) return;
|
|
248
|
+
|
|
249
|
+
const classNode = node.parent && node.parent.type === 'ClassBody' ? node.parent.parent : null;
|
|
250
|
+
if (!classNode || (classNode.type !== 'ClassDeclaration' && classNode.type !== 'ClassExpression')) return;
|
|
251
|
+
|
|
252
|
+
const memberName = getMemberName(node);
|
|
253
|
+
if (!memberName) return;
|
|
254
|
+
|
|
255
|
+
const angularMetadata = getAngularMetadata(classNode, context);
|
|
256
|
+
const isTemplateOrHostVisible = angularMetadata
|
|
257
|
+
? isReferencedFromTemplateOrHost(memberName, angularMetadata.metadata, context)
|
|
258
|
+
: false;
|
|
259
|
+
|
|
260
|
+
if (isTemplateOrHostVisible) {
|
|
261
|
+
if (node.accessibility === 'protected') return;
|
|
262
|
+
|
|
263
|
+
context.report({
|
|
264
|
+
node,
|
|
265
|
+
messageId: 'shouldBeProtected',
|
|
266
|
+
data: { name: memberName },
|
|
267
|
+
fix: buildFix(node, sourceCode, 'protected'),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (node.accessibility === 'private' || node.accessibility === 'public') return;
|
|
274
|
+
|
|
275
|
+
context.report({
|
|
276
|
+
node,
|
|
277
|
+
messageId: 'shouldBePrivate',
|
|
278
|
+
data: { name: memberName },
|
|
279
|
+
fix: buildFix(node, sourceCode, 'private'),
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
module.exports = injectMemberAccessibility;
|