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

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 (41) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/package.json +3 -4
  3. package/src/configs/recommended.js +362 -0
  4. package/src/index.js +116 -0
  5. package/src/rules/angular-decorator-property-order.js +217 -0
  6. package/src/rules/enforce-routing-view-naming.js +84 -0
  7. package/src/rules/guard-return-newline.js +81 -0
  8. package/src/rules/inject-member-accessibility.js +286 -0
  9. package/src/rules/no-angular-router-api.js +158 -0
  10. package/src/rules/no-angular-seo-services.js +82 -0
  11. package/src/rules/no-direct-dom-manipulation.js +160 -0
  12. package/src/rules/no-document-cookie.js +55 -0
  13. package/src/rules/no-dom-query.js +81 -0
  14. package/src/rules/no-inject-chain.js +44 -0
  15. package/src/rules/no-legacy-angular-decorators.js +210 -0
  16. package/src/rules/no-locale-id.js +57 -0
  17. package/src/rules/no-member-alias.js +151 -0
  18. package/src/rules/no-native-observers.js +102 -0
  19. package/src/rules/no-pipe-logic.js +76 -0
  20. package/src/rules/no-public-property.js +39 -0
  21. package/src/rules/no-readonly-signal.js +119 -0
  22. package/src/rules/no-rxjs-in-effect.js +88 -0
  23. package/src/rules/no-screaming-case-local.js +113 -0
  24. package/src/rules/no-subscribe-in-pipe.js +89 -0
  25. package/src/rules/no-subscribe-with-body.js +95 -0
  26. package/src/rules/no-trivial-return-type.js +104 -0
  27. package/src/rules/no-trivial-wrapper-method.js +120 -0
  28. package/src/rules/no-type-only-import.js +79 -0
  29. package/src/rules/no-unused-class-member.js +209 -0
  30. package/src/rules/no-window-location.js +109 -0
  31. package/src/rules/prefer-clone-equal.js +112 -0
  32. package/src/rules/prefer-concise-angular-host-directives.js +225 -0
  33. package/src/rules/prefer-concise-angular-style-metadata.js +108 -0
  34. package/src/rules/prefer-element-dimensions.js +128 -0
  35. package/src/rules/prefer-linked-signal.js +124 -0
  36. package/src/rules/prefer-match-media.js +101 -0
  37. package/src/rules/prefer-rxjs-timer.js +82 -0
  38. package/src/rules/prefer-scroll-state.js +158 -0
  39. package/src/rules/prefer-viewport-size.js +75 -0
  40. package/src/rules/require-dollar-suffix.js +123 -0
  41. package/src/rules/require-view-encapsulation-none.js +101 -0
@@ -0,0 +1,217 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /** @typedef {'Component' | 'Directive'} TDecoratorName */
5
+ /** @typedef {{ order: string[]; orderText: string }} TDecoratorConfig */
6
+ /** @typedef {{ range: [number, number] }} TNodeWithRange */
7
+ /** @typedef {import('estree').Property & TNodeWithRange} TPropertyNode */
8
+ /** @typedef {import('estree').SpreadElement & TNodeWithRange} TSpreadElementNode */
9
+ /** @typedef {import('estree').ObjectExpression & TNodeWithRange & { properties: Array<TPropertyNode | TSpreadElementNode> }} TObjectExpressionNode */
10
+ /** @typedef {{ hasComma: boolean; originalIndex: number; orderIndex: number; property: TPropertyNode; segmentText: string }} TPropertyEntry */
11
+
12
+ /** @type {Record<TDecoratorName, TDecoratorConfig>} */
13
+ const DECORATOR_CONFIG = {
14
+ Component: {
15
+ order: [
16
+ 'selector',
17
+ 'template',
18
+ 'styleUrl',
19
+ 'encapsulation',
20
+ 'changeDetection',
21
+ 'imports',
22
+ 'providers',
23
+ 'viewProviders',
24
+ 'animations',
25
+ 'hostDirectives',
26
+ 'host',
27
+ 'styles',
28
+ 'inputs',
29
+ 'outputs',
30
+ 'queries',
31
+ 'preserveWhitespaces',
32
+ 'standalone',
33
+ 'schemas',
34
+ 'jit',
35
+ 'exportAs',
36
+ ],
37
+ orderText:
38
+ 'selector, template/templateUrl, styleUrl/styleUrls, encapsulation, changeDetection, imports, providers, viewProviders, animations, hostDirectives, host, styles, inputs, outputs, queries, preserveWhitespaces, standalone, schemas, jit, exportAs',
39
+ },
40
+ 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',
54
+ },
55
+ };
56
+
57
+ /**
58
+ * @param {import('estree').Property['key']} key
59
+ */
60
+ const getPropertyName = (key) => {
61
+ if (key.type === 'Identifier') return key.name;
62
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
63
+ return null;
64
+ };
65
+
66
+ /**
67
+ * @param {TDecoratorName} decoratorName
68
+ * @param {string | null} propertyName
69
+ */
70
+ const getOrderKey = (decoratorName, propertyName) => {
71
+ if (propertyName === null) return null;
72
+
73
+ if (decoratorName === 'Component') {
74
+ if (propertyName === 'template' || propertyName === 'templateUrl') return 'template';
75
+ if (propertyName === 'styleUrl' || propertyName === 'styleUrls') return 'styleUrl';
76
+ }
77
+
78
+ return propertyName;
79
+ };
80
+
81
+ /**
82
+ * @param {import('eslint').Rule.Node} node
83
+ * @returns {TDecoratorName | null}
84
+ */
85
+ const getDecoratorName = (node) => {
86
+ const decorator = /** @type {any} */ (node);
87
+ if (decorator.type !== 'Decorator') return null;
88
+
89
+ const expression = decorator.expression;
90
+ if (expression.type === 'CallExpression') {
91
+ return expression.callee.type === 'Identifier' ? expression.callee.name : null;
92
+ }
93
+
94
+ return expression.type === 'Identifier' ? expression.name : null;
95
+ };
96
+
97
+ /** @type {import('eslint').Rule.RuleModule} */
98
+ const angularDecoratorPropertyOrder = {
99
+ meta: {
100
+ type: 'layout',
101
+ docs: {
102
+ description: 'Require a consistent property order in Angular @Component and @Directive metadata objects.',
103
+ },
104
+ fixable: 'code',
105
+ messages: {
106
+ outOfOrder: '{{decorator}} metadata properties should be ordered as: {{order}}.',
107
+ },
108
+ schema: [],
109
+ },
110
+ create(context) {
111
+ const sourceCode = context.sourceCode;
112
+
113
+ return {
114
+ /** @param {import('eslint').Rule.Node} node */
115
+ Decorator(node) {
116
+ const decoratorName = getDecoratorName(node);
117
+ if (decoratorName !== 'Component' && decoratorName !== 'Directive') return;
118
+
119
+ const decorator = /** @type {any} */ (node);
120
+ const expression = decorator.expression;
121
+ if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
122
+
123
+ const metadata = /** @type {TObjectExpressionNode | undefined} */ (expression.arguments[0]);
124
+ if (!metadata || metadata.type !== 'ObjectExpression' || metadata.properties.length < 2) return;
125
+
126
+ if (
127
+ metadata.properties.some(
128
+ /** @param {TPropertyNode | TSpreadElementNode} property */ (property) => property.type !== 'Property',
129
+ )
130
+ )
131
+ return;
132
+
133
+ const config = DECORATOR_CONFIG[decoratorName];
134
+ const orderIndexMap = new Map(
135
+ config.order.map(/** @param {string} value @param {number} index */ (value, index) => [value, index]),
136
+ );
137
+ const openingBrace = sourceCode.getFirstToken(metadata);
138
+ const closingBrace = sourceCode.getLastToken(metadata);
139
+
140
+ if (!openingBrace || !closingBrace || openingBrace.value !== '{' || closingBrace.value !== '}') return;
141
+
142
+ let segmentStart = openingBrace.range[1];
143
+
144
+ const entries = metadata.properties.map(
145
+ /** @param {TPropertyNode | TSpreadElementNode} property @param {number} originalIndex @returns {TPropertyEntry} */
146
+ (property, originalIndex) => {
147
+ const propertyNode = /** @type {TPropertyNode} */ (property);
148
+ const propertyName = getPropertyName(propertyNode.key);
149
+ const orderKey = getOrderKey(decoratorName, propertyName);
150
+ const orderIndex =
151
+ orderKey === null ? config.order.length : (orderIndexMap.get(orderKey) ?? config.order.length);
152
+ const tokenAfter = sourceCode.getTokenAfter(propertyNode);
153
+ const hasComma = Boolean(tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',');
154
+ const segmentEnd = hasComma && tokenAfter ? tokenAfter.range[0] : propertyNode.range[1];
155
+ const segmentText = sourceCode.text.slice(segmentStart, segmentEnd);
156
+
157
+ if (hasComma && tokenAfter) {
158
+ segmentStart = tokenAfter.range[1];
159
+ } else {
160
+ segmentStart = propertyNode.range[1];
161
+ }
162
+
163
+ return {
164
+ hasComma,
165
+ originalIndex,
166
+ orderIndex,
167
+ property: propertyNode,
168
+ segmentText,
169
+ };
170
+ },
171
+ );
172
+
173
+ const trailingComma = entries[entries.length - 1].hasComma;
174
+ const suffix = sourceCode.text.slice(segmentStart, closingBrace.range[0]);
175
+ const sortedEntries = [...entries].sort(
176
+ /** @param {TPropertyEntry} left @param {TPropertyEntry} right */ (left, right) => {
177
+ if (left.orderIndex !== right.orderIndex) return left.orderIndex - right.orderIndex;
178
+ return left.originalIndex - right.originalIndex;
179
+ },
180
+ );
181
+
182
+ const isSorted = entries.every(
183
+ /** @param {TPropertyEntry} entry @param {number} index */ (entry, index) =>
184
+ entry.property === sortedEntries[index].property,
185
+ );
186
+ if (isSorted) return;
187
+
188
+ const firstMismatch = entries.find(
189
+ /** @param {TPropertyEntry} entry @param {number} index */ (entry, index) =>
190
+ entry.property !== sortedEntries[index].property,
191
+ );
192
+
193
+ context.report({
194
+ node: firstMismatch ? firstMismatch.property : metadata,
195
+ messageId: 'outOfOrder',
196
+ data: {
197
+ decorator: `@${decoratorName}`,
198
+ order: config.orderText,
199
+ },
200
+ fix(fixer) {
201
+ const reorderedBody =
202
+ sortedEntries
203
+ .map(
204
+ (entry, index) =>
205
+ `${entry.segmentText}${index < sortedEntries.length - 1 || trailingComma ? ',' : ''}`,
206
+ )
207
+ .join('') + suffix;
208
+
209
+ return fixer.replaceTextRange([openingBrace.range[1], closingBrace.range[0]], reorderedBody);
210
+ },
211
+ });
212
+ },
213
+ };
214
+ },
215
+ };
216
+
217
+ module.exports = angularDecoratorPropertyOrder;
@@ -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,286 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ /**
8
+ * @param {string} value
9
+ */
10
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
11
+
12
+ /**
13
+ * @param {any} decorator
14
+ */
15
+ const getDecoratorCall = (decorator) => {
16
+ if (!decorator || decorator.type !== 'Decorator') return null;
17
+
18
+ const expression = decorator.expression;
19
+ if (expression.type !== 'CallExpression') return null;
20
+ if (expression.callee.type !== 'Identifier') return null;
21
+ if (expression.arguments.length === 0) return null;
22
+
23
+ const metadata = expression.arguments[0];
24
+ if (!metadata || metadata.type !== 'ObjectExpression') return null;
25
+
26
+ return {
27
+ name: expression.callee.name,
28
+ metadata,
29
+ };
30
+ };
31
+
32
+ /**
33
+ * @param {any} property
34
+ */
35
+ const getPropertyKeyName = (property) => {
36
+ if (!property || property.computed) return null;
37
+
38
+ const key = property.key;
39
+ if (key.type === 'Identifier') return key.name;
40
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
41
+
42
+ return null;
43
+ };
44
+
45
+ /**
46
+ * @param {any} metadata
47
+ * @param {string} propertyName
48
+ */
49
+ const getMetadataProperty = (metadata, propertyName) => {
50
+ for (const property of metadata.properties) {
51
+ if (!property || property.type !== 'Property' || property.computed) continue;
52
+
53
+ const keyName = getPropertyKeyName(property);
54
+
55
+ if (keyName === propertyName) {
56
+ return property;
57
+ }
58
+ }
59
+
60
+ return null;
61
+ };
62
+
63
+ /**
64
+ * @param {any} node
65
+ */
66
+ const getStringValue = (node) => {
67
+ if (!node) return null;
68
+ if (node.type === 'Literal' && typeof node.value === 'string') return node.value;
69
+
70
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
71
+ return node.quasis[0].value.cooked || '';
72
+ }
73
+
74
+ return null;
75
+ };
76
+
77
+ /**
78
+ * @param {string} filePath
79
+ */
80
+ const readFileIfExists = (filePath) => {
81
+ try {
82
+ if (fs.existsSync(filePath)) {
83
+ return fs.readFileSync(filePath, 'utf8');
84
+ }
85
+ } catch {
86
+ return null;
87
+ }
88
+
89
+ return null;
90
+ };
91
+
92
+ /**
93
+ * @param {string} memberName
94
+ * @param {string | null} template
95
+ */
96
+ const templateReferencesMember = (memberName, template) => {
97
+ if (!template) return false;
98
+
99
+ const escapedName = escapeRegExp(memberName);
100
+ const patterns = [
101
+ new RegExp(`\\{\\{[\\s\\S]*?\\b${escapedName}\\b[\\s\\S]*?\\}\\}`, 'u'),
102
+ new RegExp(`@[a-zA-Z]+\\s*\\([\\s\\S]*?\\b${escapedName}\\b[\\s\\S]*?\\)`, 'u'),
103
+ new RegExp(`\\[[^\\]]+\\]\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
104
+ new RegExp(`\\[[^\\]]+\\]\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
105
+ new RegExp(`\\([^\\)]+\\)\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
106
+ new RegExp(`\\([^\\)]+\\)\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
107
+ ];
108
+
109
+ return patterns.some((pattern) => pattern.test(template));
110
+ };
111
+
112
+ /**
113
+ * @param {string | null} keyName
114
+ */
115
+ const isDynamicHostBindingKey = (keyName) => {
116
+ if (!keyName) return false;
117
+
118
+ return keyName.startsWith('[') || keyName.startsWith('(') || keyName.startsWith('@');
119
+ };
120
+
121
+ /**
122
+ * @param {any} classNode
123
+ * @param {import('eslint').Rule.RuleContext} context
124
+ */
125
+ const getAngularMetadata = (classNode, context) => {
126
+ for (const decorator of classNode.decorators || []) {
127
+ const call = getDecoratorCall(decorator);
128
+ if (!call) continue;
129
+
130
+ if (call.name === 'Component' || call.name === 'Directive') {
131
+ return call;
132
+ }
133
+ }
134
+
135
+ return null;
136
+ };
137
+
138
+ /**
139
+ * @param {string} memberName
140
+ * @param {string | null} text
141
+ */
142
+ const textReferencesMember = (memberName, text) => {
143
+ if (!text) return false;
144
+
145
+ const pattern = new RegExp(`\\b${escapeRegExp(memberName)}\\b`, 'u');
146
+ return pattern.test(text);
147
+ };
148
+
149
+ /**
150
+ * @param {string} memberName
151
+ * @param {any} metadata
152
+ * @param {import('eslint').Rule.RuleContext} context
153
+ */
154
+ const isReferencedFromTemplateOrHost = (memberName, metadata, context) => {
155
+ const templateProperty = getMetadataProperty(metadata, 'template');
156
+ if (templateProperty && templateReferencesMember(memberName, getStringValue(templateProperty.value))) {
157
+ return true;
158
+ }
159
+
160
+ const templateUrlProperty = getMetadataProperty(metadata, 'templateUrl');
161
+ if (templateUrlProperty) {
162
+ const templateUrl = getStringValue(templateUrlProperty.value);
163
+ if (templateUrl) {
164
+ const templatePath = path.resolve(path.dirname(context.filename), templateUrl);
165
+ const templateText = readFileIfExists(templatePath);
166
+ if (templateReferencesMember(memberName, templateText)) {
167
+ return true;
168
+ }
169
+ }
170
+ }
171
+
172
+ const hostProperty = getMetadataProperty(metadata, 'host');
173
+ if (hostProperty && hostProperty.value.type === 'ObjectExpression') {
174
+ for (const property of hostProperty.value.properties) {
175
+ if (!property || property.type !== 'Property') continue;
176
+ if (!isDynamicHostBindingKey(getPropertyKeyName(property))) continue;
177
+
178
+ if (textReferencesMember(memberName, getStringValue(property.value))) {
179
+ return true;
180
+ }
181
+ }
182
+ }
183
+
184
+ return false;
185
+ };
186
+
187
+ /**
188
+ * @param {any} node
189
+ */
190
+ const isInjectCall = (node) => {
191
+ if (!node || node.type !== 'PropertyDefinition' || !node.value) return false;
192
+ if (node.value.type !== 'CallExpression') return false;
193
+ return node.value.callee.type === 'Identifier' && node.value.callee.name === 'inject';
194
+ };
195
+
196
+ /**
197
+ * @param {any} node
198
+ */
199
+ const getMemberName = (node) => {
200
+ if (node.key.type === 'Identifier') return node.key.name;
201
+ if (node.key.type === 'Literal' && typeof node.key.value === 'string') return node.key.value;
202
+ return null;
203
+ };
204
+
205
+ /**
206
+ * @param {any} node
207
+ * @param {import('eslint').SourceCode} sourceCode
208
+ * @param {'private' | 'protected'} expectedAccessibility
209
+ */
210
+ const buildFix = (node, sourceCode, expectedAccessibility) => {
211
+ const keyToken = sourceCode.getFirstToken(node.key);
212
+ if (!keyToken) return null;
213
+
214
+ if (node.accessibility) {
215
+ const firstToken = sourceCode.getFirstToken(node);
216
+ if (!firstToken) return null;
217
+
218
+ return (fixer) => fixer.replaceText(firstToken, expectedAccessibility);
219
+ }
220
+
221
+ return (fixer) => fixer.insertTextBefore(keyToken, `${expectedAccessibility} `);
222
+ };
223
+
224
+ /** @type {import('eslint').Rule.RuleModule} */
225
+ const injectMemberAccessibility = {
226
+ meta: {
227
+ type: 'problem',
228
+ docs: {
229
+ description:
230
+ 'Require injected class members to be private by default, and protected when referenced from an Angular template or host binding.',
231
+ recommended: true,
232
+ },
233
+ fixable: 'code',
234
+ schema: [],
235
+ messages: {
236
+ shouldBePrivate:
237
+ 'Injected member `{{name}}` should be private. Use protected only when it is referenced from a template or host binding.',
238
+ shouldBeProtected:
239
+ 'Injected member `{{name}}` should be protected because it is referenced from a template or host binding.',
240
+ },
241
+ },
242
+ create(context) {
243
+ const sourceCode = context.sourceCode;
244
+
245
+ return {
246
+ PropertyDefinition(node) {
247
+ if (!isInjectCall(node)) return;
248
+
249
+ const classNode = node.parent && node.parent.type === 'ClassBody' ? node.parent.parent : null;
250
+ if (!classNode || (classNode.type !== 'ClassDeclaration' && classNode.type !== 'ClassExpression')) return;
251
+
252
+ const memberName = getMemberName(node);
253
+ if (!memberName) return;
254
+
255
+ const angularMetadata = getAngularMetadata(classNode, context);
256
+ const isTemplateOrHostVisible = angularMetadata
257
+ ? isReferencedFromTemplateOrHost(memberName, angularMetadata.metadata, context)
258
+ : false;
259
+
260
+ if (isTemplateOrHostVisible) {
261
+ if (node.accessibility === 'protected') return;
262
+
263
+ context.report({
264
+ node,
265
+ messageId: 'shouldBeProtected',
266
+ data: { name: memberName },
267
+ fix: buildFix(node, sourceCode, 'protected'),
268
+ });
269
+
270
+ return;
271
+ }
272
+
273
+ if (node.accessibility === 'private' || node.accessibility === 'public') return;
274
+
275
+ context.report({
276
+ node,
277
+ messageId: 'shouldBePrivate',
278
+ data: { name: memberName },
279
+ fix: buildFix(node, sourceCode, 'private'),
280
+ });
281
+ },
282
+ };
283
+ },
284
+ };
285
+
286
+ module.exports = injectMemberAccessibility;