@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,82 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows injecting Angular's `Title` and `Meta` services from
|
|
6
|
+
* `@angular/platform-browser` and importing them from that module.
|
|
7
|
+
*
|
|
8
|
+
* These services manipulate the document `<title>` and `<meta>` tags
|
|
9
|
+
* imperatively. The Ethlete platform requires going through the reactive SEO
|
|
10
|
+
* utilities in `@ethlete/core` instead, which integrate with the priority-based
|
|
11
|
+
* head-binding system and work correctly with SSR.
|
|
12
|
+
*
|
|
13
|
+
* ❌ Direct injection:
|
|
14
|
+
* const title = inject(Title);
|
|
15
|
+
* const meta = inject(Meta);
|
|
16
|
+
*
|
|
17
|
+
* ❌ Import (without injection — still signals intent to use them):
|
|
18
|
+
* import { Title, Meta } from '@angular/platform-browser';
|
|
19
|
+
*
|
|
20
|
+
* ✅ Use the @ethlete/core SEO utilities:
|
|
21
|
+
* import { injectTitleBinding, injectMetaBinding } from '@ethlete/core';
|
|
22
|
+
* const titleBinding = injectTitleBinding();
|
|
23
|
+
* const metaBinding = injectMetaBinding();
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
27
|
+
const rule = {
|
|
28
|
+
meta: {
|
|
29
|
+
type: 'suggestion',
|
|
30
|
+
docs: {
|
|
31
|
+
description: "Disallow Angular's Title/Meta services. Use @ethlete/core SEO utilities instead.",
|
|
32
|
+
},
|
|
33
|
+
schema: [],
|
|
34
|
+
messages: {
|
|
35
|
+
noInjectTitle: "Do not inject Angular's Title service. Use injectTitleBinding() from '@ethlete/core' instead.",
|
|
36
|
+
noInjectMeta: "Do not inject Angular's Meta service. Use injectMetaBinding() from '@ethlete/core' instead.",
|
|
37
|
+
noImportTitle:
|
|
38
|
+
"Do not import Title from '@angular/platform-browser'. Use injectTitleBinding() from '@ethlete/core' instead.",
|
|
39
|
+
noImportMeta:
|
|
40
|
+
"Do not import Meta from '@angular/platform-browser'. Use injectMetaBinding() from '@ethlete/core' instead.",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
create(context) {
|
|
44
|
+
/**
|
|
45
|
+
* Returns the first argument identifier name of an inject() call, or null.
|
|
46
|
+
* @param {any} node - CallExpression
|
|
47
|
+
* @returns {string | null}
|
|
48
|
+
*/
|
|
49
|
+
const injectTokenName = (node) => {
|
|
50
|
+
if (node.type !== 'CallExpression') return null;
|
|
51
|
+
if (node.callee.type !== 'Identifier' || node.callee.name !== 'inject') return null;
|
|
52
|
+
const firstArg = node.arguments[0];
|
|
53
|
+
if (!firstArg) return null;
|
|
54
|
+
if (firstArg.type === 'Identifier') return firstArg.name;
|
|
55
|
+
return null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
// Detect: const x = inject(Title) / inject(Meta)
|
|
60
|
+
CallExpression(node) {
|
|
61
|
+
const token = injectTokenName(/** @type {any} */ (node));
|
|
62
|
+
if (token === 'Title') context.report({ node, messageId: 'noInjectTitle' });
|
|
63
|
+
if (token === 'Meta') context.report({ node, messageId: 'noInjectMeta' });
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// Detect: import { Title, Meta } from '@angular/platform-browser'
|
|
67
|
+
ImportDeclaration(node) {
|
|
68
|
+
const decl = /** @type {any} */ (node);
|
|
69
|
+
if (decl.source.value !== '@angular/platform-browser') return;
|
|
70
|
+
|
|
71
|
+
for (const specifier of decl.specifiers) {
|
|
72
|
+
if (specifier.type !== 'ImportSpecifier') continue;
|
|
73
|
+
const imported = specifier.imported.name ?? specifier.imported.value;
|
|
74
|
+
if (imported === 'Title') context.report({ node: specifier, messageId: 'noImportTitle' });
|
|
75
|
+
if (imported === 'Meta') context.report({ node: specifier, messageId: 'noImportMeta' });
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
module.exports = rule;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows direct DOM manipulation methods. Use injectRenderer() from @ethlete/core instead.
|
|
6
|
+
*
|
|
7
|
+
* injectRenderer() is a fully-typed wrapper around Angular's Renderer2 that:
|
|
8
|
+
* - Works with server-side rendering (Angular Universal / SSR)
|
|
9
|
+
* - Works inside Web Workers
|
|
10
|
+
* - Is mockable in unit tests
|
|
11
|
+
* - Respects Angular's change detection lifecycle
|
|
12
|
+
*
|
|
13
|
+
* BAD:
|
|
14
|
+
* this.document.createElement('div') → renderer.createElement('div')
|
|
15
|
+
* element.appendChild(child) → renderer.appendChild(element, child)
|
|
16
|
+
* element.setAttribute('disabled', '') → renderer.setAttribute(element, 'disabled', '')
|
|
17
|
+
* element.classList.add('active') → renderer.addClass(element, 'active')
|
|
18
|
+
* element.classList.remove('active') → renderer.removeClass(element, 'active')
|
|
19
|
+
* element.style.color = 'red' → renderer.setStyle(element, 'color', 'red')
|
|
20
|
+
*
|
|
21
|
+
* GOOD:
|
|
22
|
+
* private renderer = injectRenderer(); // from @ethlete/core
|
|
23
|
+
*
|
|
24
|
+
* NOTE: Calls where the receiver name contains 'renderer' (e.g. this.renderer.createElement)
|
|
25
|
+
* are excluded — that is the correct injectRenderer() usage.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* DOM creation methods and their Renderer2 equivalents.
|
|
30
|
+
* @type {Map<string, string>}
|
|
31
|
+
*/
|
|
32
|
+
const DOM_CREATE_METHODS = new Map([
|
|
33
|
+
['createElement', 'renderer.createElement(tag, namespace?)'],
|
|
34
|
+
['createTextNode', 'renderer.createText(value)'],
|
|
35
|
+
['createComment', 'renderer.createComment(value)'],
|
|
36
|
+
['createDocumentFragment', "renderer.createElement('div') and compose child nodes"],
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* DOM mutation methods and their Renderer2 equivalents.
|
|
41
|
+
* @type {Map<string, string>}
|
|
42
|
+
*/
|
|
43
|
+
const DOM_MUTATION_METHODS = new Map([
|
|
44
|
+
['appendChild', 'renderer.appendChild(parent, child)'],
|
|
45
|
+
['removeChild', 'renderer.removeChild(parent, child)'],
|
|
46
|
+
['insertBefore', 'renderer.insertBefore(parent, child, refNode)'],
|
|
47
|
+
['replaceChild', 'renderer.insertBefore(parent, newChild, refNode) + renderer.removeChild(parent, oldChild)'],
|
|
48
|
+
['setAttribute', 'renderer.setAttribute(el, name, value)'],
|
|
49
|
+
['removeAttribute', 'renderer.removeAttribute(el, name)'],
|
|
50
|
+
['toggleAttribute', 'renderer.setAttribute / renderer.removeAttribute with a condition'],
|
|
51
|
+
['setProperty', 'renderer.setProperty(el, name, value)'],
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* classList methods and their Renderer2 equivalents.
|
|
56
|
+
* @type {Map<string, string>}
|
|
57
|
+
*/
|
|
58
|
+
const CLASSLIST_METHODS = new Map([
|
|
59
|
+
['add', 'renderer.addClass(el, className)'],
|
|
60
|
+
['remove', 'renderer.removeClass(el, className)'],
|
|
61
|
+
['toggle', 'renderer.addClass / renderer.removeClass with a condition'],
|
|
62
|
+
['replace', 'renderer.removeClass(el, old) + renderer.addClass(el, new)'],
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Returns true when the receiver of a MemberExpression looks like a Renderer2 instance,
|
|
67
|
+
* which means the call is already using the correct API.
|
|
68
|
+
* @param {import('eslint').Rule.Node} objectNode
|
|
69
|
+
* @param {import('eslint').Rule.RuleContext} context
|
|
70
|
+
*/
|
|
71
|
+
const isRendererReceiver = (objectNode, context) => {
|
|
72
|
+
const receiverText = context.sourceCode.getText(objectNode).toLowerCase();
|
|
73
|
+
return receiverText.includes('renderer');
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
77
|
+
const noDirectDomManipulation = {
|
|
78
|
+
meta: {
|
|
79
|
+
type: 'suggestion',
|
|
80
|
+
docs: {
|
|
81
|
+
description: "Disallow direct DOM manipulation. Use injectRenderer() from '@ethlete/core' instead.",
|
|
82
|
+
recommended: true,
|
|
83
|
+
},
|
|
84
|
+
messages: {
|
|
85
|
+
domCreate: "Use '{{alternative}}' instead of '.{{method}}()'. Use injectRenderer() from '@ethlete/core'.",
|
|
86
|
+
domMutation: "Use '{{alternative}}' instead of '.{{method}}()'. Use injectRenderer() from '@ethlete/core'.",
|
|
87
|
+
domClassList:
|
|
88
|
+
"Use '{{alternative}}' instead of '.classList.{{method}}()'. Use injectRenderer() from '@ethlete/core'.",
|
|
89
|
+
domStyle:
|
|
90
|
+
"Use 'renderer.setStyle(el, prop, value)' instead of direct style property assignment. Use injectRenderer() from '@ethlete/core'.",
|
|
91
|
+
},
|
|
92
|
+
schema: [],
|
|
93
|
+
},
|
|
94
|
+
create(context) {
|
|
95
|
+
return {
|
|
96
|
+
CallExpression(node) {
|
|
97
|
+
const { callee } = node;
|
|
98
|
+
if (callee.type !== 'MemberExpression') return;
|
|
99
|
+
if (callee.property.type !== 'Identifier') return;
|
|
100
|
+
|
|
101
|
+
const methodName = callee.property.name;
|
|
102
|
+
|
|
103
|
+
// ── classList.add / .remove / .toggle / .replace ─────────────────────
|
|
104
|
+
if (
|
|
105
|
+
callee.object.type === 'MemberExpression' &&
|
|
106
|
+
callee.object.property.type === 'Identifier' &&
|
|
107
|
+
callee.object.property.name === 'classList'
|
|
108
|
+
) {
|
|
109
|
+
const alternative = CLASSLIST_METHODS.get(methodName);
|
|
110
|
+
if (alternative) {
|
|
111
|
+
context.report({
|
|
112
|
+
node,
|
|
113
|
+
messageId: 'domClassList',
|
|
114
|
+
data: { method: methodName, alternative },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Skip calls on a Renderer2 instance — those are the correct pattern.
|
|
121
|
+
if (isRendererReceiver(callee.object, context)) return;
|
|
122
|
+
|
|
123
|
+
// ── DOM creation ──────────────────────────────────────────────────────
|
|
124
|
+
if (DOM_CREATE_METHODS.has(methodName)) {
|
|
125
|
+
context.report({
|
|
126
|
+
node,
|
|
127
|
+
messageId: 'domCreate',
|
|
128
|
+
data: { method: methodName, alternative: DOM_CREATE_METHODS.get(methodName) },
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── DOM mutation ──────────────────────────────────────────────────────
|
|
134
|
+
if (DOM_MUTATION_METHODS.has(methodName)) {
|
|
135
|
+
context.report({
|
|
136
|
+
node,
|
|
137
|
+
messageId: 'domMutation',
|
|
138
|
+
data: { method: methodName, alternative: DOM_MUTATION_METHODS.get(methodName) },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// ── el.style.prop = value ─────────────────────────────────────────────
|
|
144
|
+
AssignmentExpression(node) {
|
|
145
|
+
const { left } = node;
|
|
146
|
+
if (
|
|
147
|
+
left.type === 'MemberExpression' &&
|
|
148
|
+
left.object.type === 'MemberExpression' &&
|
|
149
|
+
left.object.property.type === 'Identifier' &&
|
|
150
|
+
left.object.property.name === 'style' &&
|
|
151
|
+
!isRendererReceiver(left.object.object, context)
|
|
152
|
+
) {
|
|
153
|
+
context.report({ node, messageId: 'domStyle' });
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
module.exports = noDirectDomManipulation;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows reading or writing 'document.cookie' directly.
|
|
6
|
+
*
|
|
7
|
+
* Direct access to document.cookie:
|
|
8
|
+
* - Returns / writes a raw semicolon-delimited string that is easy to misparse
|
|
9
|
+
* - Bypasses SSR guards (throws in non-browser environments)
|
|
10
|
+
* - Has no expiry, domain, path, or SameSite handling by default
|
|
11
|
+
*
|
|
12
|
+
* BAD:
|
|
13
|
+
* document.cookie // read
|
|
14
|
+
* document.cookie = 'name=value; path=/'; // write
|
|
15
|
+
*
|
|
16
|
+
* GOOD:
|
|
17
|
+
* import { getCookie, setCookie, hasCookie, deleteCookie } from '@ethlete/core';
|
|
18
|
+
* setCookie('name', 'value');
|
|
19
|
+
* getCookie('name');
|
|
20
|
+
* hasCookie('name');
|
|
21
|
+
* deleteCookie('name');
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
25
|
+
const noDocumentCookie = {
|
|
26
|
+
meta: {
|
|
27
|
+
type: 'suggestion',
|
|
28
|
+
docs: {
|
|
29
|
+
description:
|
|
30
|
+
"Disallow reading or writing 'document.cookie' directly. Use the cookie utilities from '@ethlete/core' instead.",
|
|
31
|
+
recommended: true,
|
|
32
|
+
},
|
|
33
|
+
messages: {
|
|
34
|
+
noDocumentCookie:
|
|
35
|
+
"Do not access 'document.cookie' directly. Use 'getCookie()', 'setCookie()', 'hasCookie()', or 'deleteCookie()' from '@ethlete/core' — they handle SSR, expiry, domain, path, and SameSite attributes safely.",
|
|
36
|
+
},
|
|
37
|
+
schema: [],
|
|
38
|
+
},
|
|
39
|
+
create(context) {
|
|
40
|
+
return {
|
|
41
|
+
MemberExpression(node) {
|
|
42
|
+
if (
|
|
43
|
+
node.object.type === 'Identifier' &&
|
|
44
|
+
node.object.name === 'document' &&
|
|
45
|
+
node.property.type === 'Identifier' &&
|
|
46
|
+
node.property.name === 'cookie'
|
|
47
|
+
) {
|
|
48
|
+
context.report({ node, messageId: 'noDocumentCookie' });
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
module.exports = noDocumentCookie;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows direct DOM query methods. Use Angular's viewChild() / viewChildren() signals instead.
|
|
6
|
+
*
|
|
7
|
+
* Direct DOM query methods bypass Angular's component model, break SSR, and are not declarative.
|
|
8
|
+
* Angular provides first-class query APIs that are reactive and work within the component lifecycle.
|
|
9
|
+
*
|
|
10
|
+
* BAD:
|
|
11
|
+
* host.querySelector('[data-id]') → viewChild('[data-id]')
|
|
12
|
+
* host.querySelectorAll('.item') → viewChildren('.item')
|
|
13
|
+
* document.getElementById('my-id') → viewChild('#my-id')
|
|
14
|
+
* el.getElementsByClassName('foo') → viewChildren('.foo')
|
|
15
|
+
* el.closest('.parent') → inject(ElementRef) + traverse via Angular DI
|
|
16
|
+
*
|
|
17
|
+
* GOOD:
|
|
18
|
+
* label = viewChild('labelRef'); // template: <span #labelRef>
|
|
19
|
+
* items = viewChildren(ItemDirective); // template: <et-item *ngFor="..." />
|
|
20
|
+
* children = signalElementChildren(elementRef); // from @ethlete/core — reactive signal of child HTMLElements
|
|
21
|
+
*
|
|
22
|
+
* NOTE: In the rare case where a query is genuinely needed (e.g. third-party DOM),
|
|
23
|
+
* suppress with a targeted eslint-disable comment and document why.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The banned DOM query method names and their Angular alternatives.
|
|
28
|
+
* @type {Map<string, string>}
|
|
29
|
+
*/
|
|
30
|
+
const DOM_QUERY_METHODS = new Map([
|
|
31
|
+
['querySelector', 'viewChild() with a template reference variable, or signalElementChildren() from @ethlete/core'],
|
|
32
|
+
[
|
|
33
|
+
'querySelectorAll',
|
|
34
|
+
'viewChildren() / contentChildren() with a directive token, or signalElementChildren() from @ethlete/core for raw HTMLElement children',
|
|
35
|
+
],
|
|
36
|
+
['getElementById', 'viewChild() with a template reference variable (use #id on the element)'],
|
|
37
|
+
['getElementsByClassName', 'viewChildren() with a directive token, or signalElementChildren() from @ethlete/core'],
|
|
38
|
+
[
|
|
39
|
+
'getElementsByTagName',
|
|
40
|
+
'viewChildren() with a directive / component token, or signalElementChildren() from @ethlete/core',
|
|
41
|
+
],
|
|
42
|
+
['getElementsByName', 'viewChildren() with a template reference variable'],
|
|
43
|
+
['closest', 'inject(ParentDirective, { optional: true }) via Angular DI instead of DOM traversal'],
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
47
|
+
const noDomQuery = {
|
|
48
|
+
meta: {
|
|
49
|
+
type: 'suggestion',
|
|
50
|
+
docs: {
|
|
51
|
+
description: "Disallow direct DOM query methods. Use Angular's viewChild() / viewChildren() signals instead.",
|
|
52
|
+
recommended: true,
|
|
53
|
+
},
|
|
54
|
+
messages: {
|
|
55
|
+
noDomQuery:
|
|
56
|
+
"Avoid '{{method}}()'. Use {{alternative}} instead. Direct DOM queries bypass Angular's component model and break SSR.",
|
|
57
|
+
},
|
|
58
|
+
schema: [],
|
|
59
|
+
},
|
|
60
|
+
create(context) {
|
|
61
|
+
return {
|
|
62
|
+
CallExpression(node) {
|
|
63
|
+
const { callee } = node;
|
|
64
|
+
if (callee.type !== 'MemberExpression') return;
|
|
65
|
+
if (callee.property.type !== 'Identifier') return;
|
|
66
|
+
|
|
67
|
+
const methodName = callee.property.name;
|
|
68
|
+
const alternative = DOM_QUERY_METHODS.get(methodName);
|
|
69
|
+
if (!alternative) return;
|
|
70
|
+
|
|
71
|
+
context.report({
|
|
72
|
+
node,
|
|
73
|
+
messageId: 'noDomQuery',
|
|
74
|
+
data: { method: methodName, alternative },
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
module.exports = noDomQuery;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** @typedef {'Component' | 'Directive'} TDecoratorName */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import('estree').Property['key']} key
|
|
8
|
+
*/
|
|
9
|
+
const getPropertyName = (key) => {
|
|
10
|
+
if (key.type === 'Identifier') return key.name;
|
|
11
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
12
|
+
return null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {import('eslint').Rule.Node} node
|
|
17
|
+
* @returns {TDecoratorName | null}
|
|
18
|
+
*/
|
|
19
|
+
const getDecoratorName = (node) => {
|
|
20
|
+
const decorator = /** @type {any} */ (node);
|
|
21
|
+
if (decorator.type !== 'Decorator') return null;
|
|
22
|
+
|
|
23
|
+
const expression = decorator.expression;
|
|
24
|
+
if (expression.type === 'CallExpression') {
|
|
25
|
+
return expression.callee.type === 'Identifier' ? expression.callee.name : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return expression.type === 'Identifier' ? expression.name : null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
33
|
+
* @param {any} metadata
|
|
34
|
+
* @param {any} property
|
|
35
|
+
*/
|
|
36
|
+
const getRemovalRange = (sourceCode, metadata, property) => {
|
|
37
|
+
const properties = metadata.properties.filter((entry) => entry.type === 'Property');
|
|
38
|
+
const propertyIndex = properties.indexOf(property);
|
|
39
|
+
const openingBrace = sourceCode.getFirstToken(metadata);
|
|
40
|
+
const closingBrace = sourceCode.getLastToken(metadata);
|
|
41
|
+
|
|
42
|
+
if (!openingBrace || !closingBrace) return property.range;
|
|
43
|
+
if (properties.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
|
|
44
|
+
if (propertyIndex < properties.length - 1) return [property.range[0], properties[propertyIndex + 1].range[0]];
|
|
45
|
+
|
|
46
|
+
return [properties[propertyIndex - 1].range[1], property.range[1]];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const MESSAGE_IDS = {
|
|
50
|
+
hostDirectives: 'noEmptyHostDirectives',
|
|
51
|
+
imports: 'noEmptyImports',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
55
|
+
const noEmptyAngularMetadataArrays = {
|
|
56
|
+
meta: {
|
|
57
|
+
type: 'suggestion',
|
|
58
|
+
docs: {
|
|
59
|
+
description: 'Disallow empty Angular metadata arrays such as imports: [] and hostDirectives: [].',
|
|
60
|
+
},
|
|
61
|
+
fixable: 'code',
|
|
62
|
+
schema: [],
|
|
63
|
+
messages: {
|
|
64
|
+
noEmptyImports: 'Remove empty imports: [] from Angular metadata.',
|
|
65
|
+
noEmptyHostDirectives: 'Remove empty hostDirectives: [] from Angular metadata.',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
create(context) {
|
|
69
|
+
const sourceCode = context.sourceCode;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
/** @param {import('eslint').Rule.Node} node */
|
|
73
|
+
Decorator(node) {
|
|
74
|
+
const decoratorName = getDecoratorName(node);
|
|
75
|
+
if (decoratorName !== 'Component' && decoratorName !== 'Directive') return;
|
|
76
|
+
|
|
77
|
+
const decorator = /** @type {any} */ (node);
|
|
78
|
+
const expression = decorator.expression;
|
|
79
|
+
if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
|
|
80
|
+
|
|
81
|
+
const metadata = expression.arguments[0];
|
|
82
|
+
if (!metadata || metadata.type !== 'ObjectExpression') return;
|
|
83
|
+
|
|
84
|
+
for (const property of metadata.properties) {
|
|
85
|
+
if (property.type !== 'Property') continue;
|
|
86
|
+
|
|
87
|
+
const propertyName = getPropertyName(property.key);
|
|
88
|
+
if (propertyName !== 'imports' && propertyName !== 'hostDirectives') continue;
|
|
89
|
+
if (property.value.type !== 'ArrayExpression' || property.value.elements.length > 0) continue;
|
|
90
|
+
|
|
91
|
+
context.report({
|
|
92
|
+
node: property,
|
|
93
|
+
messageId: MESSAGE_IDS[propertyName],
|
|
94
|
+
fix(fixer) {
|
|
95
|
+
return fixer.replaceTextRange(getRemovalRange(sourceCode, metadata, property), '');
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
module.exports = noEmptyAngularMetadataArrays;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
5
|
+
const noEmptyNewlinesBetweenImports = {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'layout',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'Disallow empty blank lines between consecutive import declarations.',
|
|
10
|
+
},
|
|
11
|
+
fixable: 'code',
|
|
12
|
+
schema: [],
|
|
13
|
+
messages: {
|
|
14
|
+
noEmptyLine: 'Remove empty blank lines between import declarations.',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
create(context) {
|
|
18
|
+
const sourceCode = context.sourceCode;
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
Program(node) {
|
|
22
|
+
const importDeclarations = node.body.filter((statement) => statement.type === 'ImportDeclaration');
|
|
23
|
+
|
|
24
|
+
for (let index = 1; index < importDeclarations.length; index += 1) {
|
|
25
|
+
const previousImport = importDeclarations[index - 1];
|
|
26
|
+
const currentImport = importDeclarations[index];
|
|
27
|
+
const textBetween = sourceCode.text.slice(previousImport.range[1], currentImport.range[0]);
|
|
28
|
+
|
|
29
|
+
if (/\S/.test(textBetween)) continue;
|
|
30
|
+
if (!/\n\s*\n/.test(textBetween)) continue;
|
|
31
|
+
|
|
32
|
+
context.report({
|
|
33
|
+
node: currentImport,
|
|
34
|
+
messageId: 'noEmptyLine',
|
|
35
|
+
fix(fixer) {
|
|
36
|
+
return fixer.replaceTextRange([previousImport.range[1], currentImport.range[0]], '\n');
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
module.exports = noEmptyNewlinesBetweenImports;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
5
|
+
const noInjectChain = {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'suggestion',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'Disallow chaining member access directly off inject() calls.',
|
|
10
|
+
recommended: true,
|
|
11
|
+
},
|
|
12
|
+
messages: {
|
|
13
|
+
noChain:
|
|
14
|
+
'Do not chain off inject(). Assign to a const first: `const x = inject({{token}});` then use `x.{{member}}`.',
|
|
15
|
+
},
|
|
16
|
+
schema: [],
|
|
17
|
+
},
|
|
18
|
+
create(context) {
|
|
19
|
+
return {
|
|
20
|
+
MemberExpression(node) {
|
|
21
|
+
const obj = node.object;
|
|
22
|
+
if (obj.type === 'CallExpression' && obj.callee.type === 'Identifier' && obj.callee.name === 'inject') {
|
|
23
|
+
// Allow inject(X).method() — immediately-invoked patterns like
|
|
24
|
+
// inject(DestroyRef).onDestroy(...) are intentional Angular idioms.
|
|
25
|
+
const parent = node.parent;
|
|
26
|
+
if (parent && parent.type === 'CallExpression' && parent.callee === node) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const token = obj.arguments[0]?.type === 'Identifier' ? obj.arguments[0].name : '...';
|
|
31
|
+
const member = node.property.type === 'Identifier' ? node.property.name : '...';
|
|
32
|
+
|
|
33
|
+
context.report({
|
|
34
|
+
node,
|
|
35
|
+
messageId: 'noChain',
|
|
36
|
+
data: { token, member },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
module.exports = noInjectChain;
|