@simbiat/eslint-plugin-simbiat 1.0.0

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.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * @file Rule: simbiat/prefer-field-initializer.
3
+ * Flags `this.x = expr` assignments in a class `constructor` where all the following hold:
4
+ * 1. `x` already has a class field declaration (PropertyDefinition).
5
+ * 2. The RHS does NOT reference a constructor parameter by name because those values are unavailable at a declaration site.
6
+ * 3. The RHS does NOT reference a local variable declared inside the constructor body because those values are also unavailable at the declaration site.
7
+ * 4. The RHS does NOT contain `this.anything` – field-initializer ordering versus constructor-assignment ordering can differ subtly, so those are left for the developer to evaluate.
8
+ * Only top-level assignment statements in the constructor body are checked.
9
+ * Assignments inside if / else / for / for-of / for-in / while / do-while / switch / try-catch blocks, or inside nested functions / arrow functions, are intentionally ignored – they are conditional or deferred and cannot be safely lifted to a field initializer.
10
+ * No auto-fix is provided: the change involves removing the assignment AND updating the field declaration simultaneously; doing that incorrectly could silently break the program.
11
+ */
12
+ import { adaptNodeHandler, adaptStateHandler } from '../utils/Adapters.mjs';
13
+ import { collectFieldNames, collectParamNames, collectLocalNames, containsThisAccess, containsIdentifierRef, } from '../utils/ASTHelpers.mjs';
14
+ // Stack helpers
15
+ /**
16
+ * `True` only when the top of the stack is a constructor-method entry with no
17
+ * intervening 'block' or 'fn' markers.
18
+ * @param stack - Current stack array.
19
+ * @returns True when the top stack entry is a constructor method.
20
+ */
21
+ function isDirectlyInMethod(stack) {
22
+ return stack[stack.length - 1]?.kind === 'method';
23
+ }
24
+ /**
25
+ * Returns the field-name set of the class that owns the current method.
26
+ * @param stack - Current stack array.
27
+ * @returns Set of field names from the nearest enclosing class entry.
28
+ */
29
+ function currentFieldNames(stack) {
30
+ for (let i = stack.length - 1; i >= 0; i--) {
31
+ // eslint-disable-next-line security/detect-object-injection
32
+ const entry = stack[i];
33
+ if (entry?.kind === 'class') {
34
+ return entry.fieldNames;
35
+ }
36
+ }
37
+ return new Set();
38
+ }
39
+ // Visitor handlers
40
+ /**
41
+ * Pushes a 'class' entry when entering a class.
42
+ * @param state - Rule state.
43
+ * @param node - Class declaration or expression node (as unknown from ESLint).
44
+ */
45
+ function onClass(state, node) {
46
+ state.stack.push({
47
+ kind: 'class',
48
+ fieldNames: collectFieldNames(node),
49
+ node,
50
+ });
51
+ }
52
+ /**
53
+ * Pops the 'class' entry when leaving a class.
54
+ * @param state - Rule state.
55
+ */
56
+ function onClassExit(state) {
57
+ if (state.stack[state.stack.length - 1]?.kind === 'class') {
58
+ state.stack.pop();
59
+ }
60
+ }
61
+ /**
62
+ * Pushes a 'method' entry when entering a constructor MethodDefinition.
63
+ * @param state - Rule state.
64
+ * @param node - MethodDefinition node (as unknown from ESLint).
65
+ */
66
+ function onMethodDefinition(state, node) {
67
+ const method = node;
68
+ const top = state.stack[state.stack.length - 1];
69
+ if (top?.kind !== 'class') {
70
+ return;
71
+ }
72
+ if (top.node !== method.parent?.parent) {
73
+ return;
74
+ }
75
+ if (method.kind !== 'constructor') {
76
+ return;
77
+ }
78
+ // Collect constructor parameter names.
79
+ const param_names = new Set();
80
+ for (const param of method.value.params) {
81
+ collectParamNames(param, param_names);
82
+ }
83
+ // Pre-scan the entire constructor body for local variable names.
84
+ const local_names = new Set();
85
+ collectLocalNames(method.value.body, local_names);
86
+ state.stack.push({
87
+ kind: 'method',
88
+ methodName: 'constructor',
89
+ paramNames: param_names,
90
+ localNames: local_names,
91
+ });
92
+ }
93
+ /**
94
+ * Pops the 'method' entry when leaving a MethodDefinition.
95
+ * @param state - Rule state.
96
+ */
97
+ function onMethodDefinitionExit(state) {
98
+ if (state.stack[state.stack.length - 1]?.kind === 'method') {
99
+ state.stack.pop();
100
+ }
101
+ }
102
+ /**
103
+ * Pushes a 'block' marker when entering any control-flow statement.
104
+ * @param state - Rule state.
105
+ */
106
+ function onControlFlowEnter(state) {
107
+ const top = state.stack[state.stack.length - 1];
108
+ if (top?.kind === 'method' || top?.kind === 'block') {
109
+ state.stack.push({ kind: 'block' });
110
+ }
111
+ }
112
+ /**
113
+ * Pops the 'block' marker when leaving a control-flow statement.
114
+ * @param state - Rule state.
115
+ */
116
+ function onControlFlowExit(state) {
117
+ if (state.stack[state.stack.length - 1]?.kind === 'block') {
118
+ state.stack.pop();
119
+ }
120
+ }
121
+ /**
122
+ * Pushes "fn" marker when entering a FunctionExpression that is not a method.
123
+ * @param state - Rule state.
124
+ * @param node - FunctionExpression node (as unknown from ESLint).
125
+ */
126
+ function onFunctionExpression(state, node) {
127
+ const fn = node;
128
+ if (fn.parent?.type === 'MethodDefinition') {
129
+ return;
130
+ }
131
+ const top = state.stack[state.stack.length - 1];
132
+ if (top?.kind === 'method' || top?.kind === 'fn' || top?.kind === 'block') {
133
+ state.stack.push({ kind: 'fn' });
134
+ }
135
+ }
136
+ /**
137
+ * Pops the 'fn' marker when leaving a FunctionExpression.
138
+ * @param state - Rule state.
139
+ * @param node - FunctionExpression node (as unknown from ESLint).
140
+ */
141
+ function onFunctionExpressionExit(state, node) {
142
+ const fn = node;
143
+ if (fn.parent?.type === 'MethodDefinition') {
144
+ return;
145
+ }
146
+ if (state.stack[state.stack.length - 1]?.kind === 'fn') {
147
+ state.stack.pop();
148
+ }
149
+ }
150
+ /**
151
+ * Pushes "fn" marker when entering an ArrowFunctionExpression inside a method.
152
+ * @param state - Rule state.
153
+ */
154
+ function onArrowFunctionExpression(state) {
155
+ const top = state.stack[state.stack.length - 1];
156
+ if (top?.kind === 'method' || top?.kind === 'fn' || top?.kind === 'block') {
157
+ state.stack.push({ kind: 'fn' });
158
+ }
159
+ }
160
+ /**
161
+ * Pops the 'fn' marker when leaving an ArrowFunctionExpression.
162
+ * @param state - Rule state.
163
+ */
164
+ function onArrowFunctionExpressionExit(state) {
165
+ if (state.stack[state.stack.length - 1]?.kind === 'fn') {
166
+ state.stack.pop();
167
+ }
168
+ }
169
+ /**
170
+ * Reports `this.x = expr` assignments where x is a declared field
171
+ * whose initializer could be moved to the field declaration.
172
+ * @param state - Rule state.
173
+ * @param node - AssignmentExpression node to inspect (as unknown from ESLint).
174
+ */
175
+ function onAssignmentExpression(state, node) {
176
+ if (!isDirectlyInMethod(state.stack)) {
177
+ return;
178
+ }
179
+ const assign = node;
180
+ if (assign.operator !== '=') {
181
+ return;
182
+ }
183
+ const { left, right, } = assign;
184
+ if (left.type !== 'MemberExpression') {
185
+ return;
186
+ }
187
+ if (left.object.type !== 'ThisExpression') {
188
+ return;
189
+ }
190
+ if (left.property.type !== 'Identifier') {
191
+ return;
192
+ }
193
+ if (left.computed) {
194
+ return;
195
+ }
196
+ const prop_name = left.property.name;
197
+ if (!currentFieldNames(state.stack)
198
+ .has(prop_name)) {
199
+ return;
200
+ }
201
+ if (containsThisAccess(right)) {
202
+ return;
203
+ }
204
+ const method_state = state.stack[state.stack.length - 1];
205
+ if (method_state?.kind !== 'method') {
206
+ return;
207
+ }
208
+ if (containsIdentifierRef(right, method_state.paramNames)) {
209
+ return;
210
+ }
211
+ if (containsIdentifierRef(right, method_state.localNames)) {
212
+ return;
213
+ }
214
+ state.context.report({
215
+ node: node,
216
+ messageId: 'preferInitializer',
217
+ data: {
218
+ name: prop_name,
219
+ method: method_state.methodName,
220
+ },
221
+ });
222
+ }
223
+ // Rule definition
224
+ /**
225
+ * Stack entry shapes:
226
+ * { kind: 'class', fieldNames: Set<string>, node: ClassNode }
227
+ * { kind: 'method', methodName: string, paramNames: Set<string>, localNames: Set<string> }
228
+ * { kind: 'block' } ← inside a control-flow statement (if/for/while/switch/try)
229
+ * { kind: 'fn' } ← inside a nested function or arrow function.
230
+ */
231
+ const preferFieldInitializer = {
232
+ meta: {
233
+ type: 'suggestion',
234
+ docs: {
235
+ description: 'Suggest moving this.x = … assignments in constructor to class field initializers.',
236
+ },
237
+ messages: {
238
+ preferInitializer: '\'{{name}}\' is declared as a class field. Move its initializer to the field declaration instead of assigning it in {{method}}.',
239
+ },
240
+ schema: [],
241
+ hasSuggestions: false,
242
+ },
243
+ /**
244
+ * Create rule.
245
+ * @param context - Context to process.
246
+ */
247
+ create(context) {
248
+ const state = {
249
+ context,
250
+ stack: [],
251
+ };
252
+ // Control-flow statement types whose bodies must not be treated as
253
+ // top-level constructor statements.
254
+ const cf_enter = adaptStateHandler(state, onControlFlowEnter);
255
+ const cf_exit = adaptStateHandler(state, onControlFlowExit);
256
+ return {
257
+ 'ClassDeclaration': adaptNodeHandler(state, onClass),
258
+ 'ClassExpression': adaptNodeHandler(state, onClass),
259
+ 'ClassDeclaration:exit': adaptStateHandler(state, onClassExit),
260
+ 'ClassExpression:exit': adaptStateHandler(state, onClassExit),
261
+ 'MethodDefinition': adaptNodeHandler(state, onMethodDefinition),
262
+ 'MethodDefinition:exit': adaptStateHandler(state, onMethodDefinitionExit),
263
+ // Control-flow blocks – push 'block' so assignments inside are ignored.
264
+ 'IfStatement': cf_enter,
265
+ 'IfStatement:exit': cf_exit,
266
+ 'ForStatement': cf_enter,
267
+ 'ForStatement:exit': cf_exit,
268
+ 'ForInStatement': cf_enter,
269
+ 'ForInStatement:exit': cf_exit,
270
+ 'ForOfStatement': cf_enter,
271
+ 'ForOfStatement:exit': cf_exit,
272
+ 'WhileStatement': cf_enter,
273
+ 'WhileStatement:exit': cf_exit,
274
+ 'DoWhileStatement': cf_enter,
275
+ 'DoWhileStatement:exit': cf_exit,
276
+ 'SwitchStatement': cf_enter,
277
+ 'SwitchStatement:exit': cf_exit,
278
+ 'TryStatement': cf_enter,
279
+ 'TryStatement:exit': cf_exit,
280
+ // Nested functions / arrows – push 'fn' to suppress entirely.
281
+ 'FunctionExpression': adaptNodeHandler(state, onFunctionExpression),
282
+ 'FunctionExpression:exit': adaptNodeHandler(state, onFunctionExpressionExit),
283
+ 'ArrowFunctionExpression': adaptStateHandler(state, onArrowFunctionExpression),
284
+ 'ArrowFunctionExpression:exit': adaptStateHandler(state, onArrowFunctionExpressionExit),
285
+ 'AssignmentExpression': adaptNodeHandler(state, onAssignmentExpression),
286
+ };
287
+ },
288
+ };
289
+ export default preferFieldInitializer;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @file Rule: simbiat/require-listener-cleanup.
3
+ *
4
+ * Verifies that every `addEventListener` call on an external target inside
5
+ * `connectedCallback` of an HTMLElement subclass has a matching
6
+ * `removeEventListener` call in `disconnectedCallback`.
7
+ *
8
+ * "External targets" are: document, window, `document.body`,
9
+ * document.documentElement, document.head.
10
+ * (Self-listeners on `this` and shadow-root listeners are not checked.)
11
+ *
12
+ * Three problems are reported (five messageIds total):
13
+ *
14
+ * Inline handler (inlineHandler): An arrow function or function expression passed directly cannot be referenced in removeEventListener, so the listener will always leak.
15
+ *
16
+ * Bound handler on `add` (boundHandler): every .bind() call returns a fresh function object, so the reference stored by the browser can never be matched later.
17
+ *
18
+ * No matching removal (notRemoved): an addEventListener call whose combination of target + event-type + handler reference has no corresponding removeEventListener call in disconnectedCallback. Autofix is available when the handler is a class field reference (this.foo / this.#foo).
19
+ *
20
+ * Dynamic event type (dynamicType): a non-literal event type cannot be statically matched.
21
+ *
22
+ * Bound handler on removal (boundRemoval): `.bind()` in removeEventListener creates a new reference that will not match the originally registered listener.
23
+ *
24
+ * Options: baseClasses: string[] – additional class names to treat as HTMLElement. Defaults to ['HTMLElement'].
25
+ */
26
+ import type { Rule } from 'eslint';
27
+ declare const requireListenerCleanup: Rule.RuleModule;
28
+ export default requireListenerCleanup;
@@ -0,0 +1,361 @@
1
+ /**
2
+ * @file Rule: simbiat/require-listener-cleanup.
3
+ *
4
+ * Verifies that every `addEventListener` call on an external target inside
5
+ * `connectedCallback` of an HTMLElement subclass has a matching
6
+ * `removeEventListener` call in `disconnectedCallback`.
7
+ *
8
+ * "External targets" are: document, window, `document.body`,
9
+ * document.documentElement, document.head.
10
+ * (Self-listeners on `this` and shadow-root listeners are not checked.)
11
+ *
12
+ * Three problems are reported (five messageIds total):
13
+ *
14
+ * Inline handler (inlineHandler): An arrow function or function expression passed directly cannot be referenced in removeEventListener, so the listener will always leak.
15
+ *
16
+ * Bound handler on `add` (boundHandler): every .bind() call returns a fresh function object, so the reference stored by the browser can never be matched later.
17
+ *
18
+ * No matching removal (notRemoved): an addEventListener call whose combination of target + event-type + handler reference has no corresponding removeEventListener call in disconnectedCallback. Autofix is available when the handler is a class field reference (this.foo / this.#foo).
19
+ *
20
+ * Dynamic event type (dynamicType): a non-literal event type cannot be statically matched.
21
+ *
22
+ * Bound handler on removal (boundRemoval): `.bind()` in removeEventListener creates a new reference that will not match the originally registered listener.
23
+ *
24
+ * Options: baseClasses: string[] – additional class names to treat as HTMLElement. Defaults to ['HTMLElement'].
25
+ */
26
+ import { adaptNodeHandler } from '../utils/Adapters.mjs';
27
+ import { isExternalTarget, targetName } from '../utils/ASTHelpers.mjs';
28
+ import { baseClassesSchema } from '../utils/CustomElementsScope.mjs';
29
+ // AST utilities
30
+ const SKIP_KEYS = new Set(['type', 'parent', 'loc', 'range', 'start', 'end']);
31
+ /**
32
+ * Walks an AST subtree, calling `visit` for every node, without descending
33
+ * into FunctionExpression / FunctionDeclaration / ArrowFunctionExpression
34
+ * boundaries. This is used to collect calls that execute directly (not
35
+ * inside a deferred callback).
36
+ * @param node - Root node to walk.
37
+ * @param visit - Callback invoked for every visited node.
38
+ */
39
+ function walkNoFunctions(node, visit) {
40
+ if (node === null || typeof node !== 'object') {
41
+ return;
42
+ }
43
+ const n = node;
44
+ if (n.type === 'FunctionExpression'
45
+ || n.type === 'FunctionDeclaration'
46
+ || n.type === 'ArrowFunctionExpression') {
47
+ return;
48
+ }
49
+ visit(n);
50
+ for (const [key, val] of Object.entries(n)) {
51
+ if (SKIP_KEYS.has(key)) {
52
+ continue;
53
+ }
54
+ if (Array.isArray(val)) {
55
+ for (const child of val) {
56
+ walkNoFunctions(child, visit);
57
+ }
58
+ }
59
+ else if (val !== null && typeof val === 'object' && typeof val.type === 'string') {
60
+ walkNoFunctions(val, visit);
61
+ }
62
+ }
63
+ }
64
+ // Target helpers
65
+ /**
66
+ * Returns true when the handler argument is an inline function that cannot be removed.
67
+ * @param node - Handler AST node to test.
68
+ * @returns True if the handler is an inline function or arrow function.
69
+ */
70
+ function isInlineHandler(node) {
71
+ return (node.type === 'FunctionExpression'
72
+ || node.type === 'ArrowFunctionExpression');
73
+ }
74
+ /**
75
+ * Returns true when the handler argument is a `.bind(…)` call.
76
+ * Every call to `.bind()` returns a *new* function object, so the reference
77
+ * passed to addEventListener can never equal the one passed to
78
+ * removeEventListener – even when the source text looks identical.
79
+ * @param node - Handler AST node to test.
80
+ * @returns True if the handler is a .bind() call expression.
81
+ */
82
+ function isBoundHandler(node) {
83
+ return (node.type === 'CallExpression'
84
+ && node.callee?.type === 'MemberExpression'
85
+ && node.callee.property?.type === 'Identifier'
86
+ && node.callee.property.name === 'bind');
87
+ }
88
+ /**
89
+ * Returns true when the handler is a member expression on `this` - i.e., a class
90
+ * field or method reference such as `this.onClick` or `this.#onClick`.
91
+ * These are stable references that can be passed identically to both
92
+ * addEventListener and removeEventListener, making autofix safe.
93
+ * @param node - Handler AST node to test.
94
+ * @returns True if the handler is a `this.foo` or `this.#foo` reference.
95
+ */
96
+ function isClassFieldHandler(node) {
97
+ return (node.type === 'MemberExpression'
98
+ && node.object?.type === 'ThisExpression');
99
+ }
100
+ // Call collection
101
+ /**
102
+ * Collects all addEventListener or removeEventListener calls on external
103
+ * targets from a list of statements, without descending into nested functions.
104
+ * @param statements - Array of statement nodes to scan.
105
+ * @param method_name - Either 'addEventListener' or 'removeEventListener'.
106
+ * @param source_code - ESLint SourceCode object for text retrieval.
107
+ * @returns Array of collected listener call descriptors.
108
+ */
109
+ function collectListenerCalls(statements, method_name, source_code) {
110
+ const calls = [];
111
+ for (const stmt of statements) {
112
+ walkNoFunctions(stmt, (node) => {
113
+ if (node.type !== 'CallExpression') {
114
+ return;
115
+ }
116
+ const { callee } = node;
117
+ if (callee?.type !== 'MemberExpression'
118
+ || callee.property?.type !== 'Identifier'
119
+ || callee.property.name !== method_name) {
120
+ return;
121
+ }
122
+ if (!isExternalTarget(callee.object)) {
123
+ return;
124
+ }
125
+ // Require at least (type, handler) arguments.
126
+ const type_arg = node.arguments?.[0];
127
+ const handler_arg = node.arguments?.[1];
128
+ if (typeof type_arg === 'undefined' || typeof handler_arg === 'undefined') {
129
+ return;
130
+ }
131
+ calls.push({
132
+ node,
133
+ target: targetName(callee.object, '(unknown)'),
134
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string -- Literal.value is always a primitive at runtime
135
+ event_type: type_arg.type === 'Literal' ? String(type_arg.value) : null,
136
+ handler_text: source_code.getText(handler_arg),
137
+ handler_node: handler_arg,
138
+ handler_inline: isInlineHandler(handler_arg),
139
+ handler_bound: isBoundHandler(handler_arg),
140
+ handler_is_field: isClassFieldHandler(handler_arg),
141
+ });
142
+ });
143
+ }
144
+ return calls;
145
+ }
146
+ // Class body inspection
147
+ /**
148
+ * Finds a named instance method in a class body and returns its
149
+ * MethodDefinition node, or null if not found.
150
+ * @param class_node - The class declaration or expression node.
151
+ * @param name - Name of the method to find.
152
+ * @returns The MethodDefinition node if found, otherwise null.
153
+ */
154
+ function findMethod(class_node, name) {
155
+ return (class_node.body?.body.find((member) => {
156
+ return member.type === 'MethodDefinition'
157
+ && member.kind === 'method'
158
+ && member.static !== true
159
+ && member.key?.type === 'Identifier'
160
+ && member.key.name === name;
161
+ }) ?? null);
162
+ }
163
+ /**
164
+ * Returns a fixer function that inserts the matching removeEventListener call
165
+ * into disconnectedCallback, creating the method if it does not yet exist.
166
+ * Only called when the handler is a class field reference (this.foo / this.#foo).
167
+ * @param add - Collected addEventListener call info.
168
+ * @param connected_method - MethodDefinition for connectedCallback.
169
+ * @param connected_stmts - Body statements of connectedCallback.
170
+ * @param disconnected_method - MethodDefinition for disconnectedCallback, or null.
171
+ * @param source_code - ESLint SourceCode object.
172
+ * @returns A fixer function to insert the removeEventListener call.
173
+ */
174
+ function buildRemovalFix(add, connected_method, connected_stmts, disconnected_method, source_code) {
175
+ return (fixer) => {
176
+ // Detect the line ending convention used in this file.
177
+ const eol = source_code.getText()
178
+ .includes('\r\n')
179
+ ? '\r\n'
180
+ : '\n';
181
+ const method_indent = ' '.repeat(connected_method.loc?.start.column ?? 0);
182
+ // Quote the event type safely.
183
+ const event_type = add.event_type ?? '';
184
+ const quoted_type = event_type.includes('\'')
185
+ ? `"${event_type}"`
186
+ : `'${event_type}'`;
187
+ const remove_stmt = `${add.target}.removeEventListener(${quoted_type}, ${add.handler_text});`;
188
+ if (disconnected_method !== null) {
189
+ // Infer body indent from disconnectedCallback's own statements, falling
190
+ // back to connectedCallback's body indent if the method is currently empty.
191
+ const dis_stmts = disconnected_method.value?.body?.body ?? [];
192
+ const ref_stmt = dis_stmts[0] ?? connected_stmts[0];
193
+ const body_indent = typeof ref_stmt?.loc === 'undefined'
194
+ ? `${method_indent} `
195
+ : ' '.repeat(ref_stmt.loc.start.column);
196
+ // Insert AFTER the previous token. Otherwise, steals the whitespace before curly brace.
197
+ const body_node = disconnected_method.value?.body;
198
+ const prev_token = source_code.getTokenBefore(source_code.getLastToken(body_node));
199
+ return fixer.insertTextAfter(prev_token, `${eol}${body_indent}${remove_stmt}`);
200
+ }
201
+ // No disconnectedCallback at all - infer body indent from connectedCallback
202
+ // and generate the entire method immediately after connectedCallback.
203
+ const body_indent = typeof connected_stmts[0]?.loc === 'undefined'
204
+ ? `${method_indent} `
205
+ : ' '.repeat(connected_stmts[0].loc.start.column);
206
+ return fixer.insertTextAfter(connected_method, `${eol}${eol}${method_indent}disconnectedCallback() {${eol}${body_indent}${remove_stmt}${eol}${method_indent}}`);
207
+ };
208
+ }
209
+ /**
210
+ * Checks an HTMLElement subclass for external addEventListener calls that are
211
+ * not cleaned up in disconnectedCallback.
212
+ * @param node - Class declaration or expression node to inspect.
213
+ * @param base_classes - List of base class names considered as HTMLElement.
214
+ * @param source_code - ESLint SourceCode object.
215
+ * @param context - ESLint rule context.
216
+ */
217
+ function checkClass(node, base_classes, source_code, context) {
218
+ // Only check classes that directly extend a known base class.
219
+ if (node.superClass?.type !== 'Identifier') {
220
+ return;
221
+ }
222
+ if (!base_classes.includes(node.superClass.name ?? '')) {
223
+ return;
224
+ }
225
+ const connected_method = findMethod(node, 'connectedCallback');
226
+ if (connected_method === null) {
227
+ return; // nothing in connectedCallback to check
228
+ }
229
+ const connected_stmts = connected_method.value?.body?.body ?? [];
230
+ const disconnected_method = findMethod(node, 'disconnectedCallback');
231
+ const disconnected_stmts = disconnected_method?.value?.body?.body ?? [];
232
+ const add_calls = collectListenerCalls(connected_stmts, 'addEventListener', source_code);
233
+ const rem_calls = collectListenerCalls(disconnected_stmts, 'removeEventListener', source_code);
234
+ for (const add of add_calls) {
235
+ // Inline handler: can never be removed
236
+ if (add.handler_inline) {
237
+ context.report({
238
+ node: add.node,
239
+ messageId: 'inlineHandler',
240
+ data: {
241
+ eventType: add.event_type ?? '(dynamic)',
242
+ target: add.target,
243
+ },
244
+ });
245
+ continue;
246
+ }
247
+ // Bound handler: .bind() always produces a new reference, so removal is impossible
248
+ if (add.handler_bound) {
249
+ context.report({
250
+ node: add.node,
251
+ messageId: 'boundHandler',
252
+ data: {
253
+ eventType: add.event_type ?? '(dynamic)',
254
+ target: add.target,
255
+ },
256
+ });
257
+ continue;
258
+ }
259
+ // Dynamic event type: cannot match statically
260
+ if (add.event_type === null) {
261
+ context.report({
262
+ node: add.node,
263
+ messageId: 'dynamicType',
264
+ data: { target: add.target },
265
+ });
266
+ continue;
267
+ }
268
+ // Check for matching removeEventListener.
269
+ // All three need to match: target, event type, and handler source text.
270
+ const matched_rem = rem_calls.find((rem) => {
271
+ return rem.target === add.target
272
+ && rem.event_type === add.event_type
273
+ && rem.handler_text === add.handler_text;
274
+ });
275
+ if (typeof matched_rem === 'undefined') {
276
+ context.report({
277
+ node: add.node,
278
+ messageId: 'notRemoved',
279
+ data: {
280
+ eventType: add.event_type,
281
+ target: add.target,
282
+ },
283
+ // Autofix is only safe when the handler is a stable `this.foo` /
284
+ // `this.#foo` reference.
285
+ fix: add.handler_is_field
286
+ ? buildRemovalFix(add, connected_method, connected_stmts, disconnected_method, source_code)
287
+ : null,
288
+ });
289
+ }
290
+ else if (matched_rem.handler_bound) {
291
+ // Text matched, but the removal uses .bind() – a new reference each time,
292
+ // so the original listener will never actually be removed.
293
+ context.report({
294
+ node: matched_rem.node,
295
+ messageId: 'boundRemoval',
296
+ data: {
297
+ eventType: add.event_type,
298
+ target: add.target,
299
+ },
300
+ });
301
+ }
302
+ }
303
+ }
304
+ // Rule visitor handler (top-level, adapted via adaptNodeHandler in `create`)
305
+ /**
306
+ * Top-level ESLint visitor for ClassDeclaration / ClassExpression exit nodes.
307
+ * @param state - Class check state with base classes, source code, and context.
308
+ * @param node - Class node from ESLint.
309
+ */
310
+ function onClassExit(state, node) {
311
+ checkClass(node, state.base_classes, state.source_code, state.context);
312
+ }
313
+ // Rule definition
314
+ const requireListenerCleanup = {
315
+ meta: {
316
+ type: 'suggestion',
317
+ docs: {
318
+ description: 'Require removeEventListener in disconnectedCallback for each addEventListener on external targets in connectedCallback.',
319
+ },
320
+ messages: {
321
+ inlineHandler: 'The \'{{eventType}}\' listener on {{target}} uses an inline function that can never be passed to removeEventListener – the listener will leak. '
322
+ + 'Store the handler as a class field (e.g. #handler = (e) => { … }) and remove it in disconnectedCallback.',
323
+ boundHandler: 'The \'{{eventType}}\' listener on {{target}} uses a .bind() call, which creates a new function reference each time – '
324
+ + 'it can never be matched by removeEventListener and will leak. '
325
+ + 'Store the bound handler as a class field (e.g. #handler = this.onEvent.bind(this)) and remove it in disconnectedCallback.',
326
+ boundRemoval: 'The \'{{eventType}}\' listener on {{target}} is removed with a .bind() call, which creates a new function reference and will not match '
327
+ + 'the originally added listener – the listener will leak. '
328
+ + 'Store the bound handler as a class field (e.g. #handler = this.onEvent.bind(this)) and pass that field to both addEventListener and removeEventListener.',
329
+ notRemoved: 'The \'{{eventType}}\' listener on {{target}} added in connectedCallback has no matching removeEventListener call in disconnectedCallback. '
330
+ + 'Add: {{target}}.removeEventListener(\'{{eventType}}\', <handler>) inside disconnectedCallback.',
331
+ dynamicType: 'A listener with a dynamic event type on {{target}} is added in connectedCallback. '
332
+ + 'Ensure a matching removeEventListener call with the same type and handler exists in disconnectedCallback.',
333
+ },
334
+ schema: baseClassesSchema,
335
+ fixable: 'code',
336
+ hasSuggestions: false,
337
+ },
338
+ /**
339
+ * Creates the rule listeners.
340
+ * @param context - ESLint rule context.
341
+ * @returns Rule listener object.
342
+ */
343
+ create(context) {
344
+ const options = context.options[0];
345
+ const base_classes = options?.baseClasses ?? ['HTMLElement'];
346
+ // `sourceCode` is the current API; fall back to the deprecated getter for
347
+ // older ESLint versions.
348
+ const legacy_context = context;
349
+ const source_code = context.sourceCode ?? legacy_context.getSourceCode();
350
+ const check_state = {
351
+ base_classes,
352
+ source_code,
353
+ context,
354
+ };
355
+ return {
356
+ 'ClassDeclaration:exit': adaptNodeHandler(check_state, onClassExit),
357
+ 'ClassExpression:exit': adaptNodeHandler(check_state, onClassExit),
358
+ };
359
+ },
360
+ };
361
+ export default requireListenerCleanup;