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

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.
@@ -18,6 +18,7 @@ const noPublicProperty = {
18
18
  'Disallow explicit public on class properties, except for injected members that intentionally expose an external API.',
19
19
  recommended: true,
20
20
  },
21
+ fixable: 'code',
21
22
  schema: [],
22
23
  messages: {
23
24
  noPublicProperty:
@@ -30,7 +31,17 @@ const noPublicProperty = {
30
31
  if (node.accessibility !== 'public') return;
31
32
  if (isInjectCall(node.value)) return;
32
33
 
33
- context.report({ node, messageId: 'noPublicProperty' });
34
+ context.report({
35
+ node,
36
+ messageId: 'noPublicProperty',
37
+ fix(fixer) {
38
+ const publicToken = context.sourceCode.getFirstToken(node, (token) => token.value === 'public');
39
+ const nextToken = publicToken ? context.sourceCode.getTokenAfter(publicToken) : null;
40
+ if (!publicToken || !nextToken) return null;
41
+
42
+ return fixer.removeRange([publicToken.range[0], nextToken.range[0]]);
43
+ },
44
+ });
34
45
  },
35
46
  };
36
47
  },
@@ -0,0 +1,95 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /** @typedef {'Component' | 'Directive' | 'Pipe'} TDecoratorName */
5
+
6
+ /**
7
+ * @param {import('estree').Property['key']} key
8
+ */
9
+ const getPropertyName = (key) => {
10
+ if (key.type === 'Identifier') return key.name;
11
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
12
+ return null;
13
+ };
14
+
15
+ /**
16
+ * @param {import('eslint').Rule.Node} node
17
+ * @returns {TDecoratorName | null}
18
+ */
19
+ const getDecoratorName = (node) => {
20
+ const decorator = /** @type {any} */ (node);
21
+ if (decorator.type !== 'Decorator') return null;
22
+
23
+ const expression = decorator.expression;
24
+ if (expression.type === 'CallExpression') {
25
+ return expression.callee.type === 'Identifier' ? expression.callee.name : null;
26
+ }
27
+
28
+ return expression.type === 'Identifier' ? expression.name : null;
29
+ };
30
+
31
+ /**
32
+ * @param {import('eslint').SourceCode} sourceCode
33
+ * @param {any} metadata
34
+ * @param {any} property
35
+ */
36
+ const getRemovalRange = (sourceCode, metadata, property) => {
37
+ const properties = metadata.properties.filter((entry) => entry.type === 'Property');
38
+ const propertyIndex = properties.indexOf(property);
39
+ const openingBrace = sourceCode.getFirstToken(metadata);
40
+ const closingBrace = sourceCode.getLastToken(metadata);
41
+
42
+ if (!openingBrace || !closingBrace) return property.range;
43
+ if (properties.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
44
+ if (propertyIndex < properties.length - 1) return [property.range[0], properties[propertyIndex + 1].range[0]];
45
+
46
+ return [properties[propertyIndex - 1].range[1], property.range[1]];
47
+ };
48
+
49
+ /** @type {import('eslint').Rule.RuleModule} */
50
+ const noStandaloneFlag = {
51
+ meta: {
52
+ type: 'suggestion',
53
+ docs: {
54
+ description:
55
+ 'Disallow standalone metadata on Angular decorators because standalone is the default and should be omitted.',
56
+ },
57
+ fixable: 'code',
58
+ schema: [],
59
+ messages: {
60
+ noStandalone: 'Remove standalone from Angular metadata. Standalone is implicit and should not be declared.',
61
+ },
62
+ },
63
+ create(context) {
64
+ const sourceCode = context.sourceCode;
65
+
66
+ return {
67
+ Decorator(node) {
68
+ const decoratorName = getDecoratorName(node);
69
+ if (decoratorName !== 'Component' && decoratorName !== 'Directive' && decoratorName !== 'Pipe') return;
70
+
71
+ const decorator = /** @type {any} */ (node);
72
+ const expression = decorator.expression;
73
+ if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
74
+
75
+ const metadata = expression.arguments[0];
76
+ if (!metadata || metadata.type !== 'ObjectExpression') return;
77
+
78
+ for (const property of metadata.properties) {
79
+ if (property.type !== 'Property') continue;
80
+ if (getPropertyName(property.key) !== 'standalone') continue;
81
+
82
+ context.report({
83
+ node: property,
84
+ messageId: 'noStandalone',
85
+ fix(fixer) {
86
+ return fixer.replaceTextRange(getRemovalRange(sourceCode, metadata, property), '');
87
+ },
88
+ });
89
+ }
90
+ },
91
+ };
92
+ },
93
+ };
94
+
95
+ module.exports = noStandaloneFlag;
@@ -0,0 +1,271 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const COMPONENT_ORDER = ['selector', 'template', 'styleUrl', 'encapsulation', 'changeDetection'];
5
+
6
+ /**
7
+ * @param {import('estree').Property['key']} key
8
+ */
9
+ const getPropertyName = (key) => {
10
+ if (key.type === 'Identifier') return key.name;
11
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
12
+ return null;
13
+ };
14
+
15
+ /**
16
+ * @param {string | null} propertyName
17
+ */
18
+ const getOrderKey = (propertyName) => {
19
+ if (propertyName === 'template' || propertyName === 'templateUrl') return 'template';
20
+ if (propertyName === 'styleUrl' || propertyName === 'styleUrls') return 'styleUrl';
21
+ return propertyName;
22
+ };
23
+
24
+ /**
25
+ * @param {import('eslint').SourceCode} sourceCode
26
+ * @param {import('eslint').AST.Token | import('eslint').Rule.Node} node
27
+ */
28
+ const getIndent = (sourceCode, node) => {
29
+ if (!node.loc) return '';
30
+
31
+ const line = sourceCode.lines[node.loc.start.line - 1] ?? '';
32
+ return line.slice(0, node.loc.start.column);
33
+ };
34
+
35
+ /**
36
+ * @param {import('eslint').SourceCode} sourceCode
37
+ * @param {any} property
38
+ */
39
+ const hasTrailingComma = (sourceCode, property) => {
40
+ const tokenAfter = sourceCode.getTokenAfter(property);
41
+ return Boolean(tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',');
42
+ };
43
+
44
+ /**
45
+ * @param {import('eslint').SourceCode} sourceCode
46
+ */
47
+ const findAngularCoreImport = (sourceCode) =>
48
+ sourceCode.ast.body.find(
49
+ (node) =>
50
+ node.type === 'ImportDeclaration' && node.source.type === 'Literal' && node.source.value === '@angular/core',
51
+ ) ?? null;
52
+
53
+ /**
54
+ * @param {import('eslint').SourceCode} sourceCode
55
+ * @param {any} importNode
56
+ */
57
+ const buildAngularCoreImportFix = (sourceCode, importNode) => {
58
+ const specifiers = importNode.specifiers ?? [];
59
+ const hasChangeDetectionStrategy = specifiers.some(
60
+ (specifier) =>
61
+ specifier.type === 'ImportSpecifier' &&
62
+ specifier.imported.type === 'Identifier' &&
63
+ specifier.imported.name === 'ChangeDetectionStrategy',
64
+ );
65
+ if (hasChangeDetectionStrategy) return null;
66
+
67
+ const defaultSpecifier = specifiers.find((specifier) => specifier.type === 'ImportDefaultSpecifier') ?? null;
68
+ const namespaceSpecifier = specifiers.find((specifier) => specifier.type === 'ImportNamespaceSpecifier') ?? null;
69
+ const namedSpecifiers = specifiers.filter((specifier) => specifier.type === 'ImportSpecifier');
70
+
71
+ if (namespaceSpecifier) {
72
+ return (fixer) => fixer.insertTextAfter(importNode, `\nimport { ChangeDetectionStrategy } from '@angular/core';`);
73
+ }
74
+
75
+ const importParts = [];
76
+ if (defaultSpecifier) {
77
+ importParts.push(sourceCode.getText(defaultSpecifier));
78
+ }
79
+
80
+ const namedTexts = namedSpecifiers.map((specifier) => sourceCode.getText(specifier));
81
+ importParts.push(`{ ${[...namedTexts, 'ChangeDetectionStrategy'].join(', ')} }`);
82
+
83
+ return (fixer) => fixer.replaceText(importNode, `import ${importParts.join(', ')} from '@angular/core';`);
84
+ };
85
+
86
+ /**
87
+ * @param {import('eslint').SourceCode} sourceCode
88
+ */
89
+ const buildMissingAngularCoreImportFix = (sourceCode) => {
90
+ const importDeclarations = sourceCode.ast.body.filter((node) => node.type === 'ImportDeclaration');
91
+ const lastImport = importDeclarations[importDeclarations.length - 1] ?? null;
92
+
93
+ return lastImport
94
+ ? (fixer) => fixer.insertTextAfter(lastImport, `\nimport { ChangeDetectionStrategy } from '@angular/core';`)
95
+ : (fixer) =>
96
+ fixer.replaceTextRange(
97
+ [0, sourceCode.ast.range[0]],
98
+ `import { ChangeDetectionStrategy } from '@angular/core';\n\n`,
99
+ );
100
+ };
101
+
102
+ /**
103
+ * @param {any} metadata
104
+ */
105
+ const getChangeDetectionInsertionTarget = (metadata) => {
106
+ const changeDetectionIndex = COMPONENT_ORDER.indexOf('changeDetection');
107
+
108
+ return (
109
+ metadata.properties.find((property) => {
110
+ if (property.type !== 'Property') return false;
111
+
112
+ const propertyName = getOrderKey(getPropertyName(property.key));
113
+ const propertyIndex = propertyName === null ? COMPONENT_ORDER.length : COMPONENT_ORDER.indexOf(propertyName);
114
+ return propertyIndex > changeDetectionIndex || propertyIndex === -1;
115
+ }) ?? null
116
+ );
117
+ };
118
+
119
+ /**
120
+ * @param {import('eslint').SourceCode} sourceCode
121
+ * @param {any} metadata
122
+ */
123
+ const buildMetadataFix = (sourceCode, metadata) => {
124
+ const changeDetectionText = 'changeDetection: ChangeDetectionStrategy.OnPush';
125
+ const properties = metadata.properties.filter((property) => property.type === 'Property');
126
+ const insertionTarget = getChangeDetectionInsertionTarget(metadata);
127
+ const isMultiline = Boolean(metadata.loc && metadata.loc.start.line !== metadata.loc.end.line);
128
+
129
+ if (properties.length === 0) {
130
+ return (fixer) => fixer.replaceText(metadata, `{ ${changeDetectionText} }`);
131
+ }
132
+
133
+ if (insertionTarget) {
134
+ if (isMultiline) {
135
+ const propertyIndent = getIndent(sourceCode, insertionTarget);
136
+ return (fixer) => fixer.insertTextBefore(insertionTarget, `${changeDetectionText},\n${propertyIndent}`);
137
+ }
138
+
139
+ return (fixer) => fixer.insertTextBefore(insertionTarget, `${changeDetectionText}, `);
140
+ }
141
+
142
+ const lastProperty = properties[properties.length - 1];
143
+ const closingBrace = sourceCode.getLastToken(metadata);
144
+ if (!lastProperty || !closingBrace) return null;
145
+
146
+ if (isMultiline) {
147
+ const closingIndent = getIndent(sourceCode, closingBrace);
148
+ const propertyIndent = getIndent(sourceCode, lastProperty);
149
+ const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
150
+ const rangeStart =
151
+ separator === ''
152
+ ? (sourceCode.getTokenAfter(lastProperty)?.range[1] ?? lastProperty.range[1])
153
+ : lastProperty.range[1];
154
+
155
+ return (fixer) =>
156
+ fixer.replaceTextRange(
157
+ [rangeStart, closingBrace.range[0]],
158
+ `${separator}\n${propertyIndent}${changeDetectionText}\n${closingIndent}`,
159
+ );
160
+ }
161
+
162
+ const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
163
+ return (fixer) =>
164
+ fixer.replaceTextRange([lastProperty.range[1], closingBrace.range[0]], `${separator} ${changeDetectionText} `);
165
+ };
166
+
167
+ /**
168
+ * @param {import('eslint').SourceCode} sourceCode
169
+ * @param {any} metadata
170
+ * @param {any} valueNode
171
+ */
172
+ const buildFix = (sourceCode, metadata, valueNode) => {
173
+ const fixes = [];
174
+ const angularCoreImport = findAngularCoreImport(sourceCode);
175
+ const importFix = angularCoreImport
176
+ ? buildAngularCoreImportFix(sourceCode, angularCoreImport)
177
+ : buildMissingAngularCoreImportFix(sourceCode);
178
+
179
+ if (importFix) fixes.push(importFix);
180
+
181
+ if (valueNode) {
182
+ fixes.push((fixer) => fixer.replaceText(valueNode, 'ChangeDetectionStrategy.OnPush'));
183
+ } else {
184
+ const metadataFix = buildMetadataFix(sourceCode, metadata);
185
+ if (metadataFix) fixes.push(metadataFix);
186
+ }
187
+
188
+ return (fixer) => fixes.map((applyFix) => applyFix(fixer));
189
+ };
190
+
191
+ /** @type {import('eslint').Rule.RuleModule} */
192
+ const requireOnPushChangeDetection = {
193
+ meta: {
194
+ type: 'suggestion',
195
+ docs: {
196
+ description: 'Require `changeDetection: ChangeDetectionStrategy.OnPush` in all @Component decorators.',
197
+ },
198
+ fixable: 'code',
199
+ messages: {
200
+ missing:
201
+ 'Add `changeDetection: ChangeDetectionStrategy.OnPush` to @Component. Default change detection is not allowed.',
202
+ notOnPush: '`changeDetection` must be `ChangeDetectionStrategy.OnPush`. Got `ChangeDetectionStrategy.{{value}}`.',
203
+ },
204
+ schema: [],
205
+ },
206
+ create(context) {
207
+ const sourceCode = context.sourceCode;
208
+
209
+ /**
210
+ * @param {import('eslint').Rule.Node} node
211
+ */
212
+ const isComponentDecorator = (node) => {
213
+ const decorator = /** @type {any} */ (node);
214
+ if (decorator.type !== 'Decorator') return false;
215
+
216
+ const expression = decorator.expression;
217
+ if (expression.type === 'CallExpression') {
218
+ return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
219
+ }
220
+
221
+ return expression.type === 'Identifier' && expression.name === 'Component';
222
+ };
223
+
224
+ return {
225
+ Decorator(node) {
226
+ if (!isComponentDecorator(node)) return;
227
+
228
+ const decorator = /** @type {any} */ (node);
229
+ const expression = decorator.expression;
230
+ if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
231
+
232
+ const metadata = expression.arguments[0];
233
+ if (!metadata || metadata.type !== 'ObjectExpression') return;
234
+
235
+ const changeDetectionProp = metadata.properties.find(
236
+ (property) =>
237
+ property.type === 'Property' &&
238
+ property.key.type === 'Identifier' &&
239
+ property.key.name === 'changeDetection',
240
+ );
241
+
242
+ if (!changeDetectionProp) {
243
+ context.report({
244
+ node: metadata,
245
+ messageId: 'missing',
246
+ fix: buildFix(sourceCode, metadata, null),
247
+ });
248
+ return;
249
+ }
250
+
251
+ const value = changeDetectionProp.value;
252
+ if (
253
+ value.type === 'MemberExpression' &&
254
+ value.object.type === 'Identifier' &&
255
+ value.object.name === 'ChangeDetectionStrategy' &&
256
+ value.property.type === 'Identifier' &&
257
+ value.property.name !== 'OnPush'
258
+ ) {
259
+ context.report({
260
+ node: value,
261
+ messageId: 'notOnPush',
262
+ data: { value: value.property.name },
263
+ fix: buildFix(sourceCode, metadata, value),
264
+ });
265
+ }
266
+ },
267
+ };
268
+ },
269
+ };
270
+
271
+ module.exports = requireOnPushChangeDetection;