@ethlete/eslint-plugin 0.1.0-next.0 → 0.1.0-next.2
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 +12 -0
- package/package.json +3 -4
- package/src/configs/recommended.js +380 -0
- package/src/index.js +126 -0
- package/src/rules/angular-decorator-property-order.js +205 -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 +304 -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 +50 -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-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-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 +271 -0
- package/src/rules/require-view-encapsulation-none.js +264 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Prefers signalElementScrollState / signalHostElementScrollState from @ethlete/core
|
|
6
|
+
* over imperatively reading scroll position properties inside reactive contexts,
|
|
7
|
+
* and disallows adding raw scroll event listeners in favour of the same signal utilities.
|
|
8
|
+
*
|
|
9
|
+
* The signal utilities:
|
|
10
|
+
* - Set up a scroll event listener that keeps a reactive signal in sync
|
|
11
|
+
* - Clean up automatically when the component is destroyed
|
|
12
|
+
* - Provide debouncing and direction tracking built in
|
|
13
|
+
*
|
|
14
|
+
* NOTE: The property-read check is a WARNING — imperative one-shot reads of
|
|
15
|
+
* scrollTop/scrollLeft are fine in event handlers and animations; only flag
|
|
16
|
+
* inside effect() / computed().
|
|
17
|
+
*
|
|
18
|
+
* BAD (reactive context):
|
|
19
|
+
* effect(() => {
|
|
20
|
+
* const top = this.el.nativeElement.scrollTop; // stale snapshot ❌
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* BAD (scroll listeners — always wrong):
|
|
24
|
+
* el.addEventListener('scroll', fn); // ❌
|
|
25
|
+
* fromEvent(el, 'scroll').subscribe(fn); // ❌
|
|
26
|
+
* el.onscroll = fn; // ❌
|
|
27
|
+
*
|
|
28
|
+
* GOOD:
|
|
29
|
+
* scrollState = signalHostElementScrollState(); // from @ethlete/core
|
|
30
|
+
* scrollDir = signalHostElementLastScrollDirection(); // from @ethlete/core
|
|
31
|
+
* // Or use <et-scrollable> / ScrollableComponent for list scrolling
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Scroll position properties read from DOM elements. */
|
|
35
|
+
const SCROLL_PROPS = new Set(['scrollTop', 'scrollLeft', 'scrollY', 'scrollX']);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns the name of the wrapping reactive context ('effect' | 'computed') if
|
|
39
|
+
* the node is inside one, crossing at least one function boundary.
|
|
40
|
+
* @param {import('eslint').Rule.Node} node
|
|
41
|
+
* @returns {string | null}
|
|
42
|
+
*/
|
|
43
|
+
const getReactiveContext = (node) => {
|
|
44
|
+
let crossedFunctionBoundary = false;
|
|
45
|
+
let current = node.parent;
|
|
46
|
+
|
|
47
|
+
while (current) {
|
|
48
|
+
if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
|
|
49
|
+
crossedFunctionBoundary = true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (
|
|
53
|
+
crossedFunctionBoundary &&
|
|
54
|
+
current.type === 'CallExpression' &&
|
|
55
|
+
current.callee.type === 'Identifier' &&
|
|
56
|
+
(current.callee.name === 'effect' || current.callee.name === 'computed')
|
|
57
|
+
) {
|
|
58
|
+
return current.callee.name;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
current = current.parent;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
68
|
+
const preferScrollState = {
|
|
69
|
+
meta: {
|
|
70
|
+
type: 'suggestion',
|
|
71
|
+
docs: {
|
|
72
|
+
description:
|
|
73
|
+
"Prefer 'signalElementScrollState()' / 'signalHostElementScrollState()' from '@ethlete/core' over reading scroll position properties inside reactive contexts.",
|
|
74
|
+
recommended: true,
|
|
75
|
+
},
|
|
76
|
+
messages: {
|
|
77
|
+
preferScrollState:
|
|
78
|
+
"Avoid reading '{{prop}}' inside {{context}}() — it creates a stale snapshot. Use 'signalElementScrollState(elementRef)' or 'signalHostElementScrollState()' from '@ethlete/core' instead. For scroll direction, use 'signalElementLastScrollDirection()' / 'signalHostElementLastScrollDirection()'.",
|
|
79
|
+
noScrollListener:
|
|
80
|
+
"Do not add 'scroll' event listeners directly. Use 'signalElementScrollState(elementRef)' / 'signalHostElementScrollState()' for scroll position or state, 'signalElementLastScrollDirection(elementRef)' / 'signalHostElementLastScrollDirection()' for scroll direction, or the 'ScrollableComponent' (et-scrollable) from '@ethlete/cdk' for scrollable lists. All are imported from '@ethlete/core' or '@ethlete/cdk'.",
|
|
81
|
+
},
|
|
82
|
+
schema: [],
|
|
83
|
+
},
|
|
84
|
+
create(context) {
|
|
85
|
+
return {
|
|
86
|
+
CallExpression(node) {
|
|
87
|
+
const { callee } = node;
|
|
88
|
+
const args = node.arguments;
|
|
89
|
+
|
|
90
|
+
// ── el.addEventListener('scroll', fn) / window.addEventListener('scroll', fn) ──
|
|
91
|
+
if (
|
|
92
|
+
callee.type === 'MemberExpression' &&
|
|
93
|
+
callee.property.type === 'Identifier' &&
|
|
94
|
+
callee.property.name === 'addEventListener' &&
|
|
95
|
+
args[0]?.type === 'Literal' &&
|
|
96
|
+
args[0].value === 'scroll'
|
|
97
|
+
) {
|
|
98
|
+
context.report({ node, messageId: 'noScrollListener' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── fromEvent(el, 'scroll') ──────────────────────────────────────────
|
|
103
|
+
if (
|
|
104
|
+
callee.type === 'Identifier' &&
|
|
105
|
+
callee.name === 'fromEvent' &&
|
|
106
|
+
args[1]?.type === 'Literal' &&
|
|
107
|
+
args[1].value === 'scroll'
|
|
108
|
+
) {
|
|
109
|
+
context.report({ node, messageId: 'noScrollListener' });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── renderer.listen(el, 'scroll', fn) ───────────────────────────────
|
|
114
|
+
if (
|
|
115
|
+
callee.type === 'MemberExpression' &&
|
|
116
|
+
callee.property.type === 'Identifier' &&
|
|
117
|
+
callee.property.name === 'listen' &&
|
|
118
|
+
args[1]?.type === 'Literal' &&
|
|
119
|
+
args[1].value === 'scroll'
|
|
120
|
+
) {
|
|
121
|
+
context.report({ node, messageId: 'noScrollListener' });
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
AssignmentExpression(node) {
|
|
126
|
+
// ── el.onscroll = fn ────────────────────────────────────────────────
|
|
127
|
+
const { left } = node;
|
|
128
|
+
if (
|
|
129
|
+
left.type === 'MemberExpression' &&
|
|
130
|
+
left.property.type === 'Identifier' &&
|
|
131
|
+
left.property.name === 'onscroll'
|
|
132
|
+
) {
|
|
133
|
+
context.report({ node, messageId: 'noScrollListener' });
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
MemberExpression(node) {
|
|
138
|
+
if (node.property.type !== 'Identifier') return;
|
|
139
|
+
if (!SCROLL_PROPS.has(node.property.name)) return;
|
|
140
|
+
|
|
141
|
+
// Only flag reads (not assignments like el.scrollTop = 0).
|
|
142
|
+
const parent = node.parent;
|
|
143
|
+
if (parent && parent.type === 'AssignmentExpression' && parent.left === node) return;
|
|
144
|
+
|
|
145
|
+
const reactiveCtx = getReactiveContext(node);
|
|
146
|
+
if (!reactiveCtx) return;
|
|
147
|
+
|
|
148
|
+
context.report({
|
|
149
|
+
node,
|
|
150
|
+
messageId: 'preferScrollState',
|
|
151
|
+
data: { prop: node.property.name, context: reactiveCtx },
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
module.exports = preferScrollState;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Prefers injectViewportSize() from @ethlete/core over reading raw viewport
|
|
6
|
+
* dimensions from the global window object.
|
|
7
|
+
*
|
|
8
|
+
* injectViewportSize() returns a reactive signal that:
|
|
9
|
+
* - Updates automatically when the viewport is resized
|
|
10
|
+
* - Works on the server (SSR-safe)
|
|
11
|
+
* - Participates in Angular's change detection
|
|
12
|
+
*
|
|
13
|
+
* BAD:
|
|
14
|
+
* const w = window.innerWidth;
|
|
15
|
+
* document.defaultView?.innerHeight
|
|
16
|
+
*
|
|
17
|
+
* GOOD:
|
|
18
|
+
* const viewport = injectViewportSize(); // from @ethlete/core
|
|
19
|
+
* const w = viewport().width;
|
|
20
|
+
* const h = viewport().height;
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Properties on window that report viewport dimensions. */
|
|
24
|
+
const VIEWPORT_PROPERTIES = new Set([
|
|
25
|
+
'innerWidth',
|
|
26
|
+
'innerHeight',
|
|
27
|
+
'outerWidth',
|
|
28
|
+
'outerHeight',
|
|
29
|
+
'screen', // window.screen.width / window.screen.height
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
33
|
+
const preferViewportSize = {
|
|
34
|
+
meta: {
|
|
35
|
+
type: 'suggestion',
|
|
36
|
+
docs: {
|
|
37
|
+
description: "Prefer 'injectViewportSize()' from '@ethlete/core' over direct window dimension properties.",
|
|
38
|
+
recommended: true,
|
|
39
|
+
},
|
|
40
|
+
messages: {
|
|
41
|
+
preferViewportSize:
|
|
42
|
+
"Avoid reading '{{prop}}' directly from window. Use 'injectViewportSize()' from '@ethlete/core' — it returns a reactive signal that stays up to date as the viewport resizes.",
|
|
43
|
+
},
|
|
44
|
+
schema: [],
|
|
45
|
+
},
|
|
46
|
+
create(context) {
|
|
47
|
+
return {
|
|
48
|
+
MemberExpression(node) {
|
|
49
|
+
if (node.property.type !== 'Identifier') return;
|
|
50
|
+
if (!VIEWPORT_PROPERTIES.has(node.property.name)) return;
|
|
51
|
+
|
|
52
|
+
// Only flag when the object is clearly a window-like reference.
|
|
53
|
+
const obj = node.object;
|
|
54
|
+
const objText = context.sourceCode.getText(obj).toLowerCase();
|
|
55
|
+
|
|
56
|
+
const isWindowRef =
|
|
57
|
+
objText === 'window' ||
|
|
58
|
+
objText.endsWith('.defaultview') ||
|
|
59
|
+
objText.endsWith('?.defaultview') ||
|
|
60
|
+
// document.defaultView without optional chain
|
|
61
|
+
/defaultview$/.test(objText);
|
|
62
|
+
|
|
63
|
+
if (!isWindowRef) return;
|
|
64
|
+
|
|
65
|
+
context.report({
|
|
66
|
+
node,
|
|
67
|
+
messageId: 'preferViewportSize',
|
|
68
|
+
data: { prop: node.property.name },
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
module.exports = preferViewportSize;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Requires observable variables and class properties to be suffixed with `$`.
|
|
6
|
+
*
|
|
7
|
+
* Detection is heuristic — a binding is assumed to be an Observable if its
|
|
8
|
+
* initializer matches one of:
|
|
9
|
+
* - a `.pipe(...)` call (most reliable signal)
|
|
10
|
+
* - `new Subject / BehaviorSubject / ReplaySubject / AsyncSubject / Observable()`
|
|
11
|
+
* - a call to a known RxJS creator: from, of, interval, timer, fromEvent,
|
|
12
|
+
* fromEventPattern, combineLatest, merge, concat, forkJoin, race, zip,
|
|
13
|
+
* defer, iif, throwError, using, partition
|
|
14
|
+
*
|
|
15
|
+
* ❌ const myData = someObs.pipe(map(...));
|
|
16
|
+
* ❌ const clicks = fromEvent(el, 'click');
|
|
17
|
+
* ❌ class Foo { stream = this.src.pipe(filter(...)); }
|
|
18
|
+
*
|
|
19
|
+
* ✅ const myData$ = someObs.pipe(map(...));
|
|
20
|
+
* ✅ const clicks$ = fromEvent(el, 'click');
|
|
21
|
+
* ✅ class Foo { stream$ = this.src.pipe(filter(...)); }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const RXJS_CREATORS = new Set([
|
|
25
|
+
'from',
|
|
26
|
+
'of',
|
|
27
|
+
'interval',
|
|
28
|
+
'timer',
|
|
29
|
+
'fromEvent',
|
|
30
|
+
'fromEventPattern',
|
|
31
|
+
'combineLatest',
|
|
32
|
+
'merge',
|
|
33
|
+
'concat',
|
|
34
|
+
'forkJoin',
|
|
35
|
+
'race',
|
|
36
|
+
'zip',
|
|
37
|
+
'defer',
|
|
38
|
+
'iif',
|
|
39
|
+
'throwError',
|
|
40
|
+
'using',
|
|
41
|
+
'partition',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const RXJS_SUBJECT_CTORS = new Set(['Subject', 'BehaviorSubject', 'ReplaySubject', 'AsyncSubject', 'Observable']);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns true if the expression looks like an Observable.
|
|
48
|
+
* @param {any} node
|
|
49
|
+
*/
|
|
50
|
+
const isObservableInit = (node) => {
|
|
51
|
+
if (!node) return false;
|
|
52
|
+
|
|
53
|
+
// someObs.pipe(...)
|
|
54
|
+
if (
|
|
55
|
+
node.type === 'CallExpression' &&
|
|
56
|
+
node.callee.type === 'MemberExpression' &&
|
|
57
|
+
node.callee.property.type === 'Identifier' &&
|
|
58
|
+
node.callee.property.name === 'pipe'
|
|
59
|
+
) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// from(...), of(...), interval(...), etc.
|
|
64
|
+
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && RXJS_CREATORS.has(node.callee.name)) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// new Subject(), new BehaviorSubject(...), etc.
|
|
69
|
+
if (node.type === 'NewExpression' && node.callee.type === 'Identifier' && RXJS_SUBJECT_CTORS.has(node.callee.name)) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return false;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
77
|
+
const rule = {
|
|
78
|
+
meta: {
|
|
79
|
+
type: 'suggestion',
|
|
80
|
+
docs: {
|
|
81
|
+
description: 'Require observable variables and class properties to use the $ suffix.',
|
|
82
|
+
},
|
|
83
|
+
schema: [],
|
|
84
|
+
messages: {
|
|
85
|
+
missingSuffix: "Observable '{{ name }}' must end with '$' (e.g. '{{ name }}$').",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
create(context) {
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} name
|
|
91
|
+
* @param {any} reportNode
|
|
92
|
+
* @param {any} initNode
|
|
93
|
+
*/
|
|
94
|
+
const checkBinding = (name, reportNode, initNode) => {
|
|
95
|
+
if (!isObservableInit(initNode)) return;
|
|
96
|
+
if (name.endsWith('$')) return;
|
|
97
|
+
|
|
98
|
+
context.report({
|
|
99
|
+
node: reportNode,
|
|
100
|
+
messageId: 'missingSuffix',
|
|
101
|
+
data: { name },
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
// const myObs = ...
|
|
107
|
+
VariableDeclarator(node) {
|
|
108
|
+
const n = /** @type {any} */ (node);
|
|
109
|
+
if (n.id.type !== 'Identifier') return;
|
|
110
|
+
checkBinding(n.id.name, n.id, n.init);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
// class Foo { myObs = ... }
|
|
114
|
+
PropertyDefinition(node) {
|
|
115
|
+
const n = /** @type {any} */ (node);
|
|
116
|
+
if (n.key.type !== 'Identifier') return;
|
|
117
|
+
checkBinding(n.key.name, n.key, n.value);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
module.exports = rule;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const COMPONENT_ORDER = ['selector', 'template', 'styleUrl', 'encapsulation', 'changeDetection'];
|
|
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 {string | null} propertyName
|
|
17
|
+
*/
|
|
18
|
+
const getOrderKey = (propertyName) => {
|
|
19
|
+
if (propertyName === 'template' || propertyName === 'templateUrl') return 'template';
|
|
20
|
+
if (propertyName === 'styleUrl' || propertyName === 'styleUrls') return 'styleUrl';
|
|
21
|
+
return propertyName;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
26
|
+
* @param {import('eslint').AST.Token | import('eslint').Rule.Node} node
|
|
27
|
+
*/
|
|
28
|
+
const getIndent = (sourceCode, node) => {
|
|
29
|
+
if (!node.loc) return '';
|
|
30
|
+
|
|
31
|
+
const line = sourceCode.lines[node.loc.start.line - 1] ?? '';
|
|
32
|
+
return line.slice(0, node.loc.start.column);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
37
|
+
* @param {any} property
|
|
38
|
+
*/
|
|
39
|
+
const hasTrailingComma = (sourceCode, property) => {
|
|
40
|
+
const tokenAfter = sourceCode.getTokenAfter(property);
|
|
41
|
+
return Boolean(tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',');
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
46
|
+
*/
|
|
47
|
+
const findAngularCoreImport = (sourceCode) =>
|
|
48
|
+
sourceCode.ast.body.find(
|
|
49
|
+
(node) =>
|
|
50
|
+
node.type === 'ImportDeclaration' && node.source.type === 'Literal' && node.source.value === '@angular/core',
|
|
51
|
+
) ?? null;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
55
|
+
* @param {any} importNode
|
|
56
|
+
*/
|
|
57
|
+
const buildAngularCoreImportFix = (sourceCode, importNode) => {
|
|
58
|
+
const specifiers = importNode.specifiers ?? [];
|
|
59
|
+
const hasChangeDetectionStrategy = specifiers.some(
|
|
60
|
+
(specifier) =>
|
|
61
|
+
specifier.type === 'ImportSpecifier' &&
|
|
62
|
+
specifier.imported.type === 'Identifier' &&
|
|
63
|
+
specifier.imported.name === 'ChangeDetectionStrategy',
|
|
64
|
+
);
|
|
65
|
+
if (hasChangeDetectionStrategy) return null;
|
|
66
|
+
|
|
67
|
+
const defaultSpecifier = specifiers.find((specifier) => specifier.type === 'ImportDefaultSpecifier') ?? null;
|
|
68
|
+
const namespaceSpecifier = specifiers.find((specifier) => specifier.type === 'ImportNamespaceSpecifier') ?? null;
|
|
69
|
+
const namedSpecifiers = specifiers.filter((specifier) => specifier.type === 'ImportSpecifier');
|
|
70
|
+
|
|
71
|
+
if (namespaceSpecifier) {
|
|
72
|
+
return (fixer) => fixer.insertTextAfter(importNode, `\nimport { ChangeDetectionStrategy } from '@angular/core';`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const importParts = [];
|
|
76
|
+
if (defaultSpecifier) {
|
|
77
|
+
importParts.push(sourceCode.getText(defaultSpecifier));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const namedTexts = namedSpecifiers.map((specifier) => sourceCode.getText(specifier));
|
|
81
|
+
importParts.push(`{ ${[...namedTexts, 'ChangeDetectionStrategy'].join(', ')} }`);
|
|
82
|
+
|
|
83
|
+
return (fixer) => fixer.replaceText(importNode, `import ${importParts.join(', ')} from '@angular/core';`);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
88
|
+
*/
|
|
89
|
+
const buildMissingAngularCoreImportFix = (sourceCode) => {
|
|
90
|
+
const importDeclarations = sourceCode.ast.body.filter((node) => node.type === 'ImportDeclaration');
|
|
91
|
+
const lastImport = importDeclarations[importDeclarations.length - 1] ?? null;
|
|
92
|
+
|
|
93
|
+
return lastImport
|
|
94
|
+
? (fixer) => fixer.insertTextAfter(lastImport, `\nimport { ChangeDetectionStrategy } from '@angular/core';`)
|
|
95
|
+
: (fixer) =>
|
|
96
|
+
fixer.replaceTextRange(
|
|
97
|
+
[0, sourceCode.ast.range[0]],
|
|
98
|
+
`import { ChangeDetectionStrategy } from '@angular/core';\n\n`,
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {any} metadata
|
|
104
|
+
*/
|
|
105
|
+
const getChangeDetectionInsertionTarget = (metadata) => {
|
|
106
|
+
const changeDetectionIndex = COMPONENT_ORDER.indexOf('changeDetection');
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
metadata.properties.find((property) => {
|
|
110
|
+
if (property.type !== 'Property') return false;
|
|
111
|
+
|
|
112
|
+
const propertyName = getOrderKey(getPropertyName(property.key));
|
|
113
|
+
const propertyIndex = propertyName === null ? COMPONENT_ORDER.length : COMPONENT_ORDER.indexOf(propertyName);
|
|
114
|
+
return propertyIndex > changeDetectionIndex || propertyIndex === -1;
|
|
115
|
+
}) ?? null
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
121
|
+
* @param {any} metadata
|
|
122
|
+
*/
|
|
123
|
+
const buildMetadataFix = (sourceCode, metadata) => {
|
|
124
|
+
const changeDetectionText = 'changeDetection: ChangeDetectionStrategy.OnPush';
|
|
125
|
+
const properties = metadata.properties.filter((property) => property.type === 'Property');
|
|
126
|
+
const insertionTarget = getChangeDetectionInsertionTarget(metadata);
|
|
127
|
+
const isMultiline = Boolean(metadata.loc && metadata.loc.start.line !== metadata.loc.end.line);
|
|
128
|
+
|
|
129
|
+
if (properties.length === 0) {
|
|
130
|
+
return (fixer) => fixer.replaceText(metadata, `{ ${changeDetectionText} }`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (insertionTarget) {
|
|
134
|
+
if (isMultiline) {
|
|
135
|
+
const propertyIndent = getIndent(sourceCode, insertionTarget);
|
|
136
|
+
return (fixer) => fixer.insertTextBefore(insertionTarget, `${changeDetectionText},\n${propertyIndent}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return (fixer) => fixer.insertTextBefore(insertionTarget, `${changeDetectionText}, `);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lastProperty = properties[properties.length - 1];
|
|
143
|
+
const closingBrace = sourceCode.getLastToken(metadata);
|
|
144
|
+
if (!lastProperty || !closingBrace) return null;
|
|
145
|
+
|
|
146
|
+
if (isMultiline) {
|
|
147
|
+
const closingIndent = getIndent(sourceCode, closingBrace);
|
|
148
|
+
const propertyIndent = getIndent(sourceCode, lastProperty);
|
|
149
|
+
const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
|
|
150
|
+
const rangeStart =
|
|
151
|
+
separator === ''
|
|
152
|
+
? (sourceCode.getTokenAfter(lastProperty)?.range[1] ?? lastProperty.range[1])
|
|
153
|
+
: lastProperty.range[1];
|
|
154
|
+
|
|
155
|
+
return (fixer) =>
|
|
156
|
+
fixer.replaceTextRange(
|
|
157
|
+
[rangeStart, closingBrace.range[0]],
|
|
158
|
+
`${separator}\n${propertyIndent}${changeDetectionText}\n${closingIndent}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
|
|
163
|
+
return (fixer) =>
|
|
164
|
+
fixer.replaceTextRange([lastProperty.range[1], closingBrace.range[0]], `${separator} ${changeDetectionText} `);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
169
|
+
* @param {any} metadata
|
|
170
|
+
* @param {any} valueNode
|
|
171
|
+
*/
|
|
172
|
+
const buildFix = (sourceCode, metadata, valueNode) => {
|
|
173
|
+
const fixes = [];
|
|
174
|
+
const angularCoreImport = findAngularCoreImport(sourceCode);
|
|
175
|
+
const importFix = angularCoreImport
|
|
176
|
+
? buildAngularCoreImportFix(sourceCode, angularCoreImport)
|
|
177
|
+
: buildMissingAngularCoreImportFix(sourceCode);
|
|
178
|
+
|
|
179
|
+
if (importFix) fixes.push(importFix);
|
|
180
|
+
|
|
181
|
+
if (valueNode) {
|
|
182
|
+
fixes.push((fixer) => fixer.replaceText(valueNode, 'ChangeDetectionStrategy.OnPush'));
|
|
183
|
+
} else {
|
|
184
|
+
const metadataFix = buildMetadataFix(sourceCode, metadata);
|
|
185
|
+
if (metadataFix) fixes.push(metadataFix);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return (fixer) => fixes.map((applyFix) => applyFix(fixer));
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
192
|
+
const requireOnPushChangeDetection = {
|
|
193
|
+
meta: {
|
|
194
|
+
type: 'suggestion',
|
|
195
|
+
docs: {
|
|
196
|
+
description: 'Require `changeDetection: ChangeDetectionStrategy.OnPush` in all @Component decorators.',
|
|
197
|
+
},
|
|
198
|
+
fixable: 'code',
|
|
199
|
+
messages: {
|
|
200
|
+
missing:
|
|
201
|
+
'Add `changeDetection: ChangeDetectionStrategy.OnPush` to @Component. Default change detection is not allowed.',
|
|
202
|
+
notOnPush: '`changeDetection` must be `ChangeDetectionStrategy.OnPush`. Got `ChangeDetectionStrategy.{{value}}`.',
|
|
203
|
+
},
|
|
204
|
+
schema: [],
|
|
205
|
+
},
|
|
206
|
+
create(context) {
|
|
207
|
+
const sourceCode = context.sourceCode;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @param {import('eslint').Rule.Node} node
|
|
211
|
+
*/
|
|
212
|
+
const isComponentDecorator = (node) => {
|
|
213
|
+
const decorator = /** @type {any} */ (node);
|
|
214
|
+
if (decorator.type !== 'Decorator') return false;
|
|
215
|
+
|
|
216
|
+
const expression = decorator.expression;
|
|
217
|
+
if (expression.type === 'CallExpression') {
|
|
218
|
+
return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return expression.type === 'Identifier' && expression.name === 'Component';
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
Decorator(node) {
|
|
226
|
+
if (!isComponentDecorator(node)) return;
|
|
227
|
+
|
|
228
|
+
const decorator = /** @type {any} */ (node);
|
|
229
|
+
const expression = decorator.expression;
|
|
230
|
+
if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
|
|
231
|
+
|
|
232
|
+
const metadata = expression.arguments[0];
|
|
233
|
+
if (!metadata || metadata.type !== 'ObjectExpression') return;
|
|
234
|
+
|
|
235
|
+
const changeDetectionProp = metadata.properties.find(
|
|
236
|
+
(property) =>
|
|
237
|
+
property.type === 'Property' &&
|
|
238
|
+
property.key.type === 'Identifier' &&
|
|
239
|
+
property.key.name === 'changeDetection',
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
if (!changeDetectionProp) {
|
|
243
|
+
context.report({
|
|
244
|
+
node: metadata,
|
|
245
|
+
messageId: 'missing',
|
|
246
|
+
fix: buildFix(sourceCode, metadata, null),
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const value = changeDetectionProp.value;
|
|
252
|
+
if (
|
|
253
|
+
value.type === 'MemberExpression' &&
|
|
254
|
+
value.object.type === 'Identifier' &&
|
|
255
|
+
value.object.name === 'ChangeDetectionStrategy' &&
|
|
256
|
+
value.property.type === 'Identifier' &&
|
|
257
|
+
value.property.name !== 'OnPush'
|
|
258
|
+
) {
|
|
259
|
+
context.report({
|
|
260
|
+
node: value,
|
|
261
|
+
messageId: 'notOnPush',
|
|
262
|
+
data: { value: value.property.name },
|
|
263
|
+
fix: buildFix(sourceCode, metadata, value),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
module.exports = requireOnPushChangeDetection;
|