@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitrii Kustov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # eslint-plugin-simbiat
2
+
3
+ Custom ESLint rules that are used in the [simbiat.eu](https://github.com/Simbiat/simbiat.ru) project. Created with the use of Claude AI (I am realistically not that proficient) but manually reviewed, adjusted, and tested on the existing codebase.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install --save-dev @simbiat/eslint-plugin-simbiat
9
+ ```
10
+
11
+ Requires ESLint
12
+ `>=9.38.0`, [flat config](https://eslint.org/docs/latest/use/configure/configuration-files), and [ESM](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c#how-can-i-make-my-typescript-project-output-esm).
13
+
14
+ ---
15
+
16
+ ## Usage (flat config)
17
+
18
+ ```js
19
+ // eslint.config.js
20
+ import simbiat from '@simbiat/eslint-plugin-simbiat';
21
+
22
+ export default [
23
+ {
24
+ plugins: { simbiat },
25
+ rules: {
26
+ 'simbiat/no-forbidden-in-constructor': 'error',
27
+ 'simbiat/no-external-listeners-in-constructor': 'warn',
28
+ 'simbiat/prefer-field-initializer': 'warn',
29
+ 'simbiat/require-type-parameter': 'warn',
30
+ 'simbiat/require-super-first-in-constructor': 'error',
31
+ 'simbiat/no-keypress-event': 'warn',
32
+ },
33
+ },
34
+ ];
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Rules
40
+
41
+ ### `simbiat/no-forbidden-in-constructor` - *problem*
42
+
43
+ Flags things the [Custom Elements spec](https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-conformance)
44
+ forbids in a constructor and in class field definitions:
45
+
46
+ | Category | Members |
47
+ |------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
48
+ | Attribute manipulation | `this.setAttribute()`, `this.toggleAttribute()`, creation/update attributes like `this.id`, `this.className`, `this.tabIndex`, etc., that are not class fields. |
49
+ | Child-element access | `this.children`, `this.childNodes`, `this.firstChild`, `this.lastChild`, `this.firstElementChild`, `this.lastElementChild`, `this.childElementCount` |
50
+ | Child-element queries | `this.querySelector()`, `this.querySelectorAll()`, `this.getElementsByTagName()`, `this.getElementsByClassName()`, `this.getElementsByName()` |
51
+ | Content mutation | `this.innerHTML`, `this.outerHTML`, `this.textContent`, `this.innerText`, `this.appendChild()`, `this.insertBefore`, `this.replaceChild`, `this.removeChild()`, `this.append()`, `this.prepend()`, `this.replaceChildren()`, `this.insertAdjacentHTML()`, `this.insertAdjacentElement()`, `this.insertAdjacentText()`, `this.after()`, `this.before()`, `this.replaceWith()`, `this.remove()` |
52
+ | Global calls | `document.write()`, `document.open()` |
53
+ | Illegal return | Any `return <expr>;` that is not a bare `return;` or `return this;` |
54
+
55
+ Not flagged: `this.shadowRoot.*`, `this.attachShadow()`, `this.getAttribute()`,
56
+ `this.removeAttribute()`, `appendChild` on shadow-root elements, event
57
+ listeners on shadow-scoped elements.
58
+
59
+ **Options**
60
+
61
+ ```js
62
+ 'simbiat/no-forbidden-in-constructor': ['error', {
63
+ baseClasses: ['HTMLElement', 'LitElement', 'BaseComponent'],
64
+ }]
65
+ ```
66
+
67
+ `baseClasses` defaults to `['HTMLElement']`.
68
+
69
+ ---
70
+
71
+ ### `simbiat/no-external-listeners-in-constructor` - *suggestion*
72
+
73
+ Flags `addEventListener` calls on `document`, `window`, `document.body`,
74
+ `document.documentElement`, or `document.head` that appear **directly** in
75
+ the constructor body (not inside a nested callback or arrow function).
76
+
77
+ These listeners belong in `connectedCallback`, paired with removal in
78
+ `disconnectedCallback`; otherwise they leak when the element is moved or
79
+ re-inserted into the DOM.
80
+
81
+ **Options** - same `baseClasses` schema as above.
82
+
83
+ ---
84
+
85
+ ### `simbiat/require-super-first-in-constructor` - *problem*
86
+
87
+ Flags constructors, that do not start with empty
88
+ `super();`. This is normally flagged by TypeScript but may not be flagged in pure JavaScript.
89
+
90
+ **Options** - same `baseClasses` schema as above.
91
+
92
+ ---
93
+
94
+ ### `simbiat/require-listener-cleanup` - *suggestion*
95
+
96
+ Verifies that every `addEventListener` call on an external target inside
97
+ `connectedCallback` of an HTMLElement subclass has a matching `removeEventListener` call in `disconnectedCallback`. If the handler in `addEventListener` is a proper class field already, it can be auto-fixed (respective `removeEventListener` will be added).
98
+
99
+ **Options** - same `baseClasses` schema as above.
100
+
101
+ ---
102
+
103
+ ### `simbiat/prefer-field-initializer` - *suggestion*
104
+
105
+ Flags `this.x = expr` assignments in a constructor when all the following hold:
106
+
107
+ 1. `x` already has a class field declaration (`PropertyDefinition`).
108
+ 2. The RHS does **not** reference a constructor parameter by name.
109
+ 3. The RHS does **not** contain `this.anything` (field-initializer vs.
110
+ constructor-assignment ordering can differ subtly).
111
+
112
+ Only top-level assignments in the constructor body are checked. Assignments
113
+ inside `if`/`for`/`while` blocks, ternaries, or nested functions are
114
+ intentionally ignored.
115
+
116
+ > **Known limitation:** references to local variables defined earlier in the
117
+ > constructor (not parameters) are not detected and may produce false
118
+ > positives. Suppress with `// eslint-disable-next-line` where needed.
119
+
120
+ No auto-fix: the change requires removing the assignment *and* updating the
121
+ field declaration simultaneously.
122
+
123
+ ---
124
+
125
+ ### `simbiat/require-type-parameter` - *suggestion*
126
+
127
+ Flags `querySelector`, `querySelectorAll` and `closest` calls in TypeScript (`.ts` /
128
+ `.tsx`) files that lack a type parameter.
129
+
130
+ ```ts
131
+ // ✗ flagged
132
+ const element = document.querySelector('.link');
133
+
134
+ // ✓ OK
135
+ const element = document.querySelector<HTMLAnchorElement>('.link');
136
+ ```
137
+
138
+ JS files are left alone. No auto-fix: the correct type depends on the
139
+ selector and must be supplied by the developer.
140
+
141
+ ---
142
+
143
+ ### `simbiat/no-keypress-event` - *suggestion*
144
+
145
+ Flags use of `keypress` event, since it is deprecated, and `keydown` is recommended as replacement (or `beforeinput` in some cases). Provides autofix except for `removeEventListener`, because these require a complete match, and if the listener being removed is third party, it will not work as expected. Thus, manual intervention is recommended here.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @file Index file for the plugin.
3
+ */
4
+ declare const Plugin: {
5
+ meta: {
6
+ name: string;
7
+ url: string;
8
+ };
9
+ rules: {
10
+ 'no-forbidden-in-constructor': import("eslint").Rule.RuleModule;
11
+ 'no-external-listeners-in-constructor': import("eslint").Rule.RuleModule;
12
+ 'prefer-field-initializer': import("eslint").Rule.RuleModule;
13
+ 'require-type-parameter': import("eslint").Rule.RuleModule;
14
+ 'require-super-first-in-constructor': import("eslint").Rule.RuleModule;
15
+ 'require-listener-cleanup': import("eslint").Rule.RuleModule;
16
+ 'no-keypress-event': import("eslint").Rule.RuleModule;
17
+ };
18
+ };
19
+ export default Plugin;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @file Index file for the plugin.
3
+ */
4
+ // @ts-check
5
+ import noForbiddenInConstructor from './rules/NoForbiddenInConstructor.mjs';
6
+ import noExternalListenersInConstructor from './rules/NoExternalListenersInConstructor.mjs';
7
+ import preferFieldInitializer from './rules/PreferFieldInitializer.mjs';
8
+ import requireListenerCleanup from './rules/RequireListenerCleanup.mjs';
9
+ import requireTypeParameter from './rules/RequireTypeParameter.mjs';
10
+ import requireSuperFirstInConstructor from './rules/RequireSuperFirstInConstructor.mjs';
11
+ import noKeypressEvent from './rules/NoKeypressEvent.mjs';
12
+ const Plugin = {
13
+ meta: {
14
+ name: 'eslint-plugin-simbiat',
15
+ url: 'https://github.com/simbiat/eslint-plugin-simbiat',
16
+ },
17
+ rules: {
18
+ 'no-forbidden-in-constructor': noForbiddenInConstructor,
19
+ 'no-external-listeners-in-constructor': noExternalListenersInConstructor,
20
+ 'prefer-field-initializer': preferFieldInitializer,
21
+ 'require-type-parameter': requireTypeParameter,
22
+ 'require-super-first-in-constructor': requireSuperFirstInConstructor,
23
+ 'require-listener-cleanup': requireListenerCleanup,
24
+ 'no-keypress-event': noKeypressEvent,
25
+ },
26
+ };
27
+ // Used by ESLint Config
28
+ // noinspection JSUnusedGlobalSymbols
29
+ export default Plugin;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @file Rule: simbiat/no-external-listeners-in-constructor.
3
+ *
4
+ * Flags `addEventListener` calls on `document`, `window`, `document.body`,
5
+ * `document.documentElement`, or `document.head` that appear *directly* in
6
+ * the constructor body of a Custom Element (not inside a nested
7
+ * callback/arrow function).
8
+ *
9
+ * These listeners belong in `connectedCallback`, paired with removal in
10
+ * `disconnectedCallback`; otherwise they leak if the element is moved or
11
+ * re-inserted into the DOM.
12
+ *
13
+ * Options:
14
+ * baseClasses: string[] – additional class names to treat as HTMLElement. Defaults to ['HTMLElement'].
15
+ */
16
+ import type { Rule } from 'eslint';
17
+ declare const noExternalListenersInConstructor: Rule.RuleModule;
18
+ export default noExternalListenersInConstructor;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @file Rule: simbiat/no-external-listeners-in-constructor.
3
+ *
4
+ * Flags `addEventListener` calls on `document`, `window`, `document.body`,
5
+ * `document.documentElement`, or `document.head` that appear *directly* in
6
+ * the constructor body of a Custom Element (not inside a nested
7
+ * callback/arrow function).
8
+ *
9
+ * These listeners belong in `connectedCallback`, paired with removal in
10
+ * `disconnectedCallback`; otherwise they leak if the element is moved or
11
+ * re-inserted into the DOM.
12
+ *
13
+ * Options:
14
+ * baseClasses: string[] – additional class names to treat as HTMLElement. Defaults to ['HTMLElement'].
15
+ */
16
+ import { adaptNodeHandler } from '../utils/Adapters.mjs';
17
+ import { isExternalTarget, targetName } from '../utils/ASTHelpers.mjs';
18
+ import { isActiveScope, buildScopeVisitors, baseClassesSchema, } from '../utils/CustomElementsScope.mjs';
19
+ // Visitor handler
20
+ /**
21
+ * Reports `addEventListener` calls on external targets found directly in the constructor.
22
+ * @param state - Rule state including ESLint context and scope stack.
23
+ * @param node - CallExpression node to inspect (as unknown from ESLint).
24
+ */
25
+ function onCallExpression(state, node) {
26
+ if (!isActiveScope(state)) {
27
+ return;
28
+ }
29
+ const call = node;
30
+ const { callee } = call;
31
+ if (callee.type !== 'MemberExpression') {
32
+ return;
33
+ }
34
+ if (callee.property.type !== 'Identifier') {
35
+ return;
36
+ }
37
+ if (callee.property.name !== 'addEventListener') {
38
+ return;
39
+ }
40
+ if (!isExternalTarget(callee.object)) {
41
+ return;
42
+ }
43
+ state.context.report({
44
+ node: node,
45
+ messageId: 'externalListener',
46
+ data: { target: targetName(callee.object) },
47
+ });
48
+ }
49
+ // Rule definition
50
+ const noExternalListenersInConstructor = {
51
+ meta: {
52
+ type: 'suggestion',
53
+ docs: {
54
+ description: 'Discourage addEventListener on document / window in Custom Element constructors.',
55
+ },
56
+ messages: {
57
+ externalListener: 'Avoid attaching listeners to {{target}} in the constructor. '
58
+ + 'Add them in connectedCallback and remove them in disconnectedCallback; '
59
+ + 'otherwise listeners will be lost if the element is moved or re-inserted.',
60
+ },
61
+ schema: baseClassesSchema,
62
+ hasSuggestions: false,
63
+ },
64
+ /**
65
+ * Create rule.
66
+ * @param context - Contect to process.
67
+ */
68
+ create(context) {
69
+ const options = context.options[0];
70
+ const state = {
71
+ context,
72
+ stack: [],
73
+ base_classes: options?.baseClasses ?? ['HTMLElement'],
74
+ };
75
+ return {
76
+ ...buildScopeVisitors(state),
77
+ CallExpression: adaptNodeHandler(state, onCallExpression),
78
+ };
79
+ },
80
+ };
81
+ export default noExternalListenersInConstructor;
@@ -0,0 +1,28 @@
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 type { Rule } from 'eslint';
27
+ declare const noForbiddenInConstructor: Rule.RuleModule;
28
+ export default noForbiddenInConstructor;