@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-next.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#2957](https://github.com/ethlete-io/ethdk/pull/2957) [`81173d4`](https://github.com/ethlete-io/ethdk/commit/81173d426d1a53ea2a7af0d16d6eb0fe4a16dbd9) Thanks [@github-actions](https://github.com/apps/github-actions)! - Add missing auto fixers
8
+
3
9
  ## 0.1.0-next.1
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/eslint-plugin",
3
- "version": "0.1.0-next.1",
3
+ "version": "0.1.0-next.2",
4
4
  "license": "MIT",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -71,10 +71,16 @@ const recommendedTs = {
71
71
  },
72
72
  {
73
73
  selector: ['variable', 'parameter', 'property', 'parameterProperty', 'accessor'],
74
- leadingUnderscore: 'forbid',
74
+ leadingUnderscore: 'allow',
75
75
  trailingUnderscore: 'forbid',
76
76
  format: ['camelCase', 'UPPER_CASE', 'PascalCase'],
77
77
  },
78
+ {
79
+ selector: 'method',
80
+ leadingUnderscore: 'allow',
81
+ trailingUnderscore: 'forbid',
82
+ format: null,
83
+ },
78
84
  {
79
85
  selector: 'typeLike',
80
86
  leadingUnderscore: 'forbid',
@@ -269,6 +275,9 @@ const recommendedTs = {
269
275
  // No `import type { Foo }` or `import { type Foo }` — use regular value imports
270
276
  'ethlete/no-type-only-import': 'error',
271
277
 
278
+ // No empty blank lines between import declarations
279
+ 'ethlete/no-empty-newlines-between-imports': 'error',
280
+
272
281
  // No legacy Angular decorators — use signal-based APIs (input, output, viewChild, etc.)
273
282
  // and host: {} bindings (instead of @HostBinding / @HostListener)
274
283
  'ethlete/no-legacy-angular-decorators': 'error',
@@ -304,6 +313,9 @@ const recommendedTs = {
304
313
  // — widen the accessibility of the source member instead
305
314
  'ethlete/no-member-alias': 'error',
306
315
 
316
+ // No leading underscores on class members; auto-fix private members when safe
317
+ 'ethlete/no-leading-underscore-class-member': 'error',
318
+
307
319
  // No declared-but-never-read private/protected class members
308
320
  'ethlete/no-unused-class-member': 'error',
309
321
 
@@ -311,6 +323,12 @@ const recommendedTs = {
311
323
  // — use viewChild() / viewChildren() or contentChild() / contentChildren() instead
312
324
  'ethlete/no-dom-query': 'error',
313
325
 
326
+ // Remove empty imports: [] and hostDirectives: [] metadata noise
327
+ 'ethlete/no-empty-angular-metadata-arrays': 'error',
328
+
329
+ // Remove standalone metadata — standalone is implicit and should not be declared
330
+ 'ethlete/no-standalone-flag': 'error',
331
+
314
332
  // ── Angular outputs ─────────────────────────────────────────────────────
315
333
 
316
334
  // No on-prefixed outputs (onSelectDate → selectDate)
@@ -322,7 +340,7 @@ const recommendedTs = {
322
340
  // ── Angular components ──────────────────────────────────────────────────
323
341
 
324
342
  // Always use ChangeDetectionStrategy.OnPush
325
- '@angular-eslint/prefer-on-push-component-change-detection': 'error',
343
+ 'ethlete/require-on-push-change-detection': 'error',
326
344
 
327
345
  // Always use ViewEncapsulation.None (angular-eslint's use-component-view-encapsulation does the
328
346
  // opposite — it disallows None, so we use our own custom rule instead)
package/src/index.js CHANGED
@@ -22,6 +22,7 @@ const noSubscribeWithBody = require('./rules/no-subscribe-with-body');
22
22
  const requireDollarSuffix = require('./rules/require-dollar-suffix');
23
23
  const noPipeLogic = require('./rules/no-pipe-logic');
24
24
  const enforceRoutingViewNaming = require('./rules/enforce-routing-view-naming');
25
+ const requireOnPushChangeDetection = require('./rules/require-on-push-change-detection');
25
26
  const requireViewEncapsulationNone = require('./rules/require-view-encapsulation-none');
26
27
  const noLegacyAngularDecorators = require('./rules/no-legacy-angular-decorators');
27
28
  const noAngularSeoServices = require('./rules/no-angular-seo-services');
@@ -35,6 +36,10 @@ const noLocaleId = require('./rules/no-locale-id');
35
36
  const noMemberAlias = require('./rules/no-member-alias');
36
37
  const noUnusedClassMember = require('./rules/no-unused-class-member');
37
38
  const noDomQuery = require('./rules/no-dom-query');
39
+ const noLeadingUnderscoreClassMember = require('./rules/no-leading-underscore-class-member');
40
+ const noEmptyAngularMetadataArrays = require('./rules/no-empty-angular-metadata-arrays');
41
+ const noStandaloneFlag = require('./rules/no-standalone-flag');
42
+ const noEmptyNewlinesBetweenImports = require('./rules/no-empty-newlines-between-imports');
38
43
  const angularDecoratorPropertyOrder = require('./rules/angular-decorator-property-order');
39
44
  const preferConciseAngularHostDirectives = require('./rules/prefer-concise-angular-host-directives');
40
45
  const preferConciseAngularStyleMetadata = require('./rules/prefer-concise-angular-style-metadata');
@@ -68,6 +73,7 @@ const plugin = {
68
73
  'require-dollar-suffix': requireDollarSuffix,
69
74
  'no-pipe-logic': noPipeLogic,
70
75
  'enforce-routing-view-naming': enforceRoutingViewNaming,
76
+ 'require-on-push-change-detection': requireOnPushChangeDetection,
71
77
  'require-view-encapsulation-none': requireViewEncapsulationNone,
72
78
  'no-legacy-angular-decorators': noLegacyAngularDecorators,
73
79
  'no-angular-seo-services': noAngularSeoServices,
@@ -81,6 +87,10 @@ const plugin = {
81
87
  'no-member-alias': noMemberAlias,
82
88
  'no-unused-class-member': noUnusedClassMember,
83
89
  'no-dom-query': noDomQuery,
90
+ 'no-leading-underscore-class-member': noLeadingUnderscoreClassMember,
91
+ 'no-empty-angular-metadata-arrays': noEmptyAngularMetadataArrays,
92
+ 'no-standalone-flag': noStandaloneFlag,
93
+ 'no-empty-newlines-between-imports': noEmptyNewlinesBetweenImports,
84
94
  'angular-decorator-property-order': angularDecoratorPropertyOrder,
85
95
  'prefer-concise-angular-host-directives': preferConciseAngularHostDirectives,
86
96
  'prefer-concise-angular-style-metadata': preferConciseAngularStyleMetadata,
@@ -29,28 +29,16 @@ const DECORATOR_CONFIG = {
29
29
  'outputs',
30
30
  'queries',
31
31
  'preserveWhitespaces',
32
- 'standalone',
33
32
  'schemas',
34
33
  'jit',
35
34
  'exportAs',
36
35
  ],
37
36
  orderText:
38
- 'selector, template/templateUrl, styleUrl/styleUrls, encapsulation, changeDetection, imports, providers, viewProviders, animations, hostDirectives, host, styles, inputs, outputs, queries, preserveWhitespaces, standalone, schemas, jit, exportAs',
37
+ 'selector, template/templateUrl, styleUrl/styleUrls, encapsulation, changeDetection, imports, providers, viewProviders, animations, hostDirectives, host, styles, inputs, outputs, queries, preserveWhitespaces, schemas, jit, exportAs',
39
38
  },
40
39
  Directive: {
41
- order: [
42
- 'selector',
43
- 'exportAs',
44
- 'providers',
45
- 'inputs',
46
- 'outputs',
47
- 'queries',
48
- 'hostDirectives',
49
- 'host',
50
- 'standalone',
51
- 'jit',
52
- ],
53
- orderText: 'selector, exportAs, providers, inputs, outputs, queries, hostDirectives, host, standalone, jit',
40
+ order: ['selector', 'exportAs', 'providers', 'inputs', 'outputs', 'queries', 'hostDirectives', 'host', 'jit'],
41
+ orderText: 'selector, exportAs, providers, inputs, outputs, queries, hostDirectives, host, jit',
54
42
  },
55
43
  };
56
44
 
@@ -89,6 +89,19 @@ const readFileIfExists = (filePath) => {
89
89
  return null;
90
90
  };
91
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
+
92
105
  /**
93
106
  * @param {string} memberName
94
107
  * @param {string | null} template
@@ -100,6 +113,9 @@ const templateReferencesMember = (memberName, template) => {
100
113
  const patterns = [
101
114
  new RegExp(`\\{\\{[\\s\\S]*?\\b${escapedName}\\b[\\s\\S]*?\\}\\}`, 'u'),
102
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'),
103
119
  new RegExp(`\\[[^\\]]+\\]\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
104
120
  new RegExp(`\\[[^\\]]+\\]\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
105
121
  new RegExp(`\\([^\\)]+\\)\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
@@ -160,8 +176,10 @@ const isReferencedFromTemplateOrHost = (memberName, metadata, context) => {
160
176
  const templateUrlProperty = getMetadataProperty(metadata, 'templateUrl');
161
177
  if (templateUrlProperty) {
162
178
  const templateUrl = getStringValue(templateUrlProperty.value);
163
- if (templateUrl) {
164
- const templatePath = path.resolve(path.dirname(context.filename), templateUrl);
179
+ const filename = getContextFilename(context);
180
+
181
+ if (templateUrl && filename) {
182
+ const templatePath = path.resolve(path.dirname(filename), templateUrl);
165
183
  const templateText = readFileIfExists(templatePath);
166
184
  if (templateReferencesMember(memberName, templateText)) {
167
185
  return true;
@@ -0,0 +1,104 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /** @typedef {'Component' | 'Directive'} 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
+ const MESSAGE_IDS = {
50
+ hostDirectives: 'noEmptyHostDirectives',
51
+ imports: 'noEmptyImports',
52
+ };
53
+
54
+ /** @type {import('eslint').Rule.RuleModule} */
55
+ const noEmptyAngularMetadataArrays = {
56
+ meta: {
57
+ type: 'suggestion',
58
+ docs: {
59
+ description: 'Disallow empty Angular metadata arrays such as imports: [] and hostDirectives: [].',
60
+ },
61
+ fixable: 'code',
62
+ schema: [],
63
+ messages: {
64
+ noEmptyImports: 'Remove empty imports: [] from Angular metadata.',
65
+ noEmptyHostDirectives: 'Remove empty hostDirectives: [] from Angular metadata.',
66
+ },
67
+ },
68
+ create(context) {
69
+ const sourceCode = context.sourceCode;
70
+
71
+ return {
72
+ /** @param {import('eslint').Rule.Node} node */
73
+ Decorator(node) {
74
+ const decoratorName = getDecoratorName(node);
75
+ if (decoratorName !== 'Component' && decoratorName !== 'Directive') return;
76
+
77
+ const decorator = /** @type {any} */ (node);
78
+ const expression = decorator.expression;
79
+ if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
80
+
81
+ const metadata = expression.arguments[0];
82
+ if (!metadata || metadata.type !== 'ObjectExpression') return;
83
+
84
+ for (const property of metadata.properties) {
85
+ if (property.type !== 'Property') continue;
86
+
87
+ const propertyName = getPropertyName(property.key);
88
+ if (propertyName !== 'imports' && propertyName !== 'hostDirectives') continue;
89
+ if (property.value.type !== 'ArrayExpression' || property.value.elements.length > 0) continue;
90
+
91
+ context.report({
92
+ node: property,
93
+ messageId: MESSAGE_IDS[propertyName],
94
+ fix(fixer) {
95
+ return fixer.replaceTextRange(getRemovalRange(sourceCode, metadata, property), '');
96
+ },
97
+ });
98
+ }
99
+ },
100
+ };
101
+ },
102
+ };
103
+
104
+ module.exports = noEmptyAngularMetadataArrays;
@@ -0,0 +1,45 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /** @type {import('eslint').Rule.RuleModule} */
5
+ const noEmptyNewlinesBetweenImports = {
6
+ meta: {
7
+ type: 'layout',
8
+ docs: {
9
+ description: 'Disallow empty blank lines between consecutive import declarations.',
10
+ },
11
+ fixable: 'code',
12
+ schema: [],
13
+ messages: {
14
+ noEmptyLine: 'Remove empty blank lines between import declarations.',
15
+ },
16
+ },
17
+ create(context) {
18
+ const sourceCode = context.sourceCode;
19
+
20
+ return {
21
+ Program(node) {
22
+ const importDeclarations = node.body.filter((statement) => statement.type === 'ImportDeclaration');
23
+
24
+ for (let index = 1; index < importDeclarations.length; index += 1) {
25
+ const previousImport = importDeclarations[index - 1];
26
+ const currentImport = importDeclarations[index];
27
+ const textBetween = sourceCode.text.slice(previousImport.range[1], currentImport.range[0]);
28
+
29
+ if (/\S/.test(textBetween)) continue;
30
+ if (!/\n\s*\n/.test(textBetween)) continue;
31
+
32
+ context.report({
33
+ node: currentImport,
34
+ messageId: 'noEmptyLine',
35
+ fix(fixer) {
36
+ return fixer.replaceTextRange([previousImport.range[1], currentImport.range[0]], '\n');
37
+ },
38
+ });
39
+ }
40
+ },
41
+ };
42
+ },
43
+ };
44
+
45
+ module.exports = noEmptyNewlinesBetweenImports;
@@ -0,0 +1,120 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * @param {string} name
6
+ */
7
+ const getFixedName = (name) => name.replace(/^_+/, '');
8
+
9
+ /**
10
+ * @param {any} node
11
+ */
12
+ const getDirectMemberName = (node) => {
13
+ if (node.parent?.type !== 'ClassBody') return null;
14
+ if (node.static || node.computed) return null;
15
+ if (node.type === 'MethodDefinition' && node.kind === 'constructor') return null;
16
+ return node.key?.type === 'Identifier' ? node.key.name : null;
17
+ };
18
+
19
+ /** @type {import('eslint').Rule.RuleModule} */
20
+ const noLeadingUnderscoreClassMember = {
21
+ meta: {
22
+ type: 'suggestion',
23
+ docs: {
24
+ description: 'Disallow leading underscores on class members and rename private members automatically when safe.',
25
+ },
26
+ fixable: 'code',
27
+ schema: [],
28
+ messages: {
29
+ noLeadingUnderscore: "Class members must not use a leading underscore. Rename '{{oldName}}' to '{{newName}}'.",
30
+ },
31
+ },
32
+ create(context) {
33
+ /**
34
+ * @type {Array<{
35
+ * classBody: import('eslint').Rule.Node,
36
+ * declaredNames: Set<string>,
37
+ * membersByName: Map<string, any[]>,
38
+ * memberReads: any[],
39
+ * }>}
40
+ */
41
+ const classStack = [];
42
+
43
+ /**
44
+ * @param {any} node
45
+ */
46
+ const registerMember = (node) => {
47
+ const frame = classStack[classStack.length - 1];
48
+ if (!frame || node.parent !== frame.classBody) return;
49
+
50
+ const name = getDirectMemberName(node);
51
+ if (!name) return;
52
+
53
+ frame.declaredNames.add(name);
54
+
55
+ if (!name.startsWith('_')) return;
56
+
57
+ const members = frame.membersByName.get(name) ?? [];
58
+ members.push(node);
59
+ frame.membersByName.set(name, members);
60
+ };
61
+
62
+ return {
63
+ ClassBody(node) {
64
+ classStack.push({
65
+ classBody: node,
66
+ declaredNames: new Set(),
67
+ membersByName: new Map(),
68
+ memberReads: [],
69
+ });
70
+ },
71
+
72
+ 'ClassBody:exit'() {
73
+ const frame = classStack.pop();
74
+ if (!frame) return;
75
+
76
+ for (const [oldName, memberNodes] of frame.membersByName) {
77
+ const newName = getFixedName(oldName);
78
+ const canFix =
79
+ newName.length > 0 &&
80
+ !frame.declaredNames.has(newName) &&
81
+ memberNodes.every((memberNode) => memberNode.accessibility === 'private');
82
+
83
+ for (const memberNode of memberNodes) {
84
+ context.report({
85
+ node: memberNode.key,
86
+ messageId: 'noLeadingUnderscore',
87
+ data: { oldName, newName },
88
+ fix: canFix
89
+ ? (fixer) => {
90
+ const fixes = memberNodes.map((node) => fixer.replaceText(node.key, newName));
91
+
92
+ for (const memberExpression of frame.memberReads) {
93
+ if (memberExpression.property.name !== oldName) continue;
94
+ fixes.push(fixer.replaceText(memberExpression.property, newName));
95
+ }
96
+
97
+ return fixes;
98
+ }
99
+ : null,
100
+ });
101
+ }
102
+ }
103
+ },
104
+
105
+ PropertyDefinition: registerMember,
106
+ MethodDefinition: registerMember,
107
+
108
+ MemberExpression(node) {
109
+ const frame = classStack[classStack.length - 1];
110
+ if (!frame) return;
111
+ if (node.object?.type !== 'ThisExpression' || node.computed) return;
112
+ if (node.property?.type !== 'Identifier') return;
113
+
114
+ frame.memberReads.push(node);
115
+ },
116
+ };
117
+ },
118
+ };
119
+
120
+ module.exports = noLeadingUnderscoreClassMember;
@@ -51,6 +51,180 @@ const getMemberName = (node) => {
51
51
  return null;
52
52
  };
53
53
 
54
+ /**
55
+ * @param {import('estree').Property['key']} key
56
+ * @returns {string | null}
57
+ */
58
+ const getPropertyName = (key) => {
59
+ if (key.type === 'Identifier') return key.name;
60
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
61
+ return null;
62
+ };
63
+
64
+ /**
65
+ * @param {import('eslint').SourceCode} sourceCode
66
+ * @param {import('eslint').AST.Token | import('eslint').Rule.Node} node
67
+ */
68
+ const getIndent = (sourceCode, node) => {
69
+ if (!node.loc) return '';
70
+ const line = sourceCode.lines[node.loc.start.line - 1] ?? '';
71
+ return line.slice(0, node.loc.start.column);
72
+ };
73
+
74
+ /**
75
+ * @param {any} classNode
76
+ */
77
+ const getAngularMetadata = (classNode) => {
78
+ for (const decorator of classNode.decorators ?? []) {
79
+ const decoratorName = getDecoratorName(decorator);
80
+ if (decoratorName !== 'Component' && decoratorName !== 'Directive') continue;
81
+
82
+ const expression = decorator.expression;
83
+ if (expression.type !== 'CallExpression') continue;
84
+
85
+ const metadata = expression.arguments[0];
86
+ if (metadata?.type !== 'ObjectExpression') continue;
87
+
88
+ return metadata;
89
+ }
90
+
91
+ return null;
92
+ };
93
+
94
+ /**
95
+ * @param {any} objectExpression
96
+ * @param {string} propertyName
97
+ */
98
+ const findObjectProperty = (objectExpression, propertyName) =>
99
+ objectExpression.properties.find(
100
+ (property) => property.type === 'Property' && getPropertyName(property.key) === propertyName,
101
+ ) ?? null;
102
+
103
+ /**
104
+ * @param {import('eslint').SourceCode} sourceCode
105
+ * @param {any} objectExpression
106
+ * @param {string} entryText
107
+ */
108
+ const buildObjectTextWithAppendedProperty = (sourceCode, objectExpression, entryText) => {
109
+ const properties = objectExpression.properties.filter((property) => property.type === 'Property');
110
+ const isMultiline = Boolean(
111
+ objectExpression.loc && objectExpression.loc.start.line !== objectExpression.loc.end.line,
112
+ );
113
+
114
+ if (!isMultiline) {
115
+ const existingText = properties.map((property) => sourceCode.getText(property));
116
+ return existingText.length === 0 ? `{ ${entryText} }` : `{ ${existingText.join(', ')}, ${entryText} }`;
117
+ }
118
+
119
+ const openingBrace = sourceCode.getFirstToken(objectExpression);
120
+ const closingBrace = sourceCode.getLastToken(objectExpression);
121
+ const closingIndent = closingBrace ? getIndent(sourceCode, closingBrace) : '';
122
+ const propertyIndent = properties[0] ? getIndent(sourceCode, properties[0]) : `${closingIndent} `;
123
+ const existingText = properties.map((property) => `${propertyIndent}${sourceCode.getText(property)}`);
124
+
125
+ if (existingText.length === 0) {
126
+ return `{
127
+ ${propertyIndent}${entryText}
128
+ ${closingIndent}}`;
129
+ }
130
+
131
+ return `{
132
+ ${existingText.join(',\n')},
133
+ ${propertyIndent}${entryText}
134
+ ${closingIndent}}`;
135
+ };
136
+
137
+ /**
138
+ * @param {import('eslint').SourceCode} sourceCode
139
+ * @param {any} objectExpression
140
+ */
141
+ const hasTrailingComma = (sourceCode, objectExpression) => {
142
+ const properties = objectExpression.properties.filter((property) => property.type === 'Property');
143
+ const lastProperty = properties[properties.length - 1];
144
+ if (!lastProperty) return false;
145
+
146
+ const tokenAfter = sourceCode.getTokenAfter(lastProperty);
147
+ return Boolean(tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',');
148
+ };
149
+
150
+ /**
151
+ * @param {import('eslint').SourceCode} sourceCode
152
+ * @param {any} decoratorNode
153
+ * @param {any} memberNode
154
+ * @param {string} bindingKey
155
+ * @param {string} memberName
156
+ */
157
+ const buildHostBindingFix = (sourceCode, decoratorNode, memberNode, bindingKey, memberName) => {
158
+ const classBody = memberNode.parent;
159
+ const classNode = classBody?.parent;
160
+ if (!classNode) return null;
161
+
162
+ const metadata = getAngularMetadata(classNode);
163
+ if (!metadata) return null;
164
+
165
+ const hostProperty = findObjectProperty(metadata, 'host');
166
+ const hostEntryText = `'${bindingKey}': '${memberName}'`;
167
+ const fixes = [];
168
+
169
+ if (hostProperty) {
170
+ if (hostProperty.value.type !== 'ObjectExpression') return null;
171
+ if (
172
+ hostProperty.value.properties.some(
173
+ (property) => property.type === 'Property' && getPropertyName(property.key) === bindingKey,
174
+ )
175
+ ) {
176
+ return null;
177
+ }
178
+
179
+ fixes.push((fixer) =>
180
+ fixer.replaceText(
181
+ hostProperty.value,
182
+ buildObjectTextWithAppendedProperty(sourceCode, hostProperty.value, hostEntryText),
183
+ ),
184
+ );
185
+ } else {
186
+ const closingBrace = sourceCode.getLastToken(metadata);
187
+ if (!closingBrace) return null;
188
+
189
+ const isMultiline = Boolean(metadata.loc && metadata.loc.start.line !== metadata.loc.end.line);
190
+ const closingIndent = getIndent(sourceCode, closingBrace);
191
+ const propertyIndent = metadata.properties[0]
192
+ ? getIndent(sourceCode, metadata.properties[0])
193
+ : `${getIndent(sourceCode, closingBrace)} `;
194
+ const hostPropertyText = `host: { ${hostEntryText} }`;
195
+ const separator = metadata.properties.length > 0 ? (hasTrailingComma(sourceCode, metadata) ? '' : ',') : '';
196
+
197
+ if (isMultiline && metadata.properties.length > 0) {
198
+ const lastProperty = metadata.properties[metadata.properties.length - 1];
199
+ const tokenAfterLastProperty = sourceCode.getTokenAfter(lastProperty);
200
+ const rangeStart =
201
+ separator === '' && tokenAfterLastProperty ? tokenAfterLastProperty.range[1] : lastProperty.range[1];
202
+
203
+ fixes.push((fixer) =>
204
+ fixer.replaceTextRange(
205
+ [rangeStart, closingBrace.range[0]],
206
+ `${separator}\n${propertyIndent}${hostPropertyText},\n${closingIndent}`,
207
+ ),
208
+ );
209
+ } else {
210
+ const insertion = isMultiline
211
+ ? `\n${propertyIndent}${hostPropertyText}\n${closingIndent}`
212
+ : `${metadata.properties.length > 0 ? `${separator} ` : ' '}${hostPropertyText}${metadata.properties.length > 0 ? '' : ' '}`;
213
+
214
+ fixes.push((fixer) => fixer.insertTextBefore(closingBrace, insertion));
215
+ }
216
+ }
217
+
218
+ const nextToken = sourceCode.getTokenAfter(decoratorNode);
219
+ fixes.push(
220
+ nextToken
221
+ ? (fixer) => fixer.removeRange([decoratorNode.range[0], nextToken.range[0]])
222
+ : (fixer) => fixer.remove(decoratorNode),
223
+ );
224
+
225
+ return (fixer) => fixes.map((applyFix) => applyFix(fixer));
226
+ };
227
+
54
228
  /** @type {import('eslint').Rule.RuleModule} */
55
229
  const rule = {
56
230
  meta: {
@@ -59,6 +233,7 @@ const rule = {
59
233
  description:
60
234
  'Disallow legacy Angular decorators (@Input, @Output, @ViewChild, etc.) in favour of signal-based APIs and host: {} bindings.',
61
235
  },
236
+ fixable: 'code',
62
237
  schema: [],
63
238
  messages: {
64
239
  useInput: 'Use the input() signal function instead of @Input().',
@@ -195,7 +370,27 @@ const rule = {
195
370
  break;
196
371
 
197
372
  case 'HostBinding':
198
- context.report({ node, messageId: 'useHostBinding' });
373
+ context.report({
374
+ node,
375
+ messageId: 'useHostBinding',
376
+ fix: (() => {
377
+ const memberName = getMemberName(parent);
378
+ if (!memberName) return null;
379
+
380
+ const expression = anyNode.expression;
381
+ if (expression.type !== 'CallExpression' && expression.type !== 'Identifier') return null;
382
+
383
+ const bindingName =
384
+ expression.type === 'CallExpression'
385
+ ? expression.arguments[0]?.type === 'Literal' && typeof expression.arguments[0].value === 'string'
386
+ ? expression.arguments[0].value
387
+ : memberName
388
+ : memberName;
389
+
390
+ const bindingKey = bindingName.startsWith('[') ? bindingName : `[${bindingName}]`;
391
+ return buildHostBindingFix(context.sourceCode, anyNode, parent, bindingKey, memberName);
392
+ })(),
393
+ });
199
394
  break;
200
395
 
201
396
  case 'HostListener':