@ethlete/eslint-plugin 1.0.0-next.25 → 1.0.0-next.27

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +4 -0
  3. package/migrations.json +1 -0
  4. package/package.json +2 -1
  5. package/src/configs/recommended.js +16 -18
  6. package/src/rules/angular-decorator-property-order.js +24 -7
  7. package/src/rules/class-constant-property.js +1 -1
  8. package/src/rules/class-member-order.js +66 -8
  9. package/src/rules/enforce-routing-view-naming.js +47 -24
  10. package/src/rules/guard-return-newline.js +4 -7
  11. package/src/rules/internals/angular-member-visibility.js +24 -2
  12. package/src/rules/internals/angular-metadata-fix.js +231 -1
  13. package/src/rules/internals/angular-version.js +50 -0
  14. package/src/rules/internals/implemented-contract-members.js +26 -11
  15. package/src/rules/no-angular-router-api.js +86 -33
  16. package/src/rules/no-angular-seo-services.js +1 -0
  17. package/src/rules/no-async-await.js +5 -1
  18. package/src/rules/no-cdk-import.js +32 -7
  19. package/src/rules/no-direct-dom-manipulation.js +50 -28
  20. package/src/rules/no-document-cookie.js +51 -8
  21. package/src/rules/no-dom-query.js +5 -2
  22. package/src/rules/no-empty-angular-metadata-arrays.js +2 -0
  23. package/src/rules/no-empty-newlines-between-imports.js +4 -1
  24. package/src/rules/no-impure-top-level-provider.js +42 -7
  25. package/src/rules/no-inject-chain.js +5 -1
  26. package/src/rules/no-leading-underscore-class-member.js +27 -2
  27. package/src/rules/no-legacy-angular-decorators.js +19 -16
  28. package/src/rules/no-legacy-prepare-without-injector.js +57 -12
  29. package/src/rules/no-legacy-query-import.js +61 -16
  30. package/src/rules/no-member-alias.js +0 -5
  31. package/src/rules/no-native-observers.js +89 -24
  32. package/src/rules/no-pipe-logic.js +1 -2
  33. package/src/rules/no-readonly-signal.js +1 -5
  34. package/src/rules/no-redundant-internal.js +5 -2
  35. package/src/rules/no-redundant-on-push-change-detection.js +3 -48
  36. package/src/rules/no-rxjs-in-effect.js +7 -5
  37. package/src/rules/no-screaming-case-local.js +3 -5
  38. package/src/rules/no-standalone-flag.js +1 -0
  39. package/src/rules/no-subscribe-in-pipe.js +5 -1
  40. package/src/rules/no-subscribe-with-body.js +30 -1
  41. package/src/rules/no-template-literal-before-inline-template.js +6 -0
  42. package/src/rules/no-trivial-return-type.js +18 -1
  43. package/src/rules/no-trivial-wrapper-method.js +39 -1
  44. package/src/rules/no-type-only-import.js +23 -19
  45. package/src/rules/no-unused-class-member.js +14 -6
  46. package/src/rules/no-window-location.js +60 -22
  47. package/src/rules/prefer-clone-equal.js +61 -8
  48. package/src/rules/prefer-concise-angular-host-directives.js +39 -11
  49. package/src/rules/prefer-concise-angular-style-metadata.js +10 -2
  50. package/src/rules/prefer-element-dimensions.js +264 -11
  51. package/src/rules/prefer-match-media.js +17 -3
  52. package/src/rules/prefer-rxjs-timer.js +26 -4
  53. package/src/rules/prefer-scroll-state.js +0 -4
  54. package/src/rules/prefer-static-boolean-properties.js +1 -0
  55. package/src/rules/prefer-viewport-size.js +153 -16
  56. package/src/rules/require-dollar-suffix.js +11 -3
  57. package/src/rules/require-form-submit.js +4 -1
  58. package/src/rules/require-on-push-change-detection.js +7 -259
  59. package/src/rules/require-view-encapsulation-none.js +6 -214
  60. package/src/rules/template-member-accessibility.js +13 -20
@@ -17,6 +17,8 @@ const { getAngularDecoratorName } = require('./internals/import-resolution');
17
17
  *
18
18
  * GOOD:
19
19
  * // Call this.value.set(val) directly at the call sites.
20
+ * // A public method over a private/protected member is the encapsulation, not a wrapper:
21
+ * // private items = signal([]); setItems(items) { this.items.set(items); }
20
22
  * // If the method wraps something non-trivially (transforms args, adds logic), it is fine.
21
23
  * // `focus`/`blur`/`reset` on a @Component/@Directive are exempt - see CONTRACT_METHOD_NAMES.
22
24
  */
@@ -92,6 +94,39 @@ const getTrivialCallExpr = (methodNode) => {
92
94
  return allMatch ? callExpr : null;
93
95
  };
94
96
 
97
+ /** @param {any} member */
98
+ const visibilityOf = (member) => {
99
+ if (member.key?.type === 'PrivateIdentifier' || member.accessibility === 'private') return 0;
100
+ if (member.accessibility === 'protected') return 1;
101
+ return 2;
102
+ };
103
+
104
+ /**
105
+ * @param {any} classBody
106
+ * @param {any} callee
107
+ */
108
+ const receiverVisibility = (classBody, callee) => {
109
+ let object = callee;
110
+ while (object.type === 'MemberExpression' && object.object.type !== 'ThisExpression') object = object.object;
111
+ if (object.type !== 'MemberExpression' || object.computed) return undefined;
112
+
113
+ const name = object.property.type === 'PrivateIdentifier' ? `#${object.property.name}` : object.property.name;
114
+ /** @param {any} key */
115
+ const keyName = (key) => (key?.type === 'PrivateIdentifier' ? `#${key.name}` : key?.name);
116
+
117
+ for (const member of classBody.body) {
118
+ if (member.type === 'MethodDefinition' && member.kind === 'constructor') {
119
+ const property = member.value.params.find(
120
+ (/** @type {any} */ param) => param.type === 'TSParameterProperty' && param.parameter.name === name,
121
+ );
122
+ if (property) return visibilityOf(property);
123
+ continue;
124
+ }
125
+ if (keyName(member.key) === name) return visibilityOf(member);
126
+ }
127
+ return undefined;
128
+ };
129
+
95
130
  /** @type {import('eslint').Rule.RuleModule} */
96
131
  const noTrivialWrapperMethod = {
97
132
  meta: {
@@ -102,7 +137,7 @@ const noTrivialWrapperMethod = {
102
137
  },
103
138
  messages: {
104
139
  noTrivialWrapperMethod:
105
- "'{{name}}' is a trivial wrapper — it only forwards all its arguments to '{{target}}'. Remove it and call '{{target}}' directly at each call site.",
140
+ "'{{name}}' is a trivial wrapper — it only forwards all its arguments to '{{target}}', which callers can already reach. Remove it and call '{{target}}' directly at each call site.",
106
141
  },
107
142
  schema: [],
108
143
  },
@@ -133,6 +168,9 @@ const noTrivialWrapperMethod = {
133
168
 
134
169
  if (!targetName) return;
135
170
 
171
+ const wrapped = receiverVisibility(node.parent, callee);
172
+ if (wrapped !== undefined && visibilityOf(node) > wrapped) return;
173
+
136
174
  context.report({
137
175
  node,
138
176
  messageId: 'noTrivialWrapperMethod',
@@ -1,6 +1,25 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ /** @param {any} node */
5
+ const renderValueImport = (node) => {
6
+ const clauses = [];
7
+ const named = [];
8
+
9
+ for (const s of node.specifiers) {
10
+ if (s.type === 'ImportDefaultSpecifier') clauses.push(s.local.name);
11
+ else if (s.type === 'ImportNamespaceSpecifier') clauses.push(`* as ${s.local.name}`);
12
+ else {
13
+ const imported = s.imported.type === 'Identifier' ? s.imported.name : `'${s.imported.value}'`;
14
+ named.push(s.local.name === imported ? imported : `${imported} as ${s.local.name}`);
15
+ }
16
+ }
17
+
18
+ if (named.length) clauses.push(`{ ${named.join(', ')} }`);
19
+
20
+ return `import ${clauses.join(', ')} from '${node.source.value}';`;
21
+ };
22
+
4
23
  /** @type {import('eslint').Rule.RuleModule} */
5
24
  const noTypeOnlyImport = {
6
25
  meta: {
@@ -11,10 +30,9 @@ const noTypeOnlyImport = {
11
30
  },
12
31
  fixable: 'code',
13
32
  messages: {
14
- noTypeImportDeclaration:
15
- "Do not use `import type`. Use a regular import instead: `import { {{specifiers}} } from '{{source}}';`.",
33
+ noTypeImportDeclaration: 'Do not use `import type`. Use a regular import instead: `{{replacement}}`.',
16
34
  noInlineTypeSpecifier:
17
- "Do not use inline `type` on import specifiers. Use a regular import instead: `import { {{specifiers}} } from '{{source}}';`.",
35
+ 'Do not use inline `type` on import specifiers. Use a regular import instead: `{{replacement}}`.',
18
36
  },
19
37
  schema: [],
20
38
  },
@@ -23,13 +41,10 @@ const noTypeOnlyImport = {
23
41
  ImportDeclaration(node) {
24
42
  const decl = /** @type {any} */ (node);
25
43
  if (decl.importKind === 'type') {
26
- const source = /** @type {string} */ (node.source.value);
27
- const specifiers = node.specifiers.map((s) => s.local.name).join(', ');
28
-
29
44
  context.report({
30
45
  node,
31
46
  messageId: 'noTypeImportDeclaration',
32
- data: { specifiers, source },
47
+ data: { replacement: renderValueImport(node) },
33
48
  fix(fixer) {
34
49
  const sourceCode = context.sourceCode;
35
50
  const importToken = sourceCode.getFirstToken(node);
@@ -47,21 +62,10 @@ const noTypeOnlyImport = {
47
62
  for (const specifier of node.specifiers) {
48
63
  const spec = /** @type {any} */ (specifier);
49
64
  if (specifier.type === 'ImportSpecifier' && spec.importKind === 'type') {
50
- const source = /** @type {string} */ (node.source.value);
51
- const allSpecifiers = node.specifiers
52
- .map((s) => {
53
- if (s.type === 'ImportSpecifier') {
54
- const imported = s.imported.type === 'Identifier' ? s.imported.name : s.imported.value;
55
- return s.local.name === imported ? imported : `${imported} as ${s.local.name}`;
56
- }
57
- return s.local.name;
58
- })
59
- .join(', ');
60
-
61
65
  context.report({
62
66
  node: specifier,
63
67
  messageId: 'noInlineTypeSpecifier',
64
- data: { specifiers: allSpecifiers, source },
68
+ data: { replacement: renderValueImport(node) },
65
69
  fix(fixer) {
66
70
  const sourceCode = context.sourceCode;
67
71
  const tokenBefore = sourceCode.getTokenBefore(specifier.imported ?? specifier.local);
@@ -4,13 +4,13 @@
4
4
  const { getAngularDecoratorName } = require('./internals/import-resolution');
5
5
 
6
6
  /**
7
- * Disallows class members that are declared but never read within the class body.
7
+ * Disallows class members that are declared but never referenced through `this` within the class body.
8
8
  *
9
9
  * Only checks members that CANNOT be accessed from outside the class at compile
10
10
  * time — so false positives (e.g. template usage) are avoided:
11
11
  *
12
12
  * - `private` members — always checked. Private members are only accessible
13
- * within the class body, so if they are never read there they are dead code.
13
+ * within the class body, so if they are never referenced there they are dead code.
14
14
  *
15
15
  * - `protected` members — checked only when the class has an Angular decorator
16
16
  * other than `@Component` (i.e. `@Directive`, `@Pipe`, `@Injectable`).
@@ -106,13 +106,17 @@ const noUnusedClassMember = {
106
106
  meta: {
107
107
  type: 'suggestion',
108
108
  docs: {
109
- description: 'Disallow class members that are declared but never read within the class body.',
109
+ description:
110
+ 'Disallow class members that are declared but never referenced through `this` within the class body.',
110
111
  recommended: true,
111
112
  },
112
113
  messages: {
113
114
  noUnused:
114
- "'{{name}}' is declared but its value is never read within this class. " +
115
+ "'{{name}}' is declared but never referenced through `this` within this class. " +
115
116
  'Remove it, or widen its accessibility if it is intentionally used externally.',
117
+ noUnusedProtected:
118
+ "'{{name}}' is protected but never referenced through `this` within this class. " +
119
+ 'If a subclass reads it, mark this class `abstract`; otherwise remove it.',
116
120
  },
117
121
  schema: [],
118
122
  },
@@ -139,7 +143,6 @@ const noUnusedClassMember = {
139
143
  const registerMember = (node) => {
140
144
  const frame = classStack[classStack.length - 1];
141
145
  if (!frame) return;
142
- // Only register members that belong directly to this class body
143
146
  if (node.parent !== frame.classBody) return;
144
147
 
145
148
  if (node.static) return;
@@ -182,13 +185,18 @@ const noUnusedClassMember = {
182
185
  if (!frame) return;
183
186
  for (const [name, memberNode] of frame.candidates) {
184
187
  if (!frame.usedNames.has(name)) {
185
- context.report({ node: memberNode, messageId: 'noUnused', data: { name } });
188
+ context.report({
189
+ node: memberNode,
190
+ messageId: memberNode.accessibility === 'protected' ? 'noUnusedProtected' : 'noUnused',
191
+ data: { name },
192
+ });
186
193
  }
187
194
  }
188
195
  },
189
196
 
190
197
  PropertyDefinition: registerMember,
191
198
  MethodDefinition: registerMember,
199
+ AccessorProperty: registerMember,
192
200
 
193
201
  MemberExpression(node) {
194
202
  if (node.object?.type !== 'ThisExpression') return;
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { isGlobalReference } = require('./internals/import-resolution');
5
+
4
6
  /**
5
7
  * Disallows reading URL state from window.location properties.
6
8
  *
@@ -52,29 +54,55 @@ const noWindowLocation = {
52
54
  schema: [],
53
55
  },
54
56
  create(context) {
57
+ const isGlobalObject = (node) =>
58
+ node.type === 'Identifier' &&
59
+ (node.name === 'window' || node.name === 'globalThis') &&
60
+ isGlobalReference(context.sourceCode, node);
61
+
62
+ const findVariable = (identifier) => {
63
+ for (let scope = context.sourceCode.getScope(identifier); scope; scope = scope.upper) {
64
+ const variable = scope.set.get(identifier.name);
65
+ if (variable) return variable;
66
+ }
67
+
68
+ return null;
69
+ };
70
+
71
+ const isLocation = (node) => {
72
+ if (node.type === 'Identifier') {
73
+ if (node.name === 'location' && isGlobalReference(context.sourceCode, node)) return true;
74
+
75
+ const definitions = findVariable(node)?.defs ?? [];
76
+
77
+ return (
78
+ definitions.length === 1 &&
79
+ definitions[0].type === 'Variable' &&
80
+ definitions[0].node.id.type === 'Identifier' &&
81
+ definitions[0].parent.kind === 'const' &&
82
+ !!definitions[0].node.init &&
83
+ isLocation(definitions[0].node.init)
84
+ );
85
+ }
86
+
87
+ return (
88
+ node.type === 'MemberExpression' &&
89
+ !node.computed &&
90
+ isGlobalObject(node.object) &&
91
+ node.property.type === 'Identifier' &&
92
+ node.property.name === 'location'
93
+ );
94
+ };
95
+
55
96
  return {
56
- // ── window.location.{prop} reads ────────────────────────────────────────
57
97
  MemberExpression(node) {
58
- if (node.property.type !== 'Identifier') return;
98
+ if (node.property.type !== 'Identifier' || node.computed) return;
59
99
  const prop = node.property.name;
60
100
 
61
101
  if (!LOCATION_STATE_PROPS.has(prop)) return;
102
+ if (!isLocation(node.object)) return;
62
103
 
63
- // Must be window.location.{prop} — check the object is window.location
64
- const parent = node.object;
65
- if (
66
- parent.type !== 'MemberExpression' ||
67
- parent.object.type !== 'Identifier' ||
68
- parent.object.name !== 'window' ||
69
- parent.property.type !== 'Identifier' ||
70
- parent.property.name !== 'location'
71
- ) {
72
- return;
73
- }
74
-
75
- // Skip assignments TO window.location.href (navigation redirect)
76
104
  if (prop === 'href' && node.parent.type === 'AssignmentExpression' && node.parent.left === node) {
77
- return;
105
+ if (node.parent.operator === '=') return;
78
106
  }
79
107
 
80
108
  context.report({
@@ -84,18 +112,28 @@ const noWindowLocation = {
84
112
  });
85
113
  },
86
114
 
87
- // ── new URLSearchParams(window.location.search) ─────────────────────────
115
+ VariableDeclarator(node) {
116
+ if (node.id.type !== 'ObjectPattern' || !node.init || !isLocation(node.init)) return;
117
+
118
+ for (const property of node.id.properties) {
119
+ if (property.type !== 'Property' || property.computed || property.key.type !== 'Identifier') continue;
120
+ if (!LOCATION_STATE_PROPS.has(property.key.name)) continue;
121
+
122
+ context.report({
123
+ node: property,
124
+ messageId: 'noWindowLocation',
125
+ data: { prop: property.key.name, replacement: LOCATION_STATE_PROPS.get(property.key.name) },
126
+ });
127
+ }
128
+ },
129
+
88
130
  NewExpression(node) {
89
131
  if (node.callee.type !== 'Identifier' || node.callee.name !== 'URLSearchParams') return;
90
132
  const arg = node.arguments[0];
91
133
  if (!arg) return;
92
134
  if (
93
135
  arg.type === 'MemberExpression' &&
94
- arg.object.type === 'MemberExpression' &&
95
- arg.object.object.type === 'Identifier' &&
96
- arg.object.object.name === 'window' &&
97
- arg.object.property.type === 'Identifier' &&
98
- arg.object.property.name === 'location' &&
136
+ isLocation(arg.object) &&
99
137
  arg.property.type === 'Identifier' &&
100
138
  arg.property.name === 'search'
101
139
  ) {
@@ -25,7 +25,20 @@
25
25
  * import { clone, equal } from '@ethlete/core';
26
26
  */
27
27
 
28
- const { MODULE_REFERENCE_SELECTOR, getModuleReference, isGlobalReference } = require('./internals/import-resolution');
28
+ const {
29
+ MODULE_REFERENCE_SELECTOR,
30
+ getModuleReference,
31
+ isGlobalReference,
32
+ resolveIdentifier,
33
+ } = require('./internals/import-resolution');
34
+
35
+ const LODASH_SOURCES = new Set(['lodash', 'lodash-es']);
36
+
37
+ /** @type {Record<string, { messageId: 'preferClone' | 'preferEqual'; method: string }>} */
38
+ const LODASH_METHODS = {
39
+ cloneDeep: { messageId: 'preferClone', method: 'lodash cloneDeep' },
40
+ isEqual: { messageId: 'preferEqual', method: 'isEqual' },
41
+ };
29
42
 
30
43
  /** @type {import('eslint').Rule.RuleModule} */
31
44
  const preferCloneEqual = {
@@ -49,7 +62,43 @@ const preferCloneEqual = {
49
62
  CallExpression(node) {
50
63
  const { callee } = node;
51
64
 
52
- // ── JSON.parse(JSON.stringify(expr)) ─────────────────────────────────
65
+ if (
66
+ callee.type === 'MemberExpression' &&
67
+ !callee.computed &&
68
+ callee.object.type === 'Identifier' &&
69
+ callee.property.type === 'Identifier' &&
70
+ Object.hasOwn(LODASH_METHODS, callee.property.name)
71
+ ) {
72
+ const resolved = resolveIdentifier(context.sourceCode, callee.object);
73
+ if (
74
+ resolved?.source &&
75
+ LODASH_SOURCES.has(resolved.source) &&
76
+ (resolved.name === '*' || resolved.name === 'default')
77
+ ) {
78
+ const { messageId, method } = LODASH_METHODS[callee.property.name];
79
+ context.report({ node, messageId, data: { method } });
80
+ return;
81
+ }
82
+ }
83
+
84
+ if (
85
+ isGlobalReference(context.sourceCode, callee) &&
86
+ callee.name === 'require' &&
87
+ node.arguments[0]?.type === 'Literal' &&
88
+ LODASH_SOURCES.has(String(node.arguments[0].value))
89
+ ) {
90
+ const { parent } = node;
91
+ const method =
92
+ parent.type === 'MemberExpression' && parent.object === node && parent.property.type === 'Identifier'
93
+ ? parent.property.name
94
+ : null;
95
+ if (method && Object.hasOwn(LODASH_METHODS, method)) {
96
+ const { messageId, method: label } = LODASH_METHODS[method];
97
+ context.report({ node, messageId, data: { method: label } });
98
+ }
99
+ return;
100
+ }
101
+
53
102
  if (
54
103
  callee.type === 'MemberExpression' &&
55
104
  callee.object.type === 'Identifier' &&
@@ -71,7 +120,6 @@ const preferCloneEqual = {
71
120
  }
72
121
  }
73
122
 
74
- // ── structuredClone(expr) ─────────────────────────────────────────────
75
123
  if (isGlobalReference(context.sourceCode, callee) && callee.name === 'structuredClone') {
76
124
  context.report({ node, messageId: 'preferClone', data: { method: 'structuredClone()' } });
77
125
  }
@@ -82,9 +130,6 @@ const preferCloneEqual = {
82
130
  if (!reference) return;
83
131
  const src = reference.source;
84
132
 
85
- // ── lodash cloneDeep / isEqual ────────────────────────────────────────
86
- // import { cloneDeep } from 'lodash' / 'lodash-es'
87
- // import { isEqual } from 'lodash' / 'lodash-es'
88
133
  if (src === 'lodash' || src === 'lodash-es') {
89
134
  for (const { name } of reference.named) {
90
135
  if (name === 'cloneDeep') {
@@ -96,13 +141,21 @@ const preferCloneEqual = {
96
141
  return;
97
142
  }
98
143
 
99
- // import cloneDeep from 'lodash/cloneDeep'
144
+ if (src === 'lodash.clonedeep') {
145
+ context.report({ node, messageId: 'preferClone', data: { method: 'lodash cloneDeep' } });
146
+ return;
147
+ }
148
+
149
+ if (src === 'lodash.isequal') {
150
+ context.report({ node, messageId: 'preferEqual', data: { method: 'isEqual' } });
151
+ return;
152
+ }
153
+
100
154
  if (src === 'lodash/cloneDeep' || src === 'lodash-es/cloneDeep') {
101
155
  context.report({ node, messageId: 'preferClone', data: { method: 'lodash cloneDeep' } });
102
156
  return;
103
157
  }
104
158
 
105
- // import isEqual from 'lodash/isEqual'
106
159
  if (src === 'lodash/isEqual' || src === 'lodash-es/isEqual') {
107
160
  context.report({ node, messageId: 'preferEqual', data: { method: 'isEqual' } });
108
161
  }
@@ -7,7 +7,7 @@ const { getAngularDecoratorName } = require('./internals/import-resolution');
7
7
  /** @typedef {import('estree').Property & { range: [number, number] }} TPropertyNode */
8
8
  /** @typedef {import('estree').ArrayExpression & { elements: Array<import('estree').Expression | import('estree').SpreadElement | null> }} TArrayExpressionNode */
9
9
  /** @typedef {import('estree').ObjectExpression & { properties: Array<TPropertyNode | import('estree').SpreadElement> }} TObjectExpressionNode */
10
- /** @typedef {{ bodyText: string; hasComma: boolean; originalIndex: number; orderIndex: number; property: TPropertyNode; trailingText: string }} TPropertyEntry */
10
+ /** @typedef {{ bodyText: string; endsWithLineComment: boolean; hasComma: boolean; originalIndex: number; orderIndex: number; property: TPropertyNode; trailingText: string }} TPropertyEntry */
11
11
 
12
12
  const HOST_DIRECTIVE_PROPERTY_ORDER = ['directive', 'inputs', 'outputs'];
13
13
 
@@ -20,6 +20,24 @@ const getPropertyName = (key) => {
20
20
  return null;
21
21
  };
22
22
 
23
+ /**
24
+ * @param {any} node
25
+ */
26
+ const unwrapTypeAssertions = (node) => {
27
+ let current = node;
28
+
29
+ while (
30
+ current &&
31
+ (current.type === 'TSAsExpression' ||
32
+ current.type === 'TSSatisfiesExpression' ||
33
+ current.type === 'TSNonNullExpression')
34
+ ) {
35
+ current = current.expression;
36
+ }
37
+
38
+ return current;
39
+ };
40
+
23
41
  /**
24
42
  * @param {TDecoratorName} decoratorName
25
43
  * @param {import('estree').Node} node
@@ -35,9 +53,10 @@ const getHostDirectivesArray = (decoratorName, node) => {
35
53
  for (const property of metadata.properties) {
36
54
  if (!property || property.type !== 'Property') continue;
37
55
  if (getPropertyName(property.key) !== 'hostDirectives') continue;
38
- if (property.value.type !== 'ArrayExpression') return null;
56
+ const value = unwrapTypeAssertions(property.value);
57
+ if (value.type !== 'ArrayExpression') return null;
39
58
 
40
- return /** @type {TArrayExpressionNode} */ (property.value);
59
+ return /** @type {TArrayExpressionNode} */ (value);
41
60
  }
42
61
 
43
62
  return null;
@@ -66,6 +85,11 @@ const getHostDirectiveConfigProperties = (node) => {
66
85
  return properties;
67
86
  };
68
87
 
88
+ /**
89
+ * @param {string} text
90
+ */
91
+ const startsWithLineBreak = (text) => /^\s*/.exec(text)?.[0].includes('\n') ?? false;
92
+
69
93
  /**
70
94
  * @param {TObjectExpressionNode} node
71
95
  * @param {import('eslint').SourceCode} sourceCode
@@ -98,6 +122,7 @@ const buildSortedObjectText = (node, sourceCode) => {
98
122
 
99
123
  return {
100
124
  bodyText,
125
+ endsWithLineComment: trailingComment?.type === 'Line',
101
126
  hasComma: Boolean(comma),
102
127
  originalIndex,
103
128
  orderIndex: orderIndexMap.get(getPropertyName(property.key)) ?? HOST_DIRECTIVE_PROPERTY_ORDER.length,
@@ -120,10 +145,12 @@ const buildSortedObjectText = (node, sourceCode) => {
120
145
  return (
121
146
  '{' +
122
147
  sortedEntries
123
- .map(
124
- (entry, index) =>
125
- `${entry.bodyText}${index < sortedEntries.length - 1 || trailingComma ? ',' : ''}${entry.trailingText}`,
126
- )
148
+ .map((entry, index) => {
149
+ const followingText = sortedEntries[index + 1]?.bodyText ?? suffix;
150
+ const needsLineBreak = entry.endsWithLineComment && !startsWithLineBreak(followingText);
151
+
152
+ return `${entry.bodyText}${index < sortedEntries.length - 1 || trailingComma ? ',' : ''}${entry.trailingText}${needsLineBreak ? '\n' : ''}`;
153
+ })
127
154
  .join('') +
128
155
  suffix +
129
156
  '}'
@@ -181,9 +208,10 @@ const preferConciseAngularHostDirectives = {
181
208
  if (!hostDirectives) return;
182
209
 
183
210
  for (const element of hostDirectives.elements) {
184
- if (!element || element.type !== 'ObjectExpression') continue;
211
+ const unwrapped = unwrapTypeAssertions(element);
212
+ if (!unwrapped || unwrapped.type !== 'ObjectExpression') continue;
185
213
 
186
- const config = /** @type {TObjectExpressionNode} */ (element);
214
+ const config = /** @type {TObjectExpressionNode} */ (unwrapped);
187
215
  const properties = getHostDirectiveConfigProperties(config);
188
216
  if (!properties) continue;
189
217
 
@@ -191,12 +219,12 @@ const preferConciseAngularHostDirectives = {
191
219
  const directiveValue = getDirectiveValueNode(config);
192
220
  if (!directiveValue) continue;
193
221
 
194
- const hasComments = sourceCode.getCommentsInside(config).length > 0;
222
+ const hasComments = sourceCode.getCommentsInside(element).length > 0;
195
223
 
196
224
  context.report({
197
225
  node: config,
198
226
  messageId: 'preferShorthand',
199
- fix: hasComments ? null : (fixer) => fixer.replaceText(config, sourceCode.getText(directiveValue)),
227
+ fix: hasComments ? null : (fixer) => fixer.replaceText(element, sourceCode.getText(directiveValue)),
200
228
  });
201
229
 
202
230
  continue;
@@ -20,9 +20,16 @@ const getPropertyName = (key) => {
20
20
  * @returns {import('estree').Expression | null}
21
21
  */
22
22
  const getSingleArrayElement = (property) => {
23
- if (property.value.type !== 'ArrayExpression' || property.value.elements.length !== 1) return null;
23
+ /** @type {any} */
24
+ let value = property.value;
24
25
 
25
- const firstElement = /** @type {TArrayElement} */ (property.value.elements[0]);
26
+ while (value.type === 'TSAsExpression' || value.type === 'TSSatisfiesExpression') {
27
+ value = value.expression;
28
+ }
29
+
30
+ if (value.type !== 'ArrayExpression' || value.elements.length !== 1) return null;
31
+
32
+ const firstElement = /** @type {TArrayElement} */ (value.elements[0]);
26
33
  if (!firstElement || firstElement.type === 'SpreadElement') return null;
27
34
 
28
35
  return firstElement;
@@ -35,6 +42,7 @@ const preferConciseAngularStyleMetadata = {
35
42
  docs: {
36
43
  description:
37
44
  'Prefer `styleUrl` over single-item `styleUrls`, and avoid wrapping a single `styles` entry in an array.',
45
+ recommended: true,
38
46
  },
39
47
  fixable: 'code',
40
48
  messages: {