@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,348 @@
1
+ /**
2
+ * @file Rule: simbiat/no-forbidden-in-constructor.
3
+ *
4
+ * Flags everything the Custom Elements spec says you cannot or should not do
5
+ * during the construction phase — both in the constructor body and in instance
6
+ * field initializers (which run as part of construction, before the element
7
+ * is connected).
8
+ *
9
+ * Attribute / property writes: any `this.x = value` assignment where `x` is not declared as a class field (PropertyDefinition) in the current class body is flagged.
10
+ *
11
+ * DOMTokenList mutation: this.classList.add / remove / toggle / replace (…), this.part.add / remove / toggle / replace (…)
12
+ *
13
+ * Chained attribute / style writes: this.dataset.<key> = value / this.style.<prop> = value
14
+ *
15
+ * Method-based attribute manipulation: this.setAttribute(…) / this.toggleAttribute(…)
16
+ *
17
+ * Child / content access (reads and mutations via children-related properties
18
+ * and methods such as querySelector, appendChild, innerHTML, etc.)
19
+ *
20
+ * Forbidden global calls: document.write(…) / document.open(…)
21
+ *
22
+ * Illegal return (constructor only): any `return <expr>` that is not a bare `return` or `return this`.
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 { isActiveScope, getActiveScopeLocation, getClassFieldNames, buildScopeVisitors, baseClassesSchema, } from '../utils/CustomElementsScope.mjs';
28
+ // Static member sets
29
+ /**
30
+ * Reading any of these own-child properties is forbidden during construction
31
+ * (child elements are absent until the element is connected).
32
+ *
33
+ * These are READ checks via onMemberExpression. Assignments to them would
34
+ * also be caught by the "not a class field" write check, but we skip them
35
+ * there to avoid double reports.
36
+ */
37
+ const FORBIDDEN_CHILD_PROPS = new Set([
38
+ 'children',
39
+ 'childNodes',
40
+ 'firstChild',
41
+ 'lastChild',
42
+ 'firstElementChild',
43
+ 'lastElementChild',
44
+ 'childElementCount',
45
+ ]);
46
+ /**
47
+ * Calling any of these on `this` is forbidden during construction.
48
+ * Covers: child queries, all DOM-mutation methods that gain / rearrange /
49
+ * remove children, and ChildNode self-manipulation methods.
50
+ */
51
+ const FORBIDDEN_CHILD_METHODS = new Set([
52
+ // child queries
53
+ 'querySelector',
54
+ 'querySelectorAll',
55
+ 'getElementsByTagName',
56
+ 'getElementsByClassName',
57
+ 'getElementsByName',
58
+ // classic DOM mutation
59
+ 'appendChild',
60
+ 'insertBefore',
61
+ 'replaceChild',
62
+ 'removeChild',
63
+ // ParentNode / Element convenience
64
+ 'append',
65
+ 'prepend',
66
+ 'replaceChildren',
67
+ // adjacent insertion
68
+ 'insertAdjacentHTML',
69
+ 'insertAdjacentElement',
70
+ 'insertAdjacentText',
71
+ // ChildNode self-manipulation (no parent exists during construction)
72
+ 'after',
73
+ 'before',
74
+ 'replaceWith',
75
+ 'remove',
76
+ ]);
77
+ /** Calling these attribute-manipulation methods on `this` is forbidden. */
78
+ const FORBIDDEN_ATTR_METHODS = new Set(['setAttribute', 'toggleAttribute']);
79
+ /**
80
+ * Writing to any of these properties replaces or deeply modifies element
81
+ * content. Reported with a specific message rather than the generic one.
82
+ */
83
+ const FORBIDDEN_CONTENT_PROP_WRITES = new Set([
84
+ 'innerHTML',
85
+ 'outerHTML',
86
+ 'textContent',
87
+ 'innerText',
88
+ ]);
89
+ /** DOMTokenList-typed properties whose mutating methods set reflected attrs. */
90
+ const TOKEN_LIST_PROPS = new Set(['classList', 'part']);
91
+ /** Mutating methods on DOMTokenList instances. */
92
+ const TOKEN_LIST_MUTATING_METHODS = new Set(['add', 'remove', 'toggle', 'replace']);
93
+ /**
94
+ * Properties whose subproperties correspond to HTML attributes or CSS: dataset → data-* attributes / style → inline CSS properties.
95
+ */
96
+ const CHAINED_WRITE_PROPS = new Set(['dataset', 'style']);
97
+ /** Calling document.x() where x is in this set is forbidden. */
98
+ const FORBIDDEN_DOCUMENT_METHODS = new Set(['write', 'open']);
99
+ // Visitor handlers
100
+ /**
101
+ * Reports forbidden method calls on `this`, DOMTokenList members, or document.
102
+ * @param state - Rule state including ESLint context and scope stack.
103
+ * @param node - CallExpression node to inspect (as unknown from ESLint).
104
+ */
105
+ function onCallExpression(state, node) {
106
+ if (!isActiveScope(state)) {
107
+ return;
108
+ }
109
+ const call = node;
110
+ const { callee } = call;
111
+ if (callee.type !== 'MemberExpression') {
112
+ return;
113
+ }
114
+ if (callee.property.type !== 'Identifier') {
115
+ return;
116
+ }
117
+ // this.method(…)
118
+ if (callee.object.type === 'ThisExpression') {
119
+ const method = callee.property.name;
120
+ if (FORBIDDEN_ATTR_METHODS.has(method)) {
121
+ state.context.report({
122
+ node: node,
123
+ messageId: 'attrMethod',
124
+ data: {
125
+ method,
126
+ location: getActiveScopeLocation(state),
127
+ },
128
+ });
129
+ }
130
+ else if (FORBIDDEN_CHILD_METHODS.has(method)) {
131
+ state.context.report({
132
+ node: node,
133
+ messageId: 'childMethod',
134
+ data: {
135
+ method,
136
+ location: getActiveScopeLocation(state),
137
+ },
138
+ });
139
+ }
140
+ return;
141
+ }
142
+ // this.classList.add / this.part.remove / …
143
+ if (callee.object.type === 'MemberExpression'
144
+ && !callee.object.computed
145
+ && callee.object.object.type === 'ThisExpression'
146
+ && callee.object.property.type === 'Identifier'
147
+ && TOKEN_LIST_PROPS.has(callee.object.property.name)
148
+ && TOKEN_LIST_MUTATING_METHODS.has(callee.property.name)) {
149
+ state.context.report({
150
+ node: node,
151
+ messageId: 'tokenListMutation',
152
+ data: {
153
+ prop: callee.object.property.name,
154
+ method: callee.property.name,
155
+ location: getActiveScopeLocation(state),
156
+ },
157
+ });
158
+ return;
159
+ }
160
+ // document.write(…) / document.open(…)
161
+ if (callee.object.type === 'Identifier'
162
+ && callee.object.name === 'document'
163
+ && FORBIDDEN_DOCUMENT_METHODS.has(callee.property.name)) {
164
+ state.context.report({
165
+ node: node,
166
+ messageId: 'documentMethod',
167
+ data: {
168
+ method: callee.property.name,
169
+ location: getActiveScopeLocation(state),
170
+ },
171
+ });
172
+ }
173
+ }
174
+ /**
175
+ * Flags reads of forbidden own-child properties on `this`.
176
+ * @param state - Rule state including ESLint context and scope stack.
177
+ * @param node - MemberExpression node to inspect (as unknown from ESLint).
178
+ */
179
+ function onMemberExpression(state, node) {
180
+ if (!isActiveScope(state)) {
181
+ return;
182
+ }
183
+ const mem = node;
184
+ // Only flag `this.prop` - not `this.shadowRoot.prop` etc.
185
+ if (mem.object.type !== 'ThisExpression') {
186
+ return;
187
+ }
188
+ if (mem.property.type !== 'Identifier') {
189
+ return;
190
+ }
191
+ if (mem.computed) {
192
+ return; // skip this['children'] - unusual enough to ignore
193
+ }
194
+ const prop = mem.property.name;
195
+ if (FORBIDDEN_CHILD_PROPS.has(prop)) {
196
+ state.context.report({
197
+ node: node,
198
+ messageId: 'childProp',
199
+ data: {
200
+ prop,
201
+ location: getActiveScopeLocation(state),
202
+ },
203
+ });
204
+ }
205
+ }
206
+ /**
207
+ * Flags three categories of write during construction:
208
+ * 1. This.<contentProp> = … e.g., this.innerHTML = '<div>'
209
+ * 2. `this.<anything>` = … where <anything> is not a declared class field
210
+ * 3. This.dataset.<key> = … / this.style.<prop> = ….
211
+ * Only simple `=` assignments; compound operators (+=, |=, …) are left alone.
212
+ * @param state - Rule state including ESLint context and scope stack.
213
+ * @param node - AssignmentExpression node to inspect (as unknown from ESLint).
214
+ */
215
+ function onAssignmentExpression(state, node) {
216
+ if (!isActiveScope(state)) {
217
+ return;
218
+ }
219
+ const assign = node;
220
+ if (assign.operator !== '=') {
221
+ return;
222
+ }
223
+ const { left } = assign;
224
+ if (left.type !== 'MemberExpression') {
225
+ return;
226
+ }
227
+ // Branch 1: this.prop = value
228
+ if (!left.computed
229
+ && left.object.type === 'ThisExpression'
230
+ && left.property.type === 'Identifier') {
231
+ const prop = left.property.name;
232
+ const location = getActiveScopeLocation(state);
233
+ if (FORBIDDEN_CONTENT_PROP_WRITES.has(prop)) {
234
+ state.context.report({
235
+ node: node,
236
+ messageId: 'contentPropWrite',
237
+ data: {
238
+ prop,
239
+ location,
240
+ },
241
+ });
242
+ return;
243
+ }
244
+ // Avoid double-reporting what onMemberExpression already flags as childProp.
245
+ if (FORBIDDEN_CHILD_PROPS.has(prop)) {
246
+ return;
247
+ }
248
+ // Everything else that is not an explicit class field is potentially a
249
+ // reflected HTML/ARIA attribute (or state that should be declared).
250
+ if (!getClassFieldNames(state)
251
+ .has(prop)) {
252
+ state.context.report({
253
+ node: node,
254
+ messageId: 'undeclaredPropWrite',
255
+ data: {
256
+ prop,
257
+ location,
258
+ },
259
+ });
260
+ }
261
+ return;
262
+ }
263
+ // Branch 2: this.dataset.<key> = value / this.style.<prop> = value
264
+ if (left.object.type === 'MemberExpression'
265
+ && !left.object.computed
266
+ && left.object.object.type === 'ThisExpression'
267
+ && left.object.property.type === 'Identifier'
268
+ && CHAINED_WRITE_PROPS.has(left.object.property.name)) {
269
+ state.context.report({
270
+ node: node,
271
+ messageId: 'chainedPropWrite',
272
+ data: {
273
+ prop: left.object.property.name,
274
+ location: getActiveScopeLocation(state),
275
+ },
276
+ });
277
+ }
278
+ }
279
+ /**
280
+ * Flags `return <expr>` inside the constructor unless the expression is
281
+ * absent (`return;`) or is exactly `this` (`return this;`).
282
+ *
283
+ * Per spec: "A return statement must not appear anywhere inside the
284
+ * constructor body, unless it is a simple early-return (return or return this)".
285
+ * @param state - Rule state including ESLint context and scope stack.
286
+ * @param node - ReturnStatement node to inspect (as unknown from ESLint).
287
+ */
288
+ function onReturnStatement(state, node) {
289
+ if (!isActiveScope(state)) {
290
+ return;
291
+ }
292
+ const ret = node;
293
+ if (ret.argument === null) {
294
+ return; // bare `return;` is fine
295
+ }
296
+ if (ret.argument?.type === 'ThisExpression') {
297
+ return; // `return this;` is fine
298
+ }
299
+ state.context.report({
300
+ node: node,
301
+ messageId: 'illegalReturn',
302
+ });
303
+ }
304
+ // Rule definition
305
+ const noForbiddenInConstructor = {
306
+ meta: {
307
+ type: 'problem',
308
+ docs: {
309
+ description: 'Disallow spec-forbidden operations in Custom Element constructors and field initializers.',
310
+ url: 'https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-conformance',
311
+ },
312
+ messages: {
313
+ attrMethod: 'Do not call this.{{method}}() in {{location}} - attributes cannot be reliably set before the element is upgraded. Move this to connectedCallback.',
314
+ childProp: 'Do not read this.{{prop}} in {{location}} - child elements are not present until the element is connected. Move this to connectedCallback.',
315
+ childMethod: 'Do not call this.{{method}}() in {{location}} - child elements cannot be accessed or modified before the element is connected. Move this to connectedCallback.',
316
+ contentPropWrite: 'Do not assign to this.{{prop}} in {{location}} - this modifies element content before it is connected. Move this to connectedCallback.',
317
+ undeclaredPropWrite: 'this.{{prop}} is not declared as a class field (found in {{location}}). '
318
+ + 'If \'{{prop}}\' is a reflected HTML or ARIA attribute, move this assignment to connectedCallback. '
319
+ + 'If it is custom element state, declare it as a class field instead.',
320
+ tokenListMutation: 'Do not call this.{{prop}}.{{method}}() in {{location}} - this modifies a reflected attribute. Move this to connectedCallback.',
321
+ chainedPropWrite: 'Do not write to this.{{prop}} properties in {{location}} - this modifies element attributes or styles. Move this to connectedCallback.',
322
+ documentMethod: 'Do not call document.{{method}}() in {{location}} - this is explicitly forbidden by the Custom Elements spec.',
323
+ illegalReturn: 'The constructor must not return a value other than undefined or this.',
324
+ },
325
+ schema: baseClassesSchema,
326
+ hasSuggestions: false,
327
+ },
328
+ /**
329
+ * Creates rule.
330
+ * @param context - Context to process.
331
+ */
332
+ create(context) {
333
+ const options = context.options[0];
334
+ const state = {
335
+ context,
336
+ stack: [],
337
+ base_classes: options?.baseClasses ?? ['HTMLElement'],
338
+ };
339
+ return {
340
+ ...buildScopeVisitors(state),
341
+ CallExpression: adaptNodeHandler(state, onCallExpression),
342
+ MemberExpression: adaptNodeHandler(state, onMemberExpression),
343
+ AssignmentExpression: adaptNodeHandler(state, onAssignmentExpression),
344
+ ReturnStatement: adaptNodeHandler(state, onReturnStatement),
345
+ };
346
+ },
347
+ };
348
+ export default noForbiddenInConstructor;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @file Rule: no-keypress-event.
3
+ *
4
+ * Flags the deprecated `keypress` event and suggests replacing it with `keydown`.
5
+ *
6
+ * Covers two patterns:
7
+ * - element.addEventListener('keypress', handler) → suggestion offered
8
+ * - element.removeEventListener('keypress', handler) → flagged, no suggestion
9
+ * - element.onkeypress = handler → suggestion offered.
10
+ */
11
+ import type { Rule } from 'eslint';
12
+ declare const noKeypressEvent: Rule.RuleModule;
13
+ export default noKeypressEvent;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @file Rule: no-keypress-event.
3
+ *
4
+ * Flags the deprecated `keypress` event and suggests replacing it with `keydown`.
5
+ *
6
+ * Covers two patterns:
7
+ * - element.addEventListener('keypress', handler) → suggestion offered
8
+ * - element.removeEventListener('keypress', handler) → flagged, no suggestion
9
+ * - element.onkeypress = handler → suggestion offered.
10
+ */
11
+ import { adaptNodeHandler } from '../utils/Adapters.mjs';
12
+ const DEPRECATED = 'keypress';
13
+ const REPLACEMENT = 'keydown';
14
+ /**
15
+ * Checks addEventListener / removeEventListener calls whose first argument is
16
+ * the literal string 'keypress'. A fix suggestion is only offered for
17
+ * addEventListener — renaming inside removeEventListener requires the developer
18
+ * to also update the paired registration, so a manual edit is safer.
19
+ * @param context - ESLint rule context.
20
+ * @param node - CallExpression node to inspect.
21
+ */
22
+ function checkListenerCall(context, node) {
23
+ const call = node;
24
+ if (call.callee.type !== 'MemberExpression'
25
+ || call.callee.property.type !== 'Identifier') {
26
+ return;
27
+ }
28
+ const method = call.callee.property.name;
29
+ const is_add = method === 'addEventListener';
30
+ const is_remove = method === 'removeEventListener';
31
+ if (!is_add && !is_remove) {
32
+ return;
33
+ }
34
+ const event_arg = call.arguments[0];
35
+ if (event_arg?.type !== 'Literal'
36
+ || event_arg.value !== DEPRECATED) {
37
+ return;
38
+ }
39
+ context.report({
40
+ node: event_arg,
41
+ messageId: 'avoidKeypress',
42
+ suggest: is_add
43
+ ? [
44
+ {
45
+ messageId: 'replaceWithKeydown',
46
+ /**
47
+ * Replaces the 'keypress' literal with 'keydown' in the source.
48
+ * @param fixer - ESLint rule fixer.
49
+ * @returns The text replacement fix.
50
+ */
51
+ fix(fixer) {
52
+ const quote = event_arg.raw[0];
53
+ return fixer.replaceText(event_arg, `${quote}${REPLACEMENT}${quote}`);
54
+ },
55
+ },
56
+ ]
57
+ : [],
58
+ });
59
+ }
60
+ /**
61
+ * Flags `element.onkeypress = handler` assignments and suggests renaming the
62
+ * property to `onkeydown`.
63
+ * @param context - ESLint rule context.
64
+ * @param node - AssignmentExpression node to inspect.
65
+ */
66
+ function checkOnkeypressAssignment(context, node) {
67
+ const assign = node;
68
+ if (assign.left.type !== 'MemberExpression'
69
+ || assign.left.computed
70
+ || assign.left.property.type !== 'Identifier'
71
+ || assign.left.property.name !== 'onkeypress') {
72
+ return;
73
+ }
74
+ context.report({
75
+ node: assign.left.property,
76
+ messageId: 'avoidOnkeypress',
77
+ suggest: [
78
+ {
79
+ messageId: 'replaceWithOnkeydown',
80
+ /**
81
+ * Replaces the 'onkeypress' property with 'onkeydown' in the source.
82
+ * @param fixer - ESLint rule fixer.
83
+ * @returns The text replacement fix.
84
+ */
85
+ fix(fixer) {
86
+ return fixer.replaceText(assign.left.property, 'onkeydown');
87
+ },
88
+ },
89
+ ],
90
+ });
91
+ }
92
+ /**
93
+ * Top-level ESLint visitor for CallExpression nodes.
94
+ * @param context - ESLint rule context.
95
+ * @param node - CallExpression node.
96
+ */
97
+ function onCallExpression(context, node) {
98
+ checkListenerCall(context, node);
99
+ }
100
+ /**
101
+ * Top-level ESLint visitor for AssignmentExpression nodes.
102
+ * @param context - ESLint rule context.
103
+ * @param node - AssignmentExpression node.
104
+ */
105
+ function onAssignmentExpression(context, node) {
106
+ checkOnkeypressAssignment(context, node);
107
+ }
108
+ const noKeypressEvent = {
109
+ meta: {
110
+ type: 'suggestion',
111
+ hasSuggestions: true,
112
+ docs: {
113
+ description: 'Disallow the deprecated `keypress` event in favour of `keydown`.',
114
+ url: 'https://github.com/simbiat/eslint-plugin-simbiat',
115
+ },
116
+ messages: {
117
+ avoidKeypress: '`keypress` is deprecated, use `keydown` instead.',
118
+ replaceWithKeydown: '`keypress` is deprecated, use `keydown` instead.',
119
+ avoidOnkeypress: '`onkeypress` is deprecated, use `onkeydown` instead.',
120
+ replaceWithOnkeydown: '`onkeypress` is deprecated, use `onkeydown` instead.',
121
+ },
122
+ schema: [],
123
+ },
124
+ /**
125
+ * Creates the rule listeners.
126
+ * @param context - ESLint rule context.
127
+ * @returns Rule listener object.
128
+ */
129
+ create(context) {
130
+ return {
131
+ CallExpression: adaptNodeHandler(context, onCallExpression),
132
+ AssignmentExpression: adaptNodeHandler(context, onAssignmentExpression),
133
+ };
134
+ },
135
+ };
136
+ export default noKeypressEvent;
@@ -0,0 +1,21 @@
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 type { Rule } from 'eslint';
13
+ /**
14
+ * Stack entry shapes:
15
+ * { kind: 'class', fieldNames: Set<string>, node: ClassNode }
16
+ * { kind: 'method', methodName: string, paramNames: Set<string>, localNames: Set<string> }
17
+ * { kind: 'block' } ← inside a control-flow statement (if/for/while/switch/try)
18
+ * { kind: 'fn' } ← inside a nested function or arrow function.
19
+ */
20
+ declare const preferFieldInitializer: Rule.RuleModule;
21
+ export default preferFieldInitializer;