@ethlete/eslint-plugin 0.1.0-beta.5

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 (54) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +25 -0
  3. package/package.json +14 -0
  4. package/src/configs/recommended.js +403 -0
  5. package/src/index.js +136 -0
  6. package/src/rules/angular-decorator-property-order.js +205 -0
  7. package/src/rules/class-constant-property.js +344 -0
  8. package/src/rules/class-member-order.js +431 -0
  9. package/src/rules/enforce-routing-view-naming.js +84 -0
  10. package/src/rules/guard-return-newline.js +81 -0
  11. package/src/rules/inject-member-accessibility.js +93 -0
  12. package/src/rules/internals/angular-member-visibility.js +233 -0
  13. package/src/rules/internals/implemented-contract-members.js +294 -0
  14. package/src/rules/no-angular-router-api.js +158 -0
  15. package/src/rules/no-angular-seo-services.js +82 -0
  16. package/src/rules/no-direct-dom-manipulation.js +160 -0
  17. package/src/rules/no-document-cookie.js +55 -0
  18. package/src/rules/no-dom-query.js +81 -0
  19. package/src/rules/no-empty-angular-metadata-arrays.js +104 -0
  20. package/src/rules/no-empty-newlines-between-imports.js +45 -0
  21. package/src/rules/no-inject-chain.js +44 -0
  22. package/src/rules/no-leading-underscore-class-member.js +120 -0
  23. package/src/rules/no-legacy-angular-decorators.js +405 -0
  24. package/src/rules/no-locale-id.js +57 -0
  25. package/src/rules/no-member-alias.js +151 -0
  26. package/src/rules/no-native-observers.js +102 -0
  27. package/src/rules/no-pipe-logic.js +76 -0
  28. package/src/rules/no-public-property.js +85 -0
  29. package/src/rules/no-readonly-signal.js +119 -0
  30. package/src/rules/no-redundant-internal.js +94 -0
  31. package/src/rules/no-rxjs-in-effect.js +88 -0
  32. package/src/rules/no-screaming-case-local.js +113 -0
  33. package/src/rules/no-standalone-flag.js +95 -0
  34. package/src/rules/no-subscribe-in-pipe.js +89 -0
  35. package/src/rules/no-subscribe-with-body.js +95 -0
  36. package/src/rules/no-trivial-return-type.js +104 -0
  37. package/src/rules/no-trivial-wrapper-method.js +120 -0
  38. package/src/rules/no-type-only-import.js +79 -0
  39. package/src/rules/no-typed-injected-element-ref.js +88 -0
  40. package/src/rules/no-unused-class-member.js +209 -0
  41. package/src/rules/no-window-location.js +109 -0
  42. package/src/rules/prefer-clone-equal.js +112 -0
  43. package/src/rules/prefer-concise-angular-host-directives.js +225 -0
  44. package/src/rules/prefer-concise-angular-style-metadata.js +108 -0
  45. package/src/rules/prefer-element-dimensions.js +128 -0
  46. package/src/rules/prefer-linked-signal.js +124 -0
  47. package/src/rules/prefer-match-media.js +101 -0
  48. package/src/rules/prefer-rxjs-timer.js +82 -0
  49. package/src/rules/prefer-scroll-state.js +158 -0
  50. package/src/rules/prefer-viewport-size.js +75 -0
  51. package/src/rules/require-dollar-suffix.js +123 -0
  52. package/src/rules/require-on-push-change-detection.js +271 -0
  53. package/src/rules/require-view-encapsulation-none.js +264 -0
  54. package/src/rules/template-member-accessibility.js +190 -0
@@ -0,0 +1,431 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /** @typedef {'inject' | 'input' | 'output' | 'query' | 'property' | 'constructor' | 'method' | 'private-method'} TMemberGroup */
5
+ /** @typedef {{ dependencies: Set<string>; group: TMemberGroup; groupIndex: number; name: string; node: any; originalIndex: number }} TMemberEntry */
6
+ /** @typedef {TMemberEntry & { segmentText: string }} TSegmentEntry */
7
+
8
+ const INPUT_APIS = new Set(['input', 'model']);
9
+ const OUTPUT_APIS = new Set(['output', 'outputFromObservable']);
10
+ const QUERY_APIS = new Set(['viewChild', 'viewChildren', 'contentChild', 'contentChildren']);
11
+
12
+ /** @type {TMemberGroup[]} */
13
+ const MEMBER_GROUP_ORDER = [
14
+ 'inject',
15
+ 'input',
16
+ 'output',
17
+ 'query',
18
+ 'property',
19
+ 'constructor',
20
+ 'method',
21
+ 'private-method',
22
+ ];
23
+
24
+ const MEMBER_GROUP_ORDER_TEXT =
25
+ 'inject() members, inputs/model(), outputs, view/content queries, other properties, constructor, public methods, private methods';
26
+
27
+ const MEMBER_GROUP_INDEX = new Map(
28
+ MEMBER_GROUP_ORDER.map(/** @param {TMemberGroup} group @param {number} index */ (group, index) => [group, index]),
29
+ );
30
+
31
+ /**
32
+ * @param {any} key
33
+ */
34
+ const getMemberNameFromKey = (key) => {
35
+ if (!key) return null;
36
+ if (key.type === 'Identifier') return key.name;
37
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
38
+ if (key.type === 'PrivateIdentifier') return key.name;
39
+
40
+ return null;
41
+ };
42
+
43
+ /**
44
+ * @param {any} node
45
+ */
46
+ const getMemberName = (node) => {
47
+ if (node.type === 'MethodDefinition' && node.kind === 'constructor') {
48
+ return 'constructor';
49
+ }
50
+
51
+ return getMemberNameFromKey(node.key);
52
+ };
53
+
54
+ /**
55
+ * @param {any} value
56
+ */
57
+ const getCallRootName = (value) => {
58
+ if (!value || value.type !== 'CallExpression') return null;
59
+
60
+ const { callee } = value;
61
+
62
+ if (callee.type === 'Identifier') {
63
+ return callee.name;
64
+ }
65
+
66
+ if (callee.type === 'MemberExpression' && !callee.computed && callee.object.type === 'Identifier') {
67
+ return callee.object.name;
68
+ }
69
+
70
+ return null;
71
+ };
72
+
73
+ /**
74
+ * @param {any} node
75
+ * @param {Set<string>} apiNames
76
+ */
77
+ const isPropertyInitializedWith = (node, apiNames) => {
78
+ if (node.type !== 'PropertyDefinition') return false;
79
+
80
+ const apiName = getCallRootName(node.value);
81
+ return apiName !== null && apiNames.has(apiName);
82
+ };
83
+
84
+ /**
85
+ * @param {any} node
86
+ * @returns {TMemberGroup | null}
87
+ */
88
+ const getMemberGroup = (node) => {
89
+ if (node.static) return null;
90
+
91
+ if (node.type === 'PropertyDefinition') {
92
+ if (isPropertyInitializedWith(node, new Set(['inject']))) {
93
+ return 'inject';
94
+ }
95
+
96
+ if (isPropertyInitializedWith(node, INPUT_APIS)) {
97
+ return 'input';
98
+ }
99
+
100
+ if (isPropertyInitializedWith(node, OUTPUT_APIS)) {
101
+ return 'output';
102
+ }
103
+
104
+ if (isPropertyInitializedWith(node, QUERY_APIS)) {
105
+ return 'query';
106
+ }
107
+
108
+ return 'property';
109
+ }
110
+
111
+ if (node.type === 'MethodDefinition') {
112
+ if (node.kind === 'constructor') {
113
+ return 'constructor';
114
+ }
115
+
116
+ if (node.accessibility === 'private') {
117
+ return 'private-method';
118
+ }
119
+
120
+ return 'method';
121
+ }
122
+
123
+ return null;
124
+ };
125
+
126
+ /**
127
+ * @param {any} node
128
+ * @param {Set<string>} references
129
+ */
130
+ const collectThisReferences = (node, references) => {
131
+ if (!node || typeof node !== 'object') return;
132
+
133
+ if (Array.isArray(node)) {
134
+ for (const entry of node) {
135
+ collectThisReferences(entry, references);
136
+ }
137
+
138
+ return;
139
+ }
140
+
141
+ if (
142
+ node.type === 'MemberExpression' &&
143
+ !node.computed &&
144
+ node.object?.type === 'ThisExpression' &&
145
+ (node.property?.type === 'Identifier' || node.property?.type === 'PrivateIdentifier')
146
+ ) {
147
+ references.add(node.property.name);
148
+ }
149
+
150
+ for (const [key, value] of Object.entries(node)) {
151
+ if (key === 'parent' || key === 'range' || key === 'loc') continue;
152
+ if (!value || typeof value !== 'object') continue;
153
+
154
+ collectThisReferences(value, references);
155
+ }
156
+ };
157
+
158
+ /**
159
+ * @param {any} node
160
+ */
161
+ const getInitializerDependencies = (node) => {
162
+ if (node.type !== 'PropertyDefinition' || !node.value) {
163
+ return new Set();
164
+ }
165
+
166
+ const dependencies = new Set();
167
+ collectThisReferences(node.value, dependencies);
168
+
169
+ const memberName = getMemberName(node);
170
+
171
+ if (memberName !== null) {
172
+ dependencies.delete(memberName);
173
+ }
174
+
175
+ return dependencies;
176
+ };
177
+
178
+ /**
179
+ * @param {any} node
180
+ * @param {number} originalIndex
181
+ * @returns {TMemberEntry | null}
182
+ */
183
+ const toMemberEntry = (node, originalIndex) => {
184
+ const group = getMemberGroup(node);
185
+ const name = getMemberName(node);
186
+
187
+ if (group === null || name === null) {
188
+ return null;
189
+ }
190
+
191
+ return {
192
+ dependencies: getInitializerDependencies(node),
193
+ group,
194
+ groupIndex: MEMBER_GROUP_INDEX.get(group),
195
+ name,
196
+ node,
197
+ originalIndex,
198
+ };
199
+ };
200
+
201
+ /**
202
+ * @param {TMemberEntry} currentMember
203
+ * @param {TMemberEntry} earlierMember
204
+ */
205
+ const getOrderingConflict = (currentMember, earlierMember) => {
206
+ if (currentMember.dependencies.has(earlierMember.name)) {
207
+ return null;
208
+ }
209
+
210
+ if (earlierMember.dependencies.has(currentMember.name)) {
211
+ return {
212
+ type: 'dependency',
213
+ blockingMember: earlierMember,
214
+ };
215
+ }
216
+
217
+ if (currentMember.groupIndex < earlierMember.groupIndex) {
218
+ return {
219
+ type: 'group',
220
+ blockingMember: earlierMember,
221
+ };
222
+ }
223
+
224
+ return null;
225
+ };
226
+
227
+ /**
228
+ * @param {TMemberEntry[]} members
229
+ */
230
+ const sortMembers = (members) => {
231
+ /** @type {TMemberEntry[]} */
232
+ const remainingMembers = [...members];
233
+ /** @type {TMemberEntry[]} */
234
+ const sortedMembers = [];
235
+
236
+ while (remainingMembers.length > 0) {
237
+ const readyMembers = remainingMembers
238
+ .filter((member) =>
239
+ [...member.dependencies].every(
240
+ (dependencyName) => !remainingMembers.some((remainingMember) => remainingMember.name === dependencyName),
241
+ ),
242
+ )
243
+ .sort((left, right) => {
244
+ if (left.groupIndex !== right.groupIndex) {
245
+ return left.groupIndex - right.groupIndex;
246
+ }
247
+
248
+ return left.originalIndex - right.originalIndex;
249
+ });
250
+
251
+ const nextMember = readyMembers[0];
252
+
253
+ if (!nextMember) {
254
+ return null;
255
+ }
256
+
257
+ sortedMembers.push(nextMember);
258
+
259
+ const nextMemberIndex = remainingMembers.indexOf(nextMember);
260
+ remainingMembers.splice(nextMemberIndex, 1);
261
+ }
262
+
263
+ return sortedMembers;
264
+ };
265
+
266
+ /**
267
+ * @param {import('eslint').SourceCode} sourceCode
268
+ * @param {any} classBodyNode
269
+ * @param {TMemberEntry[]} members
270
+ */
271
+ const getSegmentEntries = (sourceCode, classBodyNode, members) => {
272
+ const openingBrace = sourceCode.getFirstToken(classBodyNode);
273
+ const closingBrace = sourceCode.getLastToken(classBodyNode);
274
+
275
+ if (!openingBrace || !closingBrace || openingBrace.value !== '{' || closingBrace.value !== '}') {
276
+ return null;
277
+ }
278
+
279
+ let segmentStart = openingBrace.range[1];
280
+
281
+ const segmentEntries = members.map((member) => {
282
+ const segmentText = sourceCode.text.slice(segmentStart, member.node.range[1]);
283
+ segmentStart = member.node.range[1];
284
+
285
+ return {
286
+ ...member,
287
+ segmentText,
288
+ };
289
+ });
290
+
291
+ const suffix = sourceCode.text.slice(segmentStart, closingBrace.range[0]);
292
+
293
+ return {
294
+ closingBrace,
295
+ openingBrace,
296
+ segmentEntries,
297
+ suffix,
298
+ };
299
+ };
300
+
301
+ /**
302
+ * @param {import('eslint').SourceCode} sourceCode
303
+ * @param {any} classBodyNode
304
+ * @param {TMemberEntry[]} members
305
+ */
306
+ const buildClassOrderFix = (sourceCode, classBodyNode, members) => {
307
+ if (classBodyNode.body.length !== members.length) {
308
+ return null;
309
+ }
310
+
311
+ const sortedMembers = sortMembers(members);
312
+
313
+ if (!sortedMembers) {
314
+ return null;
315
+ }
316
+
317
+ const segments = getSegmentEntries(sourceCode, classBodyNode, members);
318
+
319
+ if (!segments) {
320
+ return null;
321
+ }
322
+
323
+ const { closingBrace, openingBrace, segmentEntries, suffix } = segments;
324
+ const segmentEntriesByOriginalIndex = new Map(
325
+ segmentEntries.map(
326
+ /** @param {TSegmentEntry} segmentEntry */ (segmentEntry) => [segmentEntry.originalIndex, segmentEntry],
327
+ ),
328
+ );
329
+ const sortedSegmentEntries = sortedMembers.map((member) => segmentEntriesByOriginalIndex.get(member.originalIndex));
330
+
331
+ if (sortedSegmentEntries.some((entry) => entry === undefined)) {
332
+ return null;
333
+ }
334
+
335
+ const isAlreadySorted = sortedSegmentEntries.every((entry, index) => entry === segmentEntries[index]);
336
+
337
+ if (isAlreadySorted) {
338
+ return null;
339
+ }
340
+
341
+ const reorderedBody = sortedSegmentEntries.map((entry) => entry.segmentText).join('') + suffix;
342
+
343
+ return (fixer) => fixer.replaceTextRange([openingBrace.range[1], closingBrace.range[0]], reorderedBody);
344
+ };
345
+
346
+ /** @type {import('eslint').Rule.RuleModule} */
347
+ const classMemberOrder = {
348
+ meta: {
349
+ type: 'layout',
350
+ fixable: 'code',
351
+ docs: {
352
+ description:
353
+ 'Require class members to follow the styleguide order for inject members, inputs, outputs, queries, properties, constructor, and methods.',
354
+ recommended: true,
355
+ },
356
+ schema: [],
357
+ messages: {
358
+ dependencyOrder:
359
+ "'{{name}}' should be declared before '{{dependentName}}' because '{{dependentName}}' references it in an initializer.",
360
+ groupOrder:
361
+ "'{{name}}' should be declared before '{{blockingName}}'. Class members should be ordered as: {{order}}.",
362
+ },
363
+ },
364
+ create(context) {
365
+ const sourceCode = context.sourceCode;
366
+
367
+ return {
368
+ ClassBody(node) {
369
+ const members = node.body
370
+ .map(
371
+ /** @param {any} member @param {number} originalIndex */ (member, originalIndex) =>
372
+ toMemberEntry(member, originalIndex),
373
+ )
374
+ .filter(Boolean);
375
+
376
+ if (members.length < 2) {
377
+ return;
378
+ }
379
+
380
+ const fix = buildClassOrderFix(sourceCode, node, members);
381
+ let hasAttachedFix = false;
382
+
383
+ for (let currentIndex = 1; currentIndex < members.length; currentIndex++) {
384
+ const currentMember = members[currentIndex];
385
+
386
+ for (let earlierIndex = currentIndex - 1; earlierIndex >= 0; earlierIndex--) {
387
+ const earlierMember = members[earlierIndex];
388
+ const conflict = getOrderingConflict(currentMember, earlierMember);
389
+
390
+ if (!conflict) {
391
+ continue;
392
+ }
393
+
394
+ if (conflict.type === 'dependency') {
395
+ context.report({
396
+ node: currentMember.node,
397
+ messageId: 'dependencyOrder',
398
+ data: {
399
+ name: currentMember.name,
400
+ dependentName: conflict.blockingMember.name,
401
+ },
402
+ fix: !hasAttachedFix ? fix : null,
403
+ });
404
+
405
+ hasAttachedFix = hasAttachedFix || Boolean(fix);
406
+
407
+ break;
408
+ }
409
+
410
+ context.report({
411
+ node: currentMember.node,
412
+ messageId: 'groupOrder',
413
+ data: {
414
+ name: currentMember.name,
415
+ blockingName: conflict.blockingMember.name,
416
+ order: MEMBER_GROUP_ORDER_TEXT,
417
+ },
418
+ fix: !hasAttachedFix ? fix : null,
419
+ });
420
+
421
+ hasAttachedFix = hasAttachedFix || Boolean(fix);
422
+
423
+ break;
424
+ }
425
+ }
426
+ },
427
+ };
428
+ },
429
+ };
430
+
431
+ module.exports = classMemberOrder;
@@ -0,0 +1,84 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Checks that a `loadComponent:` route property:
6
+ * 1. Imports from a path containing "-view"
7
+ * 2. Accesses a class name ending in "ViewComponent" in the .then() callback
8
+ *
9
+ * Valid pattern:
10
+ * loadComponent: () => import('./items-list-view/items-list-view.component').then((m) => m.ItemsListViewComponent)
11
+ */
12
+
13
+ /** @type {import('eslint').Rule.RuleModule} */
14
+ const enforceRoutingViewNaming = {
15
+ meta: {
16
+ type: 'suggestion',
17
+ docs: {
18
+ description:
19
+ 'Routing components must import from a path containing "-view" and use a class name ending in "ViewComponent".',
20
+ },
21
+ messages: {
22
+ pathMustContainView: 'Routing import path must contain "-view". Got: "{{path}}".',
23
+ classMustEndWithViewComponent: 'Routing component class must end with "ViewComponent". Got: "{{name}}".',
24
+ },
25
+ schema: [],
26
+ },
27
+ create(context) {
28
+ return {
29
+ Property(node) {
30
+ const key = /** @type {any} */ (node).key;
31
+ if (key.type !== 'Identifier' || key.name !== 'loadComponent') return;
32
+
33
+ // Must be an arrow function: () => import(...).then(m => m.XxxViewComponent)
34
+ const arrow = /** @type {any} */ (node).value;
35
+ if (!arrow || arrow.type !== 'ArrowFunctionExpression') return;
36
+
37
+ const body = arrow.body;
38
+ if (!body || body.type !== 'CallExpression') return;
39
+
40
+ const callee = body.callee;
41
+ if (!callee || callee.type !== 'MemberExpression') return;
42
+ if (callee.property.type !== 'Identifier' || callee.property.name !== 'then') return;
43
+
44
+ // Check the import path contains "-view"
45
+ const importExpr = callee.object;
46
+ if (importExpr && importExpr.type === 'ImportExpression') {
47
+ const source = importExpr.source;
48
+ if (source && source.type === 'Literal' && typeof source.value === 'string') {
49
+ if (!source.value.includes('-view')) {
50
+ context.report({
51
+ node: source,
52
+ messageId: 'pathMustContainView',
53
+ data: { path: source.value },
54
+ });
55
+ }
56
+ }
57
+ }
58
+
59
+ // Check the class name in .then(m => m.XxxViewComponent)
60
+ const args = body.arguments;
61
+ if (!args || args.length === 0) return;
62
+
63
+ const callback = args[0];
64
+ if (!callback || callback.type !== 'ArrowFunctionExpression') return;
65
+
66
+ const cbBody = callback.body;
67
+ if (!cbBody || cbBody.type !== 'MemberExpression') return;
68
+
69
+ const prop = cbBody.property;
70
+ if (!prop || prop.type !== 'Identifier') return;
71
+
72
+ if (!prop.name.endsWith('ViewComponent')) {
73
+ context.report({
74
+ node: prop,
75
+ messageId: 'classMustEndWithViewComponent',
76
+ data: { name: prop.name },
77
+ });
78
+ }
79
+ },
80
+ };
81
+ },
82
+ };
83
+
84
+ module.exports = enforceRoutingViewNaming;
@@ -0,0 +1,81 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Enforces an empty line before a `return` statement that is the last statement
6
+ * in a multi-statement if-block (guard clause pattern).
7
+ *
8
+ * ❌ if (!allFilled) {
9
+ * this.selectFirstUnfilledGuidedSlot();
10
+ * return;
11
+ * }
12
+ *
13
+ * ✅ if (!allFilled) {
14
+ * this.selectFirstUnfilledGuidedSlot();
15
+ *
16
+ * return;
17
+ * }
18
+ *
19
+ * ✅ Single-line guards are exempt: if (!slot) return;
20
+ *
21
+ * @type {import('eslint').Rule.RuleModule}
22
+ */
23
+ const rule = {
24
+ meta: {
25
+ type: 'layout',
26
+ docs: {
27
+ description: 'Require an empty line before a return in a multi-statement if-block (guard clause).',
28
+ },
29
+ fixable: 'whitespace',
30
+ schema: [],
31
+ messages: {
32
+ missingEmptyLine: 'Add an empty line before the return statement inside a multi-statement if-block.',
33
+ },
34
+ },
35
+ create(context) {
36
+ return {
37
+ ReturnStatement(node) {
38
+ const blockParent = node.parent;
39
+
40
+ // Must be directly inside a BlockStatement
41
+ if (!blockParent || blockParent.type !== 'BlockStatement') return;
42
+
43
+ // The block's parent must be an IfStatement's consequent
44
+ const ifStatement = blockParent.parent;
45
+ if (!ifStatement || ifStatement.type !== 'IfStatement') return;
46
+ if (ifStatement.consequent !== blockParent) return;
47
+
48
+ // Block must have more than one statement (multi-statement guard)
49
+ if (blockParent.body.length <= 1) return;
50
+
51
+ // The return must be the last statement in the block
52
+ if (blockParent.body[blockParent.body.length - 1] !== node) return;
53
+
54
+ const prevStatement = blockParent.body[blockParent.body.length - 2];
55
+
56
+ if (!node.loc || !prevStatement.loc) return;
57
+
58
+ const prevEndLine = prevStatement.loc.end.line;
59
+ const returnStartLine = node.loc.start.line;
60
+
61
+ // Need at least one empty line (gap of 2+ lines) between prev statement and return
62
+ if (returnStartLine - prevEndLine >= 2) return;
63
+
64
+ const sourceCode = context.sourceCode;
65
+
66
+ context.report({
67
+ node,
68
+ messageId: 'missingEmptyLine',
69
+ fix(fixer) {
70
+ const prevToken = sourceCode.getLastToken(/** @type {import('eslint').Rule.Node} */ (prevStatement));
71
+ if (!prevToken) return null;
72
+
73
+ return fixer.insertTextAfter(prevToken, '\n');
74
+ },
75
+ });
76
+ },
77
+ };
78
+ },
79
+ };
80
+
81
+ module.exports = rule;
@@ -0,0 +1,93 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const { getMemberName, isReferencedFromTemplateOrHost } = require('./internals/angular-member-visibility');
5
+
6
+ /**
7
+ * @param {any} node
8
+ */
9
+ const isInjectCall = (node) => {
10
+ if (!node || node.type !== 'PropertyDefinition' || !node.value) return false;
11
+ if (node.value.type !== 'CallExpression') return false;
12
+ return node.value.callee.type === 'Identifier' && node.value.callee.name === 'inject';
13
+ };
14
+
15
+ /**
16
+ * @param {any} node
17
+ * @param {import('eslint').SourceCode} sourceCode
18
+ * @param {'private' | 'protected'} expectedAccessibility
19
+ */
20
+ const buildFix = (node, sourceCode, expectedAccessibility) => {
21
+ const keyToken = sourceCode.getFirstToken(node.key);
22
+ if (!keyToken) return null;
23
+
24
+ if (node.accessibility) {
25
+ const firstToken = sourceCode.getFirstToken(node);
26
+ if (!firstToken) return null;
27
+
28
+ return (fixer) => fixer.replaceText(firstToken, expectedAccessibility);
29
+ }
30
+
31
+ return (fixer) => fixer.insertTextBefore(keyToken, `${expectedAccessibility} `);
32
+ };
33
+
34
+ /** @type {import('eslint').Rule.RuleModule} */
35
+ const injectMemberAccessibility = {
36
+ meta: {
37
+ type: 'problem',
38
+ docs: {
39
+ description:
40
+ 'Require injected class members to be private by default, protected when referenced from an Angular template or host binding, and allow explicit public when intentionally exposing an external API.',
41
+ recommended: true,
42
+ },
43
+ fixable: 'code',
44
+ schema: [],
45
+ messages: {
46
+ shouldBePrivate:
47
+ 'Injected member `{{name}}` should be private. Use protected only when it is referenced from a template or host binding.',
48
+ shouldBeProtected:
49
+ 'Injected member `{{name}}` should be protected because it is referenced from a template or host binding. Use public only when it intentionally exposes an external API across class boundaries.',
50
+ },
51
+ },
52
+ create(context) {
53
+ const sourceCode = context.sourceCode;
54
+
55
+ return {
56
+ PropertyDefinition(node) {
57
+ if (!isInjectCall(node)) return;
58
+
59
+ const classNode = node.parent && node.parent.type === 'ClassBody' ? node.parent.parent : null;
60
+ if (!classNode || (classNode.type !== 'ClassDeclaration' && classNode.type !== 'ClassExpression')) return;
61
+
62
+ const memberName = getMemberName(node);
63
+ if (!memberName) return;
64
+
65
+ const isTemplateOrHostVisible = isReferencedFromTemplateOrHost(classNode, memberName, context);
66
+
67
+ if (isTemplateOrHostVisible) {
68
+ if (node.accessibility === 'protected' || node.accessibility === 'public') return;
69
+
70
+ context.report({
71
+ node,
72
+ messageId: 'shouldBeProtected',
73
+ data: { name: memberName },
74
+ fix: buildFix(node, sourceCode, 'protected'),
75
+ });
76
+
77
+ return;
78
+ }
79
+
80
+ if (node.accessibility === 'private' || node.accessibility === 'public') return;
81
+
82
+ context.report({
83
+ node,
84
+ messageId: 'shouldBePrivate',
85
+ data: { name: memberName },
86
+ fix: buildFix(node, sourceCode, 'private'),
87
+ });
88
+ },
89
+ };
90
+ },
91
+ };
92
+
93
+ module.exports = injectMemberAccessibility;