@ethlete/eslint-plugin 0.1.0-beta.5
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 +39 -0
- package/README.md +25 -0
- package/package.json +14 -0
- package/src/configs/recommended.js +403 -0
- package/src/index.js +136 -0
- package/src/rules/angular-decorator-property-order.js +205 -0
- package/src/rules/class-constant-property.js +344 -0
- package/src/rules/class-member-order.js +431 -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 +93 -0
- package/src/rules/internals/angular-member-visibility.js +233 -0
- package/src/rules/internals/implemented-contract-members.js +294 -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-empty-angular-metadata-arrays.js +104 -0
- package/src/rules/no-empty-newlines-between-imports.js +45 -0
- package/src/rules/no-inject-chain.js +44 -0
- package/src/rules/no-leading-underscore-class-member.js +120 -0
- package/src/rules/no-legacy-angular-decorators.js +405 -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 +85 -0
- package/src/rules/no-readonly-signal.js +119 -0
- package/src/rules/no-redundant-internal.js +94 -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-standalone-flag.js +95 -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-typed-injected-element-ref.js +88 -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-on-push-change-detection.js +271 -0
- package/src/rules/require-view-encapsulation-none.js +264 -0
- package/src/rules/template-member-accessibility.js +190 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** @typedef {'inject' | 'input' | 'output' | 'query' | 'property' | 'constructor' | 'method' | 'private-method'} TMemberGroup */
|
|
5
|
+
/** @typedef {{ dependencies: Set<string>; group: TMemberGroup; groupIndex: number; name: string; node: any; originalIndex: number }} TMemberEntry */
|
|
6
|
+
/** @typedef {TMemberEntry & { segmentText: string }} TSegmentEntry */
|
|
7
|
+
|
|
8
|
+
const INPUT_APIS = new Set(['input', 'model']);
|
|
9
|
+
const OUTPUT_APIS = new Set(['output', 'outputFromObservable']);
|
|
10
|
+
const QUERY_APIS = new Set(['viewChild', 'viewChildren', 'contentChild', 'contentChildren']);
|
|
11
|
+
|
|
12
|
+
/** @type {TMemberGroup[]} */
|
|
13
|
+
const MEMBER_GROUP_ORDER = [
|
|
14
|
+
'inject',
|
|
15
|
+
'input',
|
|
16
|
+
'output',
|
|
17
|
+
'query',
|
|
18
|
+
'property',
|
|
19
|
+
'constructor',
|
|
20
|
+
'method',
|
|
21
|
+
'private-method',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const MEMBER_GROUP_ORDER_TEXT =
|
|
25
|
+
'inject() members, inputs/model(), outputs, view/content queries, other properties, constructor, public methods, private methods';
|
|
26
|
+
|
|
27
|
+
const MEMBER_GROUP_INDEX = new Map(
|
|
28
|
+
MEMBER_GROUP_ORDER.map(/** @param {TMemberGroup} group @param {number} index */ (group, index) => [group, index]),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {any} key
|
|
33
|
+
*/
|
|
34
|
+
const getMemberNameFromKey = (key) => {
|
|
35
|
+
if (!key) return null;
|
|
36
|
+
if (key.type === 'Identifier') return key.name;
|
|
37
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
38
|
+
if (key.type === 'PrivateIdentifier') return key.name;
|
|
39
|
+
|
|
40
|
+
return null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {any} node
|
|
45
|
+
*/
|
|
46
|
+
const getMemberName = (node) => {
|
|
47
|
+
if (node.type === 'MethodDefinition' && node.kind === 'constructor') {
|
|
48
|
+
return 'constructor';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return getMemberNameFromKey(node.key);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {any} value
|
|
56
|
+
*/
|
|
57
|
+
const getCallRootName = (value) => {
|
|
58
|
+
if (!value || value.type !== 'CallExpression') return null;
|
|
59
|
+
|
|
60
|
+
const { callee } = value;
|
|
61
|
+
|
|
62
|
+
if (callee.type === 'Identifier') {
|
|
63
|
+
return callee.name;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (callee.type === 'MemberExpression' && !callee.computed && callee.object.type === 'Identifier') {
|
|
67
|
+
return callee.object.name;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {any} node
|
|
75
|
+
* @param {Set<string>} apiNames
|
|
76
|
+
*/
|
|
77
|
+
const isPropertyInitializedWith = (node, apiNames) => {
|
|
78
|
+
if (node.type !== 'PropertyDefinition') return false;
|
|
79
|
+
|
|
80
|
+
const apiName = getCallRootName(node.value);
|
|
81
|
+
return apiName !== null && apiNames.has(apiName);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {any} node
|
|
86
|
+
* @returns {TMemberGroup | null}
|
|
87
|
+
*/
|
|
88
|
+
const getMemberGroup = (node) => {
|
|
89
|
+
if (node.static) return null;
|
|
90
|
+
|
|
91
|
+
if (node.type === 'PropertyDefinition') {
|
|
92
|
+
if (isPropertyInitializedWith(node, new Set(['inject']))) {
|
|
93
|
+
return 'inject';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (isPropertyInitializedWith(node, INPUT_APIS)) {
|
|
97
|
+
return 'input';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isPropertyInitializedWith(node, OUTPUT_APIS)) {
|
|
101
|
+
return 'output';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isPropertyInitializedWith(node, QUERY_APIS)) {
|
|
105
|
+
return 'query';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return 'property';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (node.type === 'MethodDefinition') {
|
|
112
|
+
if (node.kind === 'constructor') {
|
|
113
|
+
return 'constructor';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (node.accessibility === 'private') {
|
|
117
|
+
return 'private-method';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return 'method';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return null;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {any} node
|
|
128
|
+
* @param {Set<string>} references
|
|
129
|
+
*/
|
|
130
|
+
const collectThisReferences = (node, references) => {
|
|
131
|
+
if (!node || typeof node !== 'object') return;
|
|
132
|
+
|
|
133
|
+
if (Array.isArray(node)) {
|
|
134
|
+
for (const entry of node) {
|
|
135
|
+
collectThisReferences(entry, references);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (
|
|
142
|
+
node.type === 'MemberExpression' &&
|
|
143
|
+
!node.computed &&
|
|
144
|
+
node.object?.type === 'ThisExpression' &&
|
|
145
|
+
(node.property?.type === 'Identifier' || node.property?.type === 'PrivateIdentifier')
|
|
146
|
+
) {
|
|
147
|
+
references.add(node.property.name);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const [key, value] of Object.entries(node)) {
|
|
151
|
+
if (key === 'parent' || key === 'range' || key === 'loc') continue;
|
|
152
|
+
if (!value || typeof value !== 'object') continue;
|
|
153
|
+
|
|
154
|
+
collectThisReferences(value, references);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {any} node
|
|
160
|
+
*/
|
|
161
|
+
const getInitializerDependencies = (node) => {
|
|
162
|
+
if (node.type !== 'PropertyDefinition' || !node.value) {
|
|
163
|
+
return new Set();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const dependencies = new Set();
|
|
167
|
+
collectThisReferences(node.value, dependencies);
|
|
168
|
+
|
|
169
|
+
const memberName = getMemberName(node);
|
|
170
|
+
|
|
171
|
+
if (memberName !== null) {
|
|
172
|
+
dependencies.delete(memberName);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return dependencies;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {any} node
|
|
180
|
+
* @param {number} originalIndex
|
|
181
|
+
* @returns {TMemberEntry | null}
|
|
182
|
+
*/
|
|
183
|
+
const toMemberEntry = (node, originalIndex) => {
|
|
184
|
+
const group = getMemberGroup(node);
|
|
185
|
+
const name = getMemberName(node);
|
|
186
|
+
|
|
187
|
+
if (group === null || name === null) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
dependencies: getInitializerDependencies(node),
|
|
193
|
+
group,
|
|
194
|
+
groupIndex: MEMBER_GROUP_INDEX.get(group),
|
|
195
|
+
name,
|
|
196
|
+
node,
|
|
197
|
+
originalIndex,
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {TMemberEntry} currentMember
|
|
203
|
+
* @param {TMemberEntry} earlierMember
|
|
204
|
+
*/
|
|
205
|
+
const getOrderingConflict = (currentMember, earlierMember) => {
|
|
206
|
+
if (currentMember.dependencies.has(earlierMember.name)) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (earlierMember.dependencies.has(currentMember.name)) {
|
|
211
|
+
return {
|
|
212
|
+
type: 'dependency',
|
|
213
|
+
blockingMember: earlierMember,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (currentMember.groupIndex < earlierMember.groupIndex) {
|
|
218
|
+
return {
|
|
219
|
+
type: 'group',
|
|
220
|
+
blockingMember: earlierMember,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return null;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @param {TMemberEntry[]} members
|
|
229
|
+
*/
|
|
230
|
+
const sortMembers = (members) => {
|
|
231
|
+
/** @type {TMemberEntry[]} */
|
|
232
|
+
const remainingMembers = [...members];
|
|
233
|
+
/** @type {TMemberEntry[]} */
|
|
234
|
+
const sortedMembers = [];
|
|
235
|
+
|
|
236
|
+
while (remainingMembers.length > 0) {
|
|
237
|
+
const readyMembers = remainingMembers
|
|
238
|
+
.filter((member) =>
|
|
239
|
+
[...member.dependencies].every(
|
|
240
|
+
(dependencyName) => !remainingMembers.some((remainingMember) => remainingMember.name === dependencyName),
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
.sort((left, right) => {
|
|
244
|
+
if (left.groupIndex !== right.groupIndex) {
|
|
245
|
+
return left.groupIndex - right.groupIndex;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return left.originalIndex - right.originalIndex;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const nextMember = readyMembers[0];
|
|
252
|
+
|
|
253
|
+
if (!nextMember) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
sortedMembers.push(nextMember);
|
|
258
|
+
|
|
259
|
+
const nextMemberIndex = remainingMembers.indexOf(nextMember);
|
|
260
|
+
remainingMembers.splice(nextMemberIndex, 1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return sortedMembers;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
268
|
+
* @param {any} classBodyNode
|
|
269
|
+
* @param {TMemberEntry[]} members
|
|
270
|
+
*/
|
|
271
|
+
const getSegmentEntries = (sourceCode, classBodyNode, members) => {
|
|
272
|
+
const openingBrace = sourceCode.getFirstToken(classBodyNode);
|
|
273
|
+
const closingBrace = sourceCode.getLastToken(classBodyNode);
|
|
274
|
+
|
|
275
|
+
if (!openingBrace || !closingBrace || openingBrace.value !== '{' || closingBrace.value !== '}') {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let segmentStart = openingBrace.range[1];
|
|
280
|
+
|
|
281
|
+
const segmentEntries = members.map((member) => {
|
|
282
|
+
const segmentText = sourceCode.text.slice(segmentStart, member.node.range[1]);
|
|
283
|
+
segmentStart = member.node.range[1];
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
...member,
|
|
287
|
+
segmentText,
|
|
288
|
+
};
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const suffix = sourceCode.text.slice(segmentStart, closingBrace.range[0]);
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
closingBrace,
|
|
295
|
+
openingBrace,
|
|
296
|
+
segmentEntries,
|
|
297
|
+
suffix,
|
|
298
|
+
};
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
303
|
+
* @param {any} classBodyNode
|
|
304
|
+
* @param {TMemberEntry[]} members
|
|
305
|
+
*/
|
|
306
|
+
const buildClassOrderFix = (sourceCode, classBodyNode, members) => {
|
|
307
|
+
if (classBodyNode.body.length !== members.length) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const sortedMembers = sortMembers(members);
|
|
312
|
+
|
|
313
|
+
if (!sortedMembers) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const segments = getSegmentEntries(sourceCode, classBodyNode, members);
|
|
318
|
+
|
|
319
|
+
if (!segments) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const { closingBrace, openingBrace, segmentEntries, suffix } = segments;
|
|
324
|
+
const segmentEntriesByOriginalIndex = new Map(
|
|
325
|
+
segmentEntries.map(
|
|
326
|
+
/** @param {TSegmentEntry} segmentEntry */ (segmentEntry) => [segmentEntry.originalIndex, segmentEntry],
|
|
327
|
+
),
|
|
328
|
+
);
|
|
329
|
+
const sortedSegmentEntries = sortedMembers.map((member) => segmentEntriesByOriginalIndex.get(member.originalIndex));
|
|
330
|
+
|
|
331
|
+
if (sortedSegmentEntries.some((entry) => entry === undefined)) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const isAlreadySorted = sortedSegmentEntries.every((entry, index) => entry === segmentEntries[index]);
|
|
336
|
+
|
|
337
|
+
if (isAlreadySorted) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const reorderedBody = sortedSegmentEntries.map((entry) => entry.segmentText).join('') + suffix;
|
|
342
|
+
|
|
343
|
+
return (fixer) => fixer.replaceTextRange([openingBrace.range[1], closingBrace.range[0]], reorderedBody);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
347
|
+
const classMemberOrder = {
|
|
348
|
+
meta: {
|
|
349
|
+
type: 'layout',
|
|
350
|
+
fixable: 'code',
|
|
351
|
+
docs: {
|
|
352
|
+
description:
|
|
353
|
+
'Require class members to follow the styleguide order for inject members, inputs, outputs, queries, properties, constructor, and methods.',
|
|
354
|
+
recommended: true,
|
|
355
|
+
},
|
|
356
|
+
schema: [],
|
|
357
|
+
messages: {
|
|
358
|
+
dependencyOrder:
|
|
359
|
+
"'{{name}}' should be declared before '{{dependentName}}' because '{{dependentName}}' references it in an initializer.",
|
|
360
|
+
groupOrder:
|
|
361
|
+
"'{{name}}' should be declared before '{{blockingName}}'. Class members should be ordered as: {{order}}.",
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
create(context) {
|
|
365
|
+
const sourceCode = context.sourceCode;
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
ClassBody(node) {
|
|
369
|
+
const members = node.body
|
|
370
|
+
.map(
|
|
371
|
+
/** @param {any} member @param {number} originalIndex */ (member, originalIndex) =>
|
|
372
|
+
toMemberEntry(member, originalIndex),
|
|
373
|
+
)
|
|
374
|
+
.filter(Boolean);
|
|
375
|
+
|
|
376
|
+
if (members.length < 2) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const fix = buildClassOrderFix(sourceCode, node, members);
|
|
381
|
+
let hasAttachedFix = false;
|
|
382
|
+
|
|
383
|
+
for (let currentIndex = 1; currentIndex < members.length; currentIndex++) {
|
|
384
|
+
const currentMember = members[currentIndex];
|
|
385
|
+
|
|
386
|
+
for (let earlierIndex = currentIndex - 1; earlierIndex >= 0; earlierIndex--) {
|
|
387
|
+
const earlierMember = members[earlierIndex];
|
|
388
|
+
const conflict = getOrderingConflict(currentMember, earlierMember);
|
|
389
|
+
|
|
390
|
+
if (!conflict) {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (conflict.type === 'dependency') {
|
|
395
|
+
context.report({
|
|
396
|
+
node: currentMember.node,
|
|
397
|
+
messageId: 'dependencyOrder',
|
|
398
|
+
data: {
|
|
399
|
+
name: currentMember.name,
|
|
400
|
+
dependentName: conflict.blockingMember.name,
|
|
401
|
+
},
|
|
402
|
+
fix: !hasAttachedFix ? fix : null,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
hasAttachedFix = hasAttachedFix || Boolean(fix);
|
|
406
|
+
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
context.report({
|
|
411
|
+
node: currentMember.node,
|
|
412
|
+
messageId: 'groupOrder',
|
|
413
|
+
data: {
|
|
414
|
+
name: currentMember.name,
|
|
415
|
+
blockingName: conflict.blockingMember.name,
|
|
416
|
+
order: MEMBER_GROUP_ORDER_TEXT,
|
|
417
|
+
},
|
|
418
|
+
fix: !hasAttachedFix ? fix : null,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
hasAttachedFix = hasAttachedFix || Boolean(fix);
|
|
422
|
+
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
module.exports = classMemberOrder;
|
|
@@ -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,93 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { getMemberName, isReferencedFromTemplateOrHost } = require('./internals/angular-member-visibility');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {any} node
|
|
8
|
+
*/
|
|
9
|
+
const isInjectCall = (node) => {
|
|
10
|
+
if (!node || node.type !== 'PropertyDefinition' || !node.value) return false;
|
|
11
|
+
if (node.value.type !== 'CallExpression') return false;
|
|
12
|
+
return node.value.callee.type === 'Identifier' && node.value.callee.name === 'inject';
|
|
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} `);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
35
|
+
const injectMemberAccessibility = {
|
|
36
|
+
meta: {
|
|
37
|
+
type: 'problem',
|
|
38
|
+
docs: {
|
|
39
|
+
description:
|
|
40
|
+
'Require injected class members to be private by default, protected when referenced from an Angular template or host binding, and allow explicit public when intentionally exposing an external API.',
|
|
41
|
+
recommended: true,
|
|
42
|
+
},
|
|
43
|
+
fixable: 'code',
|
|
44
|
+
schema: [],
|
|
45
|
+
messages: {
|
|
46
|
+
shouldBePrivate:
|
|
47
|
+
'Injected member `{{name}}` should be private. Use protected only when it is referenced from a template or host binding.',
|
|
48
|
+
shouldBeProtected:
|
|
49
|
+
'Injected member `{{name}}` should be protected because it is referenced from a template or host binding. Use public only when it intentionally exposes an external API across class boundaries.',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
create(context) {
|
|
53
|
+
const sourceCode = context.sourceCode;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
PropertyDefinition(node) {
|
|
57
|
+
if (!isInjectCall(node)) return;
|
|
58
|
+
|
|
59
|
+
const classNode = node.parent && node.parent.type === 'ClassBody' ? node.parent.parent : null;
|
|
60
|
+
if (!classNode || (classNode.type !== 'ClassDeclaration' && classNode.type !== 'ClassExpression')) return;
|
|
61
|
+
|
|
62
|
+
const memberName = getMemberName(node);
|
|
63
|
+
if (!memberName) return;
|
|
64
|
+
|
|
65
|
+
const isTemplateOrHostVisible = isReferencedFromTemplateOrHost(classNode, memberName, context);
|
|
66
|
+
|
|
67
|
+
if (isTemplateOrHostVisible) {
|
|
68
|
+
if (node.accessibility === 'protected' || node.accessibility === 'public') return;
|
|
69
|
+
|
|
70
|
+
context.report({
|
|
71
|
+
node,
|
|
72
|
+
messageId: 'shouldBeProtected',
|
|
73
|
+
data: { name: memberName },
|
|
74
|
+
fix: buildFix(node, sourceCode, 'protected'),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (node.accessibility === 'private' || node.accessibility === 'public') return;
|
|
81
|
+
|
|
82
|
+
context.report({
|
|
83
|
+
node,
|
|
84
|
+
messageId: 'shouldBePrivate',
|
|
85
|
+
data: { name: memberName },
|
|
86
|
+
fix: buildFix(node, sourceCode, 'private'),
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
module.exports = injectMemberAccessibility;
|