@ethlete/eslint-plugin 0.1.0-next.1 → 0.1.0-next.3

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.
@@ -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;
@@ -1,188 +1,7 @@
1
1
  // @ts-check
2
2
  'use strict';
3
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
- };
4
+ const { getMemberName, isReferencedFromTemplateOrHost } = require('./internals/angular-member-visibility');
186
5
 
187
6
  /**
188
7
  * @param {any} node
@@ -193,15 +12,6 @@ const isInjectCall = (node) => {
193
12
  return node.value.callee.type === 'Identifier' && node.value.callee.name === 'inject';
194
13
  };
195
14
 
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
15
  /**
206
16
  * @param {any} node
207
17
  * @param {import('eslint').SourceCode} sourceCode
@@ -227,7 +37,7 @@ const injectMemberAccessibility = {
227
37
  type: 'problem',
228
38
  docs: {
229
39
  description:
230
- 'Require injected class members to be private by default, and protected when referenced from an Angular template or host binding.',
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.',
231
41
  recommended: true,
232
42
  },
233
43
  fixable: 'code',
@@ -236,7 +46,7 @@ const injectMemberAccessibility = {
236
46
  shouldBePrivate:
237
47
  'Injected member `{{name}}` should be private. Use protected only when it is referenced from a template or host binding.',
238
48
  shouldBeProtected:
239
- 'Injected member `{{name}}` should be protected because it is referenced from a template or host binding.',
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.',
240
50
  },
241
51
  },
242
52
  create(context) {
@@ -252,13 +62,10 @@ const injectMemberAccessibility = {
252
62
  const memberName = getMemberName(node);
253
63
  if (!memberName) return;
254
64
 
255
- const angularMetadata = getAngularMetadata(classNode, context);
256
- const isTemplateOrHostVisible = angularMetadata
257
- ? isReferencedFromTemplateOrHost(memberName, angularMetadata.metadata, context)
258
- : false;
65
+ const isTemplateOrHostVisible = isReferencedFromTemplateOrHost(classNode, memberName, context);
259
66
 
260
67
  if (isTemplateOrHostVisible) {
261
- if (node.accessibility === 'protected') return;
68
+ if (node.accessibility === 'protected' || node.accessibility === 'public') return;
262
69
 
263
70
  context.report({
264
71
  node,