@ethlete/eslint-plugin 0.1.0-next.0 → 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.
- package/CHANGELOG.md +64 -0
- package/package.json +6 -5
- package/src/configs/recommended.js +414 -0
- package/src/index.js +138 -0
- package/src/rules/angular-decorator-property-order.js +205 -0
- package/src/rules/class-constant-property.js +365 -0
- package/src/rules/class-member-order.js +456 -0
- package/src/rules/enforce-routing-view-naming.js +84 -0
- package/src/rules/guard-return-newline.js +81 -0
- package/src/rules/inject-member-accessibility.js +93 -0
- package/src/rules/internals/angular-member-visibility.js +233 -0
- package/src/rules/internals/implemented-contract-members.js +294 -0
- package/src/rules/no-angular-router-api.js +158 -0
- package/src/rules/no-angular-seo-services.js +82 -0
- package/src/rules/no-direct-dom-manipulation.js +160 -0
- package/src/rules/no-document-cookie.js +55 -0
- package/src/rules/no-dom-query.js +81 -0
- package/src/rules/no-empty-angular-metadata-arrays.js +104 -0
- package/src/rules/no-empty-newlines-between-imports.js +45 -0
- package/src/rules/no-inject-chain.js +44 -0
- package/src/rules/no-leading-underscore-class-member.js +120 -0
- package/src/rules/no-legacy-angular-decorators.js +405 -0
- package/src/rules/no-locale-id.js +57 -0
- package/src/rules/no-member-alias.js +151 -0
- package/src/rules/no-native-observers.js +102 -0
- package/src/rules/no-pipe-logic.js +76 -0
- package/src/rules/no-public-property.js +85 -0
- package/src/rules/no-readonly-signal.js +119 -0
- package/src/rules/no-redundant-internal.js +94 -0
- package/src/rules/no-redundant-on-push-change-detection.js +237 -0
- package/src/rules/no-rxjs-in-effect.js +88 -0
- package/src/rules/no-screaming-case-local.js +113 -0
- package/src/rules/no-standalone-flag.js +95 -0
- package/src/rules/no-subscribe-in-pipe.js +89 -0
- package/src/rules/no-subscribe-with-body.js +95 -0
- package/src/rules/no-trivial-return-type.js +104 -0
- package/src/rules/no-trivial-wrapper-method.js +120 -0
- package/src/rules/no-type-only-import.js +79 -0
- package/src/rules/no-typed-injected-element-ref.js +88 -0
- package/src/rules/no-unused-class-member.js +209 -0
- package/src/rules/no-window-location.js +109 -0
- package/src/rules/prefer-clone-equal.js +112 -0
- package/src/rules/prefer-concise-angular-host-directives.js +225 -0
- package/src/rules/prefer-concise-angular-style-metadata.js +108 -0
- package/src/rules/prefer-element-dimensions.js +128 -0
- package/src/rules/prefer-linked-signal.js +124 -0
- package/src/rules/prefer-match-media.js +101 -0
- package/src/rules/prefer-rxjs-timer.js +82 -0
- package/src/rules/prefer-scroll-state.js +158 -0
- package/src/rules/prefer-viewport-size.js +75 -0
- package/src/rules/require-dollar-suffix.js +123 -0
- package/src/rules/require-on-push-change-detection.js +320 -0
- package/src/rules/require-view-encapsulation-none.js +264 -0
- package/src/rules/template-member-accessibility.js +204 -0
|
@@ -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;
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows legacy Angular decorators in favour of their modern signal-based
|
|
6
|
+
* equivalents and the `host: {}` shorthand.
|
|
7
|
+
*
|
|
8
|
+
* Signal-based replacements:
|
|
9
|
+
* ❌ @Input() → ✅ input()
|
|
10
|
+
* ❌ @Output() → ✅ output()
|
|
11
|
+
* ❌ @ViewChild() → ✅ viewChild()
|
|
12
|
+
* ❌ @ViewChildren() → ✅ viewChildren()
|
|
13
|
+
* ❌ @ContentChild() → ✅ contentChild()
|
|
14
|
+
* ❌ @ContentChildren() → ✅ contentChildren()
|
|
15
|
+
*
|
|
16
|
+
* Pair detection — when @Input() x and @Output() xChange are declared in the
|
|
17
|
+
* same class they form a two-way binding pattern that must be replaced by
|
|
18
|
+
* model():
|
|
19
|
+
* ❌ @Input() value; @Output() valueChange = new EventEmitter();
|
|
20
|
+
* → ✅ value = model();
|
|
21
|
+
*
|
|
22
|
+
* Host bindings — use the host: {} property instead:
|
|
23
|
+
* ❌ @HostBinding('class.active') isActive = false;
|
|
24
|
+
* ❌ @HostListener('click') onClick() {}
|
|
25
|
+
* → ✅ host: { '[class.active]': 'isActive', '(click)': 'onClick()' }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns the identifier name of a decorator.
|
|
30
|
+
* Works for both `@Foo` (Identifier) and `@Foo(...)` (CallExpression).
|
|
31
|
+
*
|
|
32
|
+
* @param {any} decorator
|
|
33
|
+
* @returns {string | null}
|
|
34
|
+
*/
|
|
35
|
+
const getDecoratorName = (decorator) => {
|
|
36
|
+
const expr = decorator.expression;
|
|
37
|
+
if (expr.type === 'Identifier') return expr.name;
|
|
38
|
+
if (expr.type === 'CallExpression' && expr.callee.type === 'Identifier') return expr.callee.name;
|
|
39
|
+
return null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns the static string key of a class member, or null for computed keys.
|
|
44
|
+
*
|
|
45
|
+
* @param {any} node - PropertyDefinition or MethodDefinition
|
|
46
|
+
* @returns {string | null}
|
|
47
|
+
*/
|
|
48
|
+
const getMemberName = (node) => {
|
|
49
|
+
if (!node.computed && node.key.type === 'Identifier') return node.key.name;
|
|
50
|
+
if (!node.computed && node.key.type === 'Literal') return String(node.key.value);
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
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
|
+
|
|
228
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
229
|
+
const rule = {
|
|
230
|
+
meta: {
|
|
231
|
+
type: 'suggestion',
|
|
232
|
+
docs: {
|
|
233
|
+
description:
|
|
234
|
+
'Disallow legacy Angular decorators (@Input, @Output, @ViewChild, etc.) in favour of signal-based APIs and host: {} bindings.',
|
|
235
|
+
},
|
|
236
|
+
fixable: 'code',
|
|
237
|
+
schema: [],
|
|
238
|
+
messages: {
|
|
239
|
+
useInput: 'Use the input() signal function instead of @Input().',
|
|
240
|
+
useOutput: 'Use the output() signal function instead of @Output().',
|
|
241
|
+
useViewChild: 'Use the viewChild() signal function instead of @ViewChild().',
|
|
242
|
+
useViewChildren: 'Use the viewChildren() signal function instead of @ViewChildren().',
|
|
243
|
+
useContentChild: 'Use the contentChild() signal function instead of @ContentChild().',
|
|
244
|
+
useContentChildren: 'Use the contentChildren() signal function instead of @ContentChildren().',
|
|
245
|
+
useHostBinding: 'Use the host: {} property in @Component/@Directive instead of @HostBinding().',
|
|
246
|
+
useHostListener: 'Use the host: {} property in @Component/@Directive instead of @HostListener().',
|
|
247
|
+
useModel:
|
|
248
|
+
"Use model() instead of the @Input()/@Output() two-way binding pattern ('{{inputName}}' / '{{outputName}}').",
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
create(context) {
|
|
252
|
+
/**
|
|
253
|
+
* Stack of per-class contexts used to detect two-way binding pairs.
|
|
254
|
+
* We defer @Input / @Output reporting to class exit so we can check whether
|
|
255
|
+
* a `xChange` output has a matching `x` input (→ model()).
|
|
256
|
+
*
|
|
257
|
+
* @type {Array<{ inputs: Map<string, any>; outputs: Map<string, any> }>}
|
|
258
|
+
*/
|
|
259
|
+
const classStack = [];
|
|
260
|
+
|
|
261
|
+
const currentClass = () => classStack[classStack.length - 1] ?? null;
|
|
262
|
+
|
|
263
|
+
const pushClass = () => classStack.push({ inputs: new Map(), outputs: new Map() });
|
|
264
|
+
|
|
265
|
+
const popClass = () => {
|
|
266
|
+
const ctx = classStack.pop();
|
|
267
|
+
if (!ctx) return;
|
|
268
|
+
|
|
269
|
+
const reportedInputNames = new Set();
|
|
270
|
+
const reportedOutputNames = new Set();
|
|
271
|
+
|
|
272
|
+
// Detect @Input() x + @Output() xChange pairs → suggest model()
|
|
273
|
+
for (const [outputName, outputDecoratorNode] of ctx.outputs) {
|
|
274
|
+
if (!outputName.endsWith('Change')) continue;
|
|
275
|
+
const inputName = outputName.slice(0, -'Change'.length);
|
|
276
|
+
if (!ctx.inputs.has(inputName)) continue;
|
|
277
|
+
|
|
278
|
+
reportedInputNames.add(inputName);
|
|
279
|
+
reportedOutputNames.add(outputName);
|
|
280
|
+
|
|
281
|
+
context.report({
|
|
282
|
+
node: ctx.inputs.get(inputName),
|
|
283
|
+
messageId: 'useModel',
|
|
284
|
+
data: { inputName, outputName },
|
|
285
|
+
});
|
|
286
|
+
context.report({
|
|
287
|
+
node: outputDecoratorNode,
|
|
288
|
+
messageId: 'useModel',
|
|
289
|
+
data: { inputName, outputName },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Report remaining @Input() decorators that are not part of a pair
|
|
294
|
+
for (const [name, decoratorNode] of ctx.inputs) {
|
|
295
|
+
if (!reportedInputNames.has(name)) {
|
|
296
|
+
context.report({ node: decoratorNode, messageId: 'useInput' });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Report remaining @Output() decorators that are not part of a pair
|
|
301
|
+
for (const [name, decoratorNode] of ctx.outputs) {
|
|
302
|
+
if (!reportedOutputNames.has(name)) {
|
|
303
|
+
context.report({ node: decoratorNode, messageId: 'useOutput' });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
ClassDeclaration: pushClass,
|
|
310
|
+
ClassExpression: pushClass,
|
|
311
|
+
'ClassDeclaration:exit': popClass,
|
|
312
|
+
'ClassExpression:exit': popClass,
|
|
313
|
+
|
|
314
|
+
Decorator(node) {
|
|
315
|
+
const anyNode = /** @type {any} */ (node);
|
|
316
|
+
const decoratorName = getDecoratorName(anyNode);
|
|
317
|
+
if (!decoratorName) return;
|
|
318
|
+
|
|
319
|
+
// Only act on decorators attached to class members, not to the class itself
|
|
320
|
+
const parent = anyNode.parent;
|
|
321
|
+
if (!parent) return;
|
|
322
|
+
if (parent.type !== 'PropertyDefinition' && parent.type !== 'MethodDefinition') return;
|
|
323
|
+
|
|
324
|
+
const ctx = currentClass();
|
|
325
|
+
|
|
326
|
+
switch (decoratorName) {
|
|
327
|
+
case 'Input': {
|
|
328
|
+
if (!ctx) {
|
|
329
|
+
context.report({ node, messageId: 'useInput' });
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
const memberName = getMemberName(parent);
|
|
333
|
+
if (memberName !== null) {
|
|
334
|
+
ctx.inputs.set(memberName, node);
|
|
335
|
+
} else {
|
|
336
|
+
// Computed key — cannot be part of a two-way binding pair, report immediately
|
|
337
|
+
context.report({ node, messageId: 'useInput' });
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
case 'Output': {
|
|
343
|
+
if (!ctx) {
|
|
344
|
+
context.report({ node, messageId: 'useOutput' });
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
const memberName = getMemberName(parent);
|
|
348
|
+
if (memberName !== null) {
|
|
349
|
+
ctx.outputs.set(memberName, node);
|
|
350
|
+
} else {
|
|
351
|
+
context.report({ node, messageId: 'useOutput' });
|
|
352
|
+
}
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
case 'ViewChild':
|
|
357
|
+
context.report({ node, messageId: 'useViewChild' });
|
|
358
|
+
break;
|
|
359
|
+
|
|
360
|
+
case 'ViewChildren':
|
|
361
|
+
context.report({ node, messageId: 'useViewChildren' });
|
|
362
|
+
break;
|
|
363
|
+
|
|
364
|
+
case 'ContentChild':
|
|
365
|
+
context.report({ node, messageId: 'useContentChild' });
|
|
366
|
+
break;
|
|
367
|
+
|
|
368
|
+
case 'ContentChildren':
|
|
369
|
+
context.report({ node, messageId: 'useContentChildren' });
|
|
370
|
+
break;
|
|
371
|
+
|
|
372
|
+
case 'HostBinding':
|
|
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
|
+
});
|
|
394
|
+
break;
|
|
395
|
+
|
|
396
|
+
case 'HostListener':
|
|
397
|
+
context.report({ node, messageId: 'useHostListener' });
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
module.exports = rule;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows injecting Angular's LOCALE_ID token directly.
|
|
6
|
+
*
|
|
7
|
+
* @ethlete/core provides injectLocale() which wraps LOCALE_ID in a signal
|
|
8
|
+
* and exposes helper utilities for locale-aware formatting.
|
|
9
|
+
*
|
|
10
|
+
* BAD:
|
|
11
|
+
* inject(LOCALE_ID)
|
|
12
|
+
* import { LOCALE_ID } from '@angular/core';
|
|
13
|
+
*
|
|
14
|
+
* GOOD:
|
|
15
|
+
* import { injectLocale } from '@ethlete/core';
|
|
16
|
+
* const locale = injectLocale();
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
20
|
+
const noLocaleId = {
|
|
21
|
+
meta: {
|
|
22
|
+
type: 'suggestion',
|
|
23
|
+
docs: {
|
|
24
|
+
description: "Disallow injecting 'LOCALE_ID' directly. Use 'injectLocale()' from '@ethlete/core' instead.",
|
|
25
|
+
recommended: true,
|
|
26
|
+
},
|
|
27
|
+
messages: {
|
|
28
|
+
noLocaleId:
|
|
29
|
+
"Do not inject 'LOCALE_ID' directly. Use 'injectLocale()' from '@ethlete/core' instead — it wraps LOCALE_ID and provides a clean, signal-compatible API.",
|
|
30
|
+
},
|
|
31
|
+
schema: [],
|
|
32
|
+
},
|
|
33
|
+
create(context) {
|
|
34
|
+
return {
|
|
35
|
+
CallExpression(node) {
|
|
36
|
+
const { callee } = node;
|
|
37
|
+
if (callee.type !== 'Identifier' || callee.name !== 'inject') return;
|
|
38
|
+
const arg = node.arguments[0];
|
|
39
|
+
if (arg?.type === 'Identifier' && arg.name === 'LOCALE_ID') {
|
|
40
|
+
context.report({ node, messageId: 'noLocaleId' });
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
ImportDeclaration(node) {
|
|
45
|
+
if (node.source.value !== '@angular/core') return;
|
|
46
|
+
for (const specifier of node.specifiers) {
|
|
47
|
+
if (specifier.type === 'ImportSpecifier' && specifier.imported.name === 'LOCALE_ID') {
|
|
48
|
+
context.report({ node, messageId: 'noLocaleId' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
module.exports = noLocaleId;
|