@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.
- package/CHANGELOG.md +6 -0
- package/package.json +3 -4
- package/src/configs/recommended.js +362 -0
- package/src/index.js +116 -0
- package/src/rules/angular-decorator-property-order.js +217 -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 +286 -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-inject-chain.js +44 -0
- package/src/rules/no-legacy-angular-decorators.js +210 -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 +39 -0
- package/src/rules/no-readonly-signal.js +119 -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-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-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-view-encapsulation-none.js +101 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows injecting Angular's ActivatedRoute (fully replaced),
|
|
6
|
+
* and disallows reading route state directly from an injected Router instance.
|
|
7
|
+
*
|
|
8
|
+
* inject(Router) is allowed — it is still needed for navigation (.navigate(),
|
|
9
|
+
* .navigateByUrl(), .createUrlTree(), etc.).
|
|
10
|
+
* Reading state properties off it is NOT allowed — use the @ethlete/core signal
|
|
11
|
+
* utilities instead.
|
|
12
|
+
*
|
|
13
|
+
* ❌ ActivatedRoute (fully replaced):
|
|
14
|
+
* inject(ActivatedRoute)
|
|
15
|
+
* import { ActivatedRoute } from '@angular/router';
|
|
16
|
+
*
|
|
17
|
+
* ❌ Router state property reads:
|
|
18
|
+
* this.router.url → injectUrl()
|
|
19
|
+
* this.router.events → injectRouterEvent()
|
|
20
|
+
* this.router.routerState → injectRouterState()
|
|
21
|
+
* this.router.snapshot → injectRouterState()
|
|
22
|
+
* this.router.lastSuccessfulNavigation → injectRouterState()
|
|
23
|
+
* this.router.currentNavigation → injectRouterState()
|
|
24
|
+
*
|
|
25
|
+
* ✅ inject(Router) is still fine for navigation:
|
|
26
|
+
* router.navigate(['/path'])
|
|
27
|
+
* router.navigateByUrl('/path')
|
|
28
|
+
* router.createUrlTree(['/path'])
|
|
29
|
+
*
|
|
30
|
+
* ✅ Use the @ethlete/core router utilities for state:
|
|
31
|
+
* injectUrl(), injectRoute(), injectRouterEvent(), injectRouterState(),
|
|
32
|
+
* injectIsRouterInitialized(), injectQueryParam(key), injectPathParam(key),
|
|
33
|
+
* injectQueryParams(), injectPathParams(), injectFragment(),
|
|
34
|
+
* injectRouteData(), injectRouteDataItem(key), injectRouteTitle(),
|
|
35
|
+
* injectQueryParamChanges(), injectPathParamChanges()
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Router properties that expose route state and must not be read directly.
|
|
40
|
+
* @type {Map<string, string>}
|
|
41
|
+
*/
|
|
42
|
+
const ROUTER_STATE_PROPS = new Map([
|
|
43
|
+
['url', "injectUrl() from '@ethlete/core'"],
|
|
44
|
+
['events', "injectRouterEvent() from '@ethlete/core'"],
|
|
45
|
+
['routerState', "injectRouterState() from '@ethlete/core'"],
|
|
46
|
+
['snapshot', "injectRouterState() from '@ethlete/core'"],
|
|
47
|
+
['lastSuccessfulNavigation', "injectRouterState() from '@ethlete/core'"],
|
|
48
|
+
['currentNavigation', "injectRouterState() from '@ethlete/core'"],
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
52
|
+
const noAngularRouterApi = {
|
|
53
|
+
meta: {
|
|
54
|
+
type: 'suggestion',
|
|
55
|
+
docs: {
|
|
56
|
+
description:
|
|
57
|
+
"Disallow injecting 'ActivatedRoute' (fully replaced) and reading state properties off an injected 'Router'.",
|
|
58
|
+
recommended: true,
|
|
59
|
+
},
|
|
60
|
+
messages: {
|
|
61
|
+
noActivatedRoute:
|
|
62
|
+
"Do not inject 'ActivatedRoute'. Use reactive signal utilities from '@ethlete/core' instead: 'injectQueryParam(key)', 'injectPathParam(key)', 'injectQueryParams()', 'injectPathParams()', 'injectFragment()', 'injectRouteData()', 'injectRouteDataItem(key)', or 'injectRouteTitle()'. All return reactive signals and are SSR-safe.",
|
|
63
|
+
noRouterStateProp:
|
|
64
|
+
"Do not read 'router.{{prop}}' directly. Use {{replacement}} instead — it returns a reactive signal and is SSR-safe. Use inject(Router) only for navigation (.navigate(), .navigateByUrl()).",
|
|
65
|
+
},
|
|
66
|
+
schema: [],
|
|
67
|
+
},
|
|
68
|
+
create(context) {
|
|
69
|
+
/**
|
|
70
|
+
* Names of variables/properties assigned from inject(Router).
|
|
71
|
+
* We track identifiers whose initializer is inject(Router) so we can
|
|
72
|
+
* detect property access on them anywhere in the file.
|
|
73
|
+
* @type {Set<string>}
|
|
74
|
+
*/
|
|
75
|
+
const routerBindings = new Set();
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
// ── Track inject(Router) bindings ──────────────────────────────────────
|
|
79
|
+
// const router = inject(Router);
|
|
80
|
+
VariableDeclarator(node) {
|
|
81
|
+
if (
|
|
82
|
+
node.init?.type === 'CallExpression' &&
|
|
83
|
+
node.init.callee.type === 'Identifier' &&
|
|
84
|
+
node.init.callee.name === 'inject' &&
|
|
85
|
+
node.init.arguments[0]?.type === 'Identifier' &&
|
|
86
|
+
node.init.arguments[0].name === 'Router' &&
|
|
87
|
+
node.id.type === 'Identifier'
|
|
88
|
+
) {
|
|
89
|
+
routerBindings.add(node.id.name);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
// ── Track class property assignments: router = inject(Router) ──────────
|
|
94
|
+
PropertyDefinition(node) {
|
|
95
|
+
if (
|
|
96
|
+
node.value?.type === 'CallExpression' &&
|
|
97
|
+
node.value.callee.type === 'Identifier' &&
|
|
98
|
+
node.value.callee.name === 'inject' &&
|
|
99
|
+
node.value.arguments[0]?.type === 'Identifier' &&
|
|
100
|
+
node.value.arguments[0].name === 'Router' &&
|
|
101
|
+
node.key.type === 'Identifier'
|
|
102
|
+
) {
|
|
103
|
+
routerBindings.add(node.key.name);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
// ── Detect router.{stateProp} access ───────────────────────────────────
|
|
108
|
+
MemberExpression(node) {
|
|
109
|
+
if (node.property.type !== 'Identifier') return;
|
|
110
|
+
const prop = node.property.name;
|
|
111
|
+
if (!ROUTER_STATE_PROPS.has(prop)) return;
|
|
112
|
+
|
|
113
|
+
// Check object is a known router binding (direct: router.url)
|
|
114
|
+
// or a this.router.url / self.router.url pattern
|
|
115
|
+
const obj = node.object;
|
|
116
|
+
const isDirectBinding = obj.type === 'Identifier' && routerBindings.has(obj.name);
|
|
117
|
+
const isThisBinding =
|
|
118
|
+
obj.type === 'MemberExpression' &&
|
|
119
|
+
obj.object.type === 'ThisExpression' &&
|
|
120
|
+
obj.property.type === 'Identifier' &&
|
|
121
|
+
routerBindings.has(obj.property.name);
|
|
122
|
+
|
|
123
|
+
if (!isDirectBinding && !isThisBinding) return;
|
|
124
|
+
|
|
125
|
+
context.report({
|
|
126
|
+
node,
|
|
127
|
+
messageId: 'noRouterStateProp',
|
|
128
|
+
data: { prop, replacement: ROUTER_STATE_PROPS.get(prop) },
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
// ── ActivatedRoute import ───────────────────────────────────────────────
|
|
133
|
+
ImportDeclaration(node) {
|
|
134
|
+
if (node.source.value !== '@angular/router') return;
|
|
135
|
+
for (const specifier of node.specifiers) {
|
|
136
|
+
if (specifier.type !== 'ImportSpecifier') continue;
|
|
137
|
+
if (specifier.imported.name === 'ActivatedRoute') {
|
|
138
|
+
context.report({ node, messageId: 'noActivatedRoute' });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
// ── inject(ActivatedRoute) ──────────────────────────────────────────────
|
|
145
|
+
CallExpression(node) {
|
|
146
|
+
const { callee } = node;
|
|
147
|
+
if (callee.type !== 'Identifier' || callee.name !== 'inject') return;
|
|
148
|
+
const arg = node.arguments[0];
|
|
149
|
+
if (arg?.type !== 'Identifier') return;
|
|
150
|
+
if (arg.name === 'ActivatedRoute') {
|
|
151
|
+
context.report({ node, messageId: 'noActivatedRoute' });
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
module.exports = noAngularRouterApi;
|
|
@@ -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,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;
|