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

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,320 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const COMPONENT_ORDER = ['selector', 'template', 'styleUrl', 'encapsulation', 'changeDetection'];
5
+
6
+ // OnPush became the default change detection strategy in Angular 22, so from
7
+ // v22 onward it must NOT be declared (see no-redundant-on-push-change-detection).
8
+ // This rule therefore only applies to older Angular, where OnPush is opt-in.
9
+ const MAX_MAJOR = 21;
10
+
11
+ /** @type {number | null | undefined} */
12
+ let cachedAngularMajor;
13
+
14
+ /**
15
+ * Detect the workspace's installed Angular major version. Cached across files.
16
+ *
17
+ * @returns {number | null} major version, or null when it cannot be determined
18
+ */
19
+ const detectAngularMajor = () => {
20
+ if (cachedAngularMajor !== undefined) return cachedAngularMajor;
21
+
22
+ try {
23
+ const pkg = require('@angular/core/package.json');
24
+ const major = Number.parseInt(String(pkg.version).split('.')[0], 10);
25
+ cachedAngularMajor = Number.isNaN(major) ? null : major;
26
+ } catch {
27
+ cachedAngularMajor = null;
28
+ }
29
+
30
+ return cachedAngularMajor;
31
+ };
32
+
33
+ /**
34
+ * Resolve the Angular major version to gate on. An explicit
35
+ * `settings.ethlete.angularMajor` wins over auto-detection — handy for
36
+ * pinning behaviour and for tests.
37
+ *
38
+ * @param {import('eslint').Rule.RuleContext} context
39
+ * @returns {number | null}
40
+ */
41
+ const getAngularMajor = (context) => {
42
+ const settings = /** @type {any} */ (context.settings);
43
+ const override = settings?.ethlete?.angularMajor;
44
+ if (typeof override === 'number') return override;
45
+
46
+ return detectAngularMajor();
47
+ };
48
+
49
+ /**
50
+ * @param {import('estree').Property['key']} key
51
+ */
52
+ const getPropertyName = (key) => {
53
+ if (key.type === 'Identifier') return key.name;
54
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
55
+ return null;
56
+ };
57
+
58
+ /**
59
+ * @param {string | null} propertyName
60
+ */
61
+ const getOrderKey = (propertyName) => {
62
+ if (propertyName === 'template' || propertyName === 'templateUrl') return 'template';
63
+ if (propertyName === 'styleUrl' || propertyName === 'styleUrls') return 'styleUrl';
64
+ return propertyName;
65
+ };
66
+
67
+ /**
68
+ * @param {import('eslint').SourceCode} sourceCode
69
+ * @param {import('eslint').AST.Token | import('eslint').Rule.Node} node
70
+ */
71
+ const getIndent = (sourceCode, node) => {
72
+ if (!node.loc) return '';
73
+
74
+ const line = sourceCode.lines[node.loc.start.line - 1] ?? '';
75
+ return line.slice(0, node.loc.start.column);
76
+ };
77
+
78
+ /**
79
+ * @param {import('eslint').SourceCode} sourceCode
80
+ * @param {any} property
81
+ */
82
+ const hasTrailingComma = (sourceCode, property) => {
83
+ const tokenAfter = sourceCode.getTokenAfter(property);
84
+ return Boolean(tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',');
85
+ };
86
+
87
+ /**
88
+ * @param {import('eslint').SourceCode} sourceCode
89
+ */
90
+ const findAngularCoreImport = (sourceCode) =>
91
+ sourceCode.ast.body.find(
92
+ (node) =>
93
+ node.type === 'ImportDeclaration' && node.source.type === 'Literal' && node.source.value === '@angular/core',
94
+ ) ?? null;
95
+
96
+ /**
97
+ * @param {import('eslint').SourceCode} sourceCode
98
+ * @param {any} importNode
99
+ */
100
+ const buildAngularCoreImportFix = (sourceCode, importNode) => {
101
+ const specifiers = importNode.specifiers ?? [];
102
+ const hasChangeDetectionStrategy = specifiers.some(
103
+ (specifier) =>
104
+ specifier.type === 'ImportSpecifier' &&
105
+ specifier.imported.type === 'Identifier' &&
106
+ specifier.imported.name === 'ChangeDetectionStrategy',
107
+ );
108
+ if (hasChangeDetectionStrategy) return null;
109
+
110
+ const defaultSpecifier = specifiers.find((specifier) => specifier.type === 'ImportDefaultSpecifier') ?? null;
111
+ const namespaceSpecifier = specifiers.find((specifier) => specifier.type === 'ImportNamespaceSpecifier') ?? null;
112
+ const namedSpecifiers = specifiers.filter((specifier) => specifier.type === 'ImportSpecifier');
113
+
114
+ if (namespaceSpecifier) {
115
+ return (fixer) => fixer.insertTextAfter(importNode, `\nimport { ChangeDetectionStrategy } from '@angular/core';`);
116
+ }
117
+
118
+ const importParts = [];
119
+ if (defaultSpecifier) {
120
+ importParts.push(sourceCode.getText(defaultSpecifier));
121
+ }
122
+
123
+ const namedTexts = namedSpecifiers.map((specifier) => sourceCode.getText(specifier));
124
+ importParts.push(`{ ${[...namedTexts, 'ChangeDetectionStrategy'].join(', ')} }`);
125
+
126
+ return (fixer) => fixer.replaceText(importNode, `import ${importParts.join(', ')} from '@angular/core';`);
127
+ };
128
+
129
+ /**
130
+ * @param {import('eslint').SourceCode} sourceCode
131
+ */
132
+ const buildMissingAngularCoreImportFix = (sourceCode) => {
133
+ const importDeclarations = sourceCode.ast.body.filter((node) => node.type === 'ImportDeclaration');
134
+ const lastImport = importDeclarations[importDeclarations.length - 1] ?? null;
135
+
136
+ return lastImport
137
+ ? (fixer) => fixer.insertTextAfter(lastImport, `\nimport { ChangeDetectionStrategy } from '@angular/core';`)
138
+ : (fixer) =>
139
+ fixer.replaceTextRange(
140
+ [0, sourceCode.ast.range[0]],
141
+ `import { ChangeDetectionStrategy } from '@angular/core';\n\n`,
142
+ );
143
+ };
144
+
145
+ /**
146
+ * @param {any} metadata
147
+ */
148
+ const getChangeDetectionInsertionTarget = (metadata) => {
149
+ const changeDetectionIndex = COMPONENT_ORDER.indexOf('changeDetection');
150
+
151
+ return (
152
+ metadata.properties.find((property) => {
153
+ if (property.type !== 'Property') return false;
154
+
155
+ const propertyName = getOrderKey(getPropertyName(property.key));
156
+ const propertyIndex = propertyName === null ? COMPONENT_ORDER.length : COMPONENT_ORDER.indexOf(propertyName);
157
+ return propertyIndex > changeDetectionIndex || propertyIndex === -1;
158
+ }) ?? null
159
+ );
160
+ };
161
+
162
+ /**
163
+ * @param {import('eslint').SourceCode} sourceCode
164
+ * @param {any} metadata
165
+ */
166
+ const buildMetadataFix = (sourceCode, metadata) => {
167
+ const changeDetectionText = 'changeDetection: ChangeDetectionStrategy.OnPush';
168
+ const properties = metadata.properties.filter((property) => property.type === 'Property');
169
+ const insertionTarget = getChangeDetectionInsertionTarget(metadata);
170
+ const isMultiline = Boolean(metadata.loc && metadata.loc.start.line !== metadata.loc.end.line);
171
+
172
+ if (properties.length === 0) {
173
+ return (fixer) => fixer.replaceText(metadata, `{ ${changeDetectionText} }`);
174
+ }
175
+
176
+ if (insertionTarget) {
177
+ if (isMultiline) {
178
+ const propertyIndent = getIndent(sourceCode, insertionTarget);
179
+ return (fixer) => fixer.insertTextBefore(insertionTarget, `${changeDetectionText},\n${propertyIndent}`);
180
+ }
181
+
182
+ return (fixer) => fixer.insertTextBefore(insertionTarget, `${changeDetectionText}, `);
183
+ }
184
+
185
+ const lastProperty = properties[properties.length - 1];
186
+ const closingBrace = sourceCode.getLastToken(metadata);
187
+ if (!lastProperty || !closingBrace) return null;
188
+
189
+ if (isMultiline) {
190
+ const closingIndent = getIndent(sourceCode, closingBrace);
191
+ const propertyIndent = getIndent(sourceCode, lastProperty);
192
+ const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
193
+ const rangeStart =
194
+ separator === ''
195
+ ? (sourceCode.getTokenAfter(lastProperty)?.range[1] ?? lastProperty.range[1])
196
+ : lastProperty.range[1];
197
+
198
+ return (fixer) =>
199
+ fixer.replaceTextRange(
200
+ [rangeStart, closingBrace.range[0]],
201
+ `${separator}\n${propertyIndent}${changeDetectionText}\n${closingIndent}`,
202
+ );
203
+ }
204
+
205
+ const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
206
+ return (fixer) =>
207
+ fixer.replaceTextRange([lastProperty.range[1], closingBrace.range[0]], `${separator} ${changeDetectionText} `);
208
+ };
209
+
210
+ /**
211
+ * @param {import('eslint').SourceCode} sourceCode
212
+ * @param {any} metadata
213
+ * @param {any} valueNode
214
+ */
215
+ const buildFix = (sourceCode, metadata, valueNode) => {
216
+ const fixes = [];
217
+ const angularCoreImport = findAngularCoreImport(sourceCode);
218
+ const importFix = angularCoreImport
219
+ ? buildAngularCoreImportFix(sourceCode, angularCoreImport)
220
+ : buildMissingAngularCoreImportFix(sourceCode);
221
+
222
+ if (importFix) fixes.push(importFix);
223
+
224
+ if (valueNode) {
225
+ fixes.push((fixer) => fixer.replaceText(valueNode, 'ChangeDetectionStrategy.OnPush'));
226
+ } else {
227
+ const metadataFix = buildMetadataFix(sourceCode, metadata);
228
+ if (metadataFix) fixes.push(metadataFix);
229
+ }
230
+
231
+ return (fixer) => fixes.map((applyFix) => applyFix(fixer));
232
+ };
233
+
234
+ /** @type {import('eslint').Rule.RuleModule} */
235
+ const requireOnPushChangeDetection = {
236
+ meta: {
237
+ type: 'suggestion',
238
+ docs: {
239
+ description: 'Require `changeDetection: ChangeDetectionStrategy.OnPush` in all @Component decorators.',
240
+ },
241
+ fixable: 'code',
242
+ messages: {
243
+ missing:
244
+ 'Add `changeDetection: ChangeDetectionStrategy.OnPush` to @Component. Default change detection is not allowed.',
245
+ notOnPush: '`changeDetection` must be `ChangeDetectionStrategy.OnPush`. Got `ChangeDetectionStrategy.{{value}}`.',
246
+ },
247
+ schema: [],
248
+ },
249
+ create(context) {
250
+ // Only enforce on Angular <= 21, where OnPush is opt-in. On v22+ (or when
251
+ // the version is unknown — this workspace targets modern Angular) stay
252
+ // silent so no-redundant-on-push-change-detection owns the metadata.
253
+ const angularMajor = getAngularMajor(context);
254
+ if (angularMajor === null || angularMajor > MAX_MAJOR) return {};
255
+
256
+ const sourceCode = context.sourceCode;
257
+
258
+ /**
259
+ * @param {import('eslint').Rule.Node} node
260
+ */
261
+ const isComponentDecorator = (node) => {
262
+ const decorator = /** @type {any} */ (node);
263
+ if (decorator.type !== 'Decorator') return false;
264
+
265
+ const expression = decorator.expression;
266
+ if (expression.type === 'CallExpression') {
267
+ return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
268
+ }
269
+
270
+ return expression.type === 'Identifier' && expression.name === 'Component';
271
+ };
272
+
273
+ return {
274
+ Decorator(node) {
275
+ if (!isComponentDecorator(node)) return;
276
+
277
+ const decorator = /** @type {any} */ (node);
278
+ const expression = decorator.expression;
279
+ if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
280
+
281
+ const metadata = expression.arguments[0];
282
+ if (!metadata || metadata.type !== 'ObjectExpression') return;
283
+
284
+ const changeDetectionProp = metadata.properties.find(
285
+ (property) =>
286
+ property.type === 'Property' &&
287
+ property.key.type === 'Identifier' &&
288
+ property.key.name === 'changeDetection',
289
+ );
290
+
291
+ if (!changeDetectionProp) {
292
+ context.report({
293
+ node: metadata,
294
+ messageId: 'missing',
295
+ fix: buildFix(sourceCode, metadata, null),
296
+ });
297
+ return;
298
+ }
299
+
300
+ const value = changeDetectionProp.value;
301
+ if (
302
+ value.type === 'MemberExpression' &&
303
+ value.object.type === 'Identifier' &&
304
+ value.object.name === 'ChangeDetectionStrategy' &&
305
+ value.property.type === 'Identifier' &&
306
+ value.property.name !== 'OnPush'
307
+ ) {
308
+ context.report({
309
+ node: value,
310
+ messageId: 'notOnPush',
311
+ data: { value: value.property.name },
312
+ fix: buildFix(sourceCode, metadata, value),
313
+ });
314
+ }
315
+ },
316
+ };
317
+ },
318
+ };
319
+
320
+ module.exports = requireOnPushChangeDetection;
@@ -1,25 +1,188 @@
1
1
  // @ts-check
2
2
  'use strict';
3
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 hasViewEncapsulation = specifiers.some(
60
+ (specifier) =>
61
+ specifier.type === 'ImportSpecifier' &&
62
+ specifier.imported.type === 'Identifier' &&
63
+ specifier.imported.name === 'ViewEncapsulation',
64
+ );
65
+ if (hasViewEncapsulation) 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 { ViewEncapsulation } 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, 'ViewEncapsulation'].join(', ')} }`);
82
+
83
+ return (fixer) => fixer.replaceText(importNode, `import ${importParts.join(', ')} from '@angular/core';`);
84
+ };
85
+
4
86
  /**
5
- * Requires @Component decorators to explicitly set `encapsulation: ViewEncapsulation.None`.
6
- *
7
- * The Ethlete styleguide mandates `ViewEncapsulation.None` for all components
8
- * because styles are managed globally via design tokens and CSS custom properties.
9
- * Emulated or ShadowDom encapsulation would interfere with this system.
10
- *
11
- * ❌ Missing the property entirely (default is Emulated):
12
- * @Component({ selector: 'my-cmp', template: '' })
13
- *
14
- * ❌ Explicitly set to Emulated:
15
- * @Component({ encapsulation: ViewEncapsulation.Emulated, ... })
16
- *
17
- * ❌ ShadowDom:
18
- * @Component({ encapsulation: ViewEncapsulation.ShadowDom, ... })
19
- *
20
- * ✅ Correct:
21
- * @Component({ encapsulation: ViewEncapsulation.None, ... })
87
+ * @param {import('eslint').SourceCode} sourceCode
22
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 { ViewEncapsulation } from '@angular/core';`)
95
+ : (fixer) =>
96
+ fixer.replaceTextRange([0, sourceCode.ast.range[0]], `import { ViewEncapsulation } from '@angular/core';\n\n`);
97
+ };
98
+
99
+ /**
100
+ * @param {any} metadata
101
+ */
102
+ const getEncapsulationInsertionTarget = (metadata) => {
103
+ const encapsulationIndex = COMPONENT_ORDER.indexOf('encapsulation');
104
+
105
+ return (
106
+ metadata.properties.find((property) => {
107
+ if (property.type !== 'Property') return false;
108
+
109
+ const propertyName = getOrderKey(getPropertyName(property.key));
110
+ const propertyIndex = propertyName === null ? COMPONENT_ORDER.length : COMPONENT_ORDER.indexOf(propertyName);
111
+ return propertyIndex > encapsulationIndex || propertyIndex === -1;
112
+ }) ?? null
113
+ );
114
+ };
115
+
116
+ /**
117
+ * @param {import('eslint').SourceCode} sourceCode
118
+ * @param {any} metadata
119
+ */
120
+ const buildMetadataFix = (sourceCode, metadata) => {
121
+ const encText = 'encapsulation: ViewEncapsulation.None';
122
+ const properties = metadata.properties.filter((property) => property.type === 'Property');
123
+ const insertionTarget = getEncapsulationInsertionTarget(metadata);
124
+ const isMultiline = Boolean(metadata.loc && metadata.loc.start.line !== metadata.loc.end.line);
125
+
126
+ if (properties.length === 0) {
127
+ return (fixer) => fixer.replaceText(metadata, `{ ${encText} }`);
128
+ }
129
+
130
+ if (insertionTarget) {
131
+ if (isMultiline) {
132
+ const propertyIndent = getIndent(sourceCode, insertionTarget);
133
+ return (fixer) => fixer.insertTextBefore(insertionTarget, `${encText},\n${propertyIndent}`);
134
+ }
135
+
136
+ return (fixer) => fixer.insertTextBefore(insertionTarget, `${encText}, `);
137
+ }
138
+
139
+ const lastProperty = properties[properties.length - 1];
140
+ const closingBrace = sourceCode.getLastToken(metadata);
141
+ if (!lastProperty || !closingBrace) return null;
142
+
143
+ if (isMultiline) {
144
+ const closingIndent = getIndent(sourceCode, closingBrace);
145
+ const propertyIndent = getIndent(sourceCode, lastProperty);
146
+ const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
147
+ const rangeStart =
148
+ separator === ''
149
+ ? (sourceCode.getTokenAfter(lastProperty)?.range[1] ?? lastProperty.range[1])
150
+ : lastProperty.range[1];
151
+
152
+ return (fixer) =>
153
+ fixer.replaceTextRange(
154
+ [rangeStart, closingBrace.range[0]],
155
+ `${separator}\n${propertyIndent}${encText}\n${closingIndent}`,
156
+ );
157
+ }
158
+
159
+ const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
160
+ return (fixer) => fixer.replaceTextRange([lastProperty.range[1], closingBrace.range[0]], `${separator} ${encText} `);
161
+ };
162
+
163
+ /**
164
+ * @param {import('eslint').SourceCode} sourceCode
165
+ * @param {any} metadata
166
+ * @param {any} valueNode
167
+ */
168
+ const buildFix = (sourceCode, metadata, valueNode) => {
169
+ const fixes = [];
170
+ const angularCoreImport = findAngularCoreImport(sourceCode);
171
+ const importFix = angularCoreImport
172
+ ? buildAngularCoreImportFix(sourceCode, angularCoreImport)
173
+ : buildMissingAngularCoreImportFix(sourceCode);
174
+
175
+ if (importFix) fixes.push(importFix);
176
+
177
+ if (valueNode) {
178
+ fixes.push((fixer) => fixer.replaceText(valueNode, 'ViewEncapsulation.None'));
179
+ } else {
180
+ const metadataFix = buildMetadataFix(sourceCode, metadata);
181
+ if (metadataFix) fixes.push(metadataFix);
182
+ }
183
+
184
+ return (fixer) => fixes.map((applyFix) => applyFix(fixer));
185
+ };
23
186
 
24
187
  /** @type {import('eslint').Rule.RuleModule} */
25
188
  const requireViewEncapsulationNone = {
@@ -28,6 +191,7 @@ const requireViewEncapsulationNone = {
28
191
  docs: {
29
192
  description: 'Require `encapsulation: ViewEncapsulation.None` in all @Component decorators.',
30
193
  },
194
+ fixable: 'code',
31
195
  messages: {
32
196
  missing: 'Add `encapsulation: ViewEncapsulation.None` to @Component. The default (Emulated) is not allowed.',
33
197
  notNone: '`encapsulation` must be `ViewEncapsulation.None`. Got `ViewEncapsulation.{{value}}`.',
@@ -35,64 +199,63 @@ const requireViewEncapsulationNone = {
35
199
  schema: [],
36
200
  },
37
201
  create(context) {
202
+ const sourceCode = context.sourceCode;
203
+
38
204
  /**
39
- * Returns true if the node is a @Component decorator call.
40
205
  * @param {import('eslint').Rule.Node} node
41
206
  */
42
207
  const isComponentDecorator = (node) => {
43
- const dec = /** @type {any} */ (node);
44
- if (dec.type !== 'Decorator') return false;
45
- const expr = dec.expression;
46
- if (expr.type === 'CallExpression') return expr.callee.type === 'Identifier' && expr.callee.name === 'Component';
47
- if (expr.type === 'Identifier') return expr.name === 'Component';
48
- return false;
208
+ const decorator = /** @type {any} */ (node);
209
+ if (decorator.type !== 'Decorator') return false;
210
+
211
+ const expression = decorator.expression;
212
+ if (expression.type === 'CallExpression') {
213
+ return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
214
+ }
215
+
216
+ return expression.type === 'Identifier' && expression.name === 'Component';
49
217
  };
50
218
 
51
219
  return {
52
220
  Decorator(node) {
53
221
  if (!isComponentDecorator(node)) return;
54
222
 
55
- const dec = /** @type {any} */ (node);
56
- const expr = dec.expression;
57
- if (expr.type !== 'CallExpression') return;
58
-
59
- const args = expr.arguments;
60
- if (!args || args.length === 0) return;
223
+ const decorator = /** @type {any} */ (node);
224
+ const expression = decorator.expression;
225
+ if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
61
226
 
62
- const metadata = args[0];
227
+ const metadata = expression.arguments[0];
63
228
  if (!metadata || metadata.type !== 'ObjectExpression') return;
64
229
 
65
- // Look for an `encapsulation` property
66
230
  const encProp = metadata.properties.find(
67
- (/** @type {any} */ p) =>
68
- p.type === 'Property' && p.key.type === 'Identifier' && p.key.name === 'encapsulation',
231
+ (property) =>
232
+ property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === 'encapsulation',
69
233
  );
70
234
 
71
235
  if (!encProp) {
72
- // Property absent — default is Emulated, which is not allowed
73
- context.report({ node: metadata, messageId: 'missing' });
236
+ context.report({
237
+ node: metadata,
238
+ messageId: 'missing',
239
+ fix: buildFix(sourceCode, metadata, null),
240
+ });
74
241
  return;
75
242
  }
76
243
 
77
- // Must be ViewEncapsulation.None
78
- const val = /** @type {any} */ (encProp).value;
244
+ const value = encProp.value;
79
245
  if (
80
- val.type === 'MemberExpression' &&
81
- val.object.type === 'Identifier' &&
82
- val.object.name === 'ViewEncapsulation' &&
83
- val.property.type === 'Identifier'
246
+ value.type === 'MemberExpression' &&
247
+ value.object.type === 'Identifier' &&
248
+ value.object.name === 'ViewEncapsulation' &&
249
+ value.property.type === 'Identifier' &&
250
+ value.property.name !== 'None'
84
251
  ) {
85
- if (val.property.name !== 'None') {
86
- context.report({
87
- node: val,
88
- messageId: 'notNone',
89
- data: { value: val.property.name },
90
- });
91
- }
92
- // .None — valid, do nothing
252
+ context.report({
253
+ node: value,
254
+ messageId: 'notNone',
255
+ data: { value: value.property.name },
256
+ fix: buildFix(sourceCode, metadata, value),
257
+ });
93
258
  }
94
- // If it's not a MemberExpression at all we don't flag it
95
- // (dynamic or unusual expression — let TypeScript catch it)
96
259
  },
97
260
  };
98
261
  },