@ethlete/eslint-plugin 0.1.0-next.2 → 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,206 +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 {import('eslint').Rule.RuleContext} context
94
- */
95
- const getContextFilename = (context) => {
96
- const filename = context.physicalFilename || context.filename;
97
-
98
- if (!filename || filename === '<input>' || filename === '<text>') {
99
- return null;
100
- }
101
-
102
- return filename;
103
- };
104
-
105
- /**
106
- * @param {string} memberName
107
- * @param {string | null} template
108
- */
109
- const templateReferencesMember = (memberName, template) => {
110
- if (!template) return false;
111
-
112
- const escapedName = escapeRegExp(memberName);
113
- const patterns = [
114
- new RegExp(`\\{\\{[\\s\\S]*?\\b${escapedName}\\b[\\s\\S]*?\\}\\}`, 'u'),
115
- new RegExp(`@[a-zA-Z]+\\s*\\([\\s\\S]*?\\b${escapedName}\\b[\\s\\S]*?\\)`, 'u'),
116
- new RegExp(`@let\\s+[a-zA-Z_$][\\w$]*\\s*=\\s*[\\s\\S]*?\\b${escapedName}\\b`, 'u'),
117
- new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
118
- new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
119
- new RegExp(`\\[[^\\]]+\\]\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
120
- new RegExp(`\\[[^\\]]+\\]\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
121
- new RegExp(`\\([^\\)]+\\)\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
122
- new RegExp(`\\([^\\)]+\\)\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
123
- ];
124
-
125
- return patterns.some((pattern) => pattern.test(template));
126
- };
127
-
128
- /**
129
- * @param {string | null} keyName
130
- */
131
- const isDynamicHostBindingKey = (keyName) => {
132
- if (!keyName) return false;
133
-
134
- return keyName.startsWith('[') || keyName.startsWith('(') || keyName.startsWith('@');
135
- };
136
-
137
- /**
138
- * @param {any} classNode
139
- * @param {import('eslint').Rule.RuleContext} context
140
- */
141
- const getAngularMetadata = (classNode, context) => {
142
- for (const decorator of classNode.decorators || []) {
143
- const call = getDecoratorCall(decorator);
144
- if (!call) continue;
145
-
146
- if (call.name === 'Component' || call.name === 'Directive') {
147
- return call;
148
- }
149
- }
150
-
151
- return null;
152
- };
153
-
154
- /**
155
- * @param {string} memberName
156
- * @param {string | null} text
157
- */
158
- const textReferencesMember = (memberName, text) => {
159
- if (!text) return false;
160
-
161
- const pattern = new RegExp(`\\b${escapeRegExp(memberName)}\\b`, 'u');
162
- return pattern.test(text);
163
- };
164
-
165
- /**
166
- * @param {string} memberName
167
- * @param {any} metadata
168
- * @param {import('eslint').Rule.RuleContext} context
169
- */
170
- const isReferencedFromTemplateOrHost = (memberName, metadata, context) => {
171
- const templateProperty = getMetadataProperty(metadata, 'template');
172
- if (templateProperty && templateReferencesMember(memberName, getStringValue(templateProperty.value))) {
173
- return true;
174
- }
175
-
176
- const templateUrlProperty = getMetadataProperty(metadata, 'templateUrl');
177
- if (templateUrlProperty) {
178
- const templateUrl = getStringValue(templateUrlProperty.value);
179
- const filename = getContextFilename(context);
180
-
181
- if (templateUrl && filename) {
182
- const templatePath = path.resolve(path.dirname(filename), templateUrl);
183
- const templateText = readFileIfExists(templatePath);
184
- if (templateReferencesMember(memberName, templateText)) {
185
- return true;
186
- }
187
- }
188
- }
189
-
190
- const hostProperty = getMetadataProperty(metadata, 'host');
191
- if (hostProperty && hostProperty.value.type === 'ObjectExpression') {
192
- for (const property of hostProperty.value.properties) {
193
- if (!property || property.type !== 'Property') continue;
194
- if (!isDynamicHostBindingKey(getPropertyKeyName(property))) continue;
195
-
196
- if (textReferencesMember(memberName, getStringValue(property.value))) {
197
- return true;
198
- }
199
- }
200
- }
201
-
202
- return false;
203
- };
4
+ const { getMemberName, isReferencedFromTemplateOrHost } = require('./internals/angular-member-visibility');
204
5
 
205
6
  /**
206
7
  * @param {any} node
@@ -211,15 +12,6 @@ const isInjectCall = (node) => {
211
12
  return node.value.callee.type === 'Identifier' && node.value.callee.name === 'inject';
212
13
  };
213
14
 
214
- /**
215
- * @param {any} node
216
- */
217
- const getMemberName = (node) => {
218
- if (node.key.type === 'Identifier') return node.key.name;
219
- if (node.key.type === 'Literal' && typeof node.key.value === 'string') return node.key.value;
220
- return null;
221
- };
222
-
223
15
  /**
224
16
  * @param {any} node
225
17
  * @param {import('eslint').SourceCode} sourceCode
@@ -245,7 +37,7 @@ const injectMemberAccessibility = {
245
37
  type: 'problem',
246
38
  docs: {
247
39
  description:
248
- '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.',
249
41
  recommended: true,
250
42
  },
251
43
  fixable: 'code',
@@ -254,7 +46,7 @@ const injectMemberAccessibility = {
254
46
  shouldBePrivate:
255
47
  'Injected member `{{name}}` should be private. Use protected only when it is referenced from a template or host binding.',
256
48
  shouldBeProtected:
257
- '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.',
258
50
  },
259
51
  },
260
52
  create(context) {
@@ -270,13 +62,10 @@ const injectMemberAccessibility = {
270
62
  const memberName = getMemberName(node);
271
63
  if (!memberName) return;
272
64
 
273
- const angularMetadata = getAngularMetadata(classNode, context);
274
- const isTemplateOrHostVisible = angularMetadata
275
- ? isReferencedFromTemplateOrHost(memberName, angularMetadata.metadata, context)
276
- : false;
65
+ const isTemplateOrHostVisible = isReferencedFromTemplateOrHost(classNode, memberName, context);
277
66
 
278
67
  if (isTemplateOrHostVisible) {
279
- if (node.accessibility === 'protected') return;
68
+ if (node.accessibility === 'protected' || node.accessibility === 'public') return;
280
69
 
281
70
  context.report({
282
71
  node,