@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,80 @@
1
+ /**
2
+ * @file Shared scope-tracking utilities for Custom Element ESLint rules.
3
+ */
4
+ /** Discriminated union of scope-stack entries. */
5
+ type ScopeEntry = {
6
+ readonly kind: 'class';
7
+ readonly isHTMLEl: boolean;
8
+ readonly fieldNames: Set<string>;
9
+ } | {
10
+ readonly kind: 'field';
11
+ readonly fieldName: string;
12
+ } | {
13
+ readonly kind: 'constructor';
14
+ } | {
15
+ readonly kind: 'fn';
16
+ };
17
+ /** State object shared across visitor callbacks for Custom Element scope rules. */
18
+ export interface ScopeState {
19
+ readonly stack: ScopeEntry[];
20
+ readonly base_classes: readonly string[];
21
+ }
22
+ /**
23
+ * `True` when execution is directly inside either:
24
+ * - the constructor body of an HTMLElement subclass, or
25
+ * - an instance field initializer of an HTMLElement subclass,
26
+ * AND there is no nested function or arrow function in between.
27
+ *
28
+ * Both 'constructor' and 'field' entries are only pushed when isHTMLEl is
29
+ * true on the enclosing class, so we can check the top alone.
30
+ * @param state - Current scope state.
31
+ * @returns True when currently in an active Custom Element scope.
32
+ */
33
+ export declare function isActiveScope(state: ScopeState): boolean;
34
+ /**
35
+ * Returns a human-readable phrase describing the currently active scope,
36
+ * for use as the `{{location}}` template variable in rule messages.
37
+ *
38
+ * Examples:
39
+ * "the constructor"
40
+ * "the field initializer for 'myField'"
41
+ * "the field initializer for '#privateField'".
42
+ *
43
+ * Falls back to "the constructor" if called outside an active scope.
44
+ * @param state - Current scope state.
45
+ * @returns Human-readable scope location string.
46
+ */
47
+ export declare function getActiveScopeLocation(state: ScopeState): string;
48
+ /**
49
+ * Returns the `fieldNames` set of the innermost enclosing class on the stack,
50
+ * or an empty Set when called outside any class body.
51
+ *
52
+ * Use this inside an `isActiveScope` guard to check whether the developer
53
+ * explicitly declared a given property name as a class field.
54
+ * @param state - Current scope state.
55
+ * @returns Set of class field names from the nearest enclosing class.
56
+ */
57
+ export declare function getClassFieldNames(state: ScopeState): Set<string>;
58
+ /**
59
+ * Returns the ESLint visitor entries that maintain the scope stack.
60
+ * Spread these into your `create()` return value alongside rule-specific
61
+ * visitors.
62
+ * @param state - Scope state with stack and base_classes.
63
+ * @returns Record of ESLint visitor callbacks for scope tracking.
64
+ */
65
+ export declare function buildScopeVisitors(state: ScopeState): Record<string, (node: unknown) => void>;
66
+ /** JSON-Schema fragment accepted by all CE constructor/lifecycle rules. */
67
+ export declare const baseClassesSchema: {
68
+ type: string;
69
+ properties: {
70
+ baseClasses: {
71
+ type: string;
72
+ items: {
73
+ type: string;
74
+ };
75
+ minItems: number;
76
+ };
77
+ };
78
+ additionalProperties: boolean;
79
+ }[];
80
+ export {};
@@ -0,0 +1,270 @@
1
+ /**
2
+ * @file Shared scope-tracking utilities for Custom Element ESLint rules.
3
+ */
4
+ import { adaptNodeHandler, adaptStateHandler } from './Adapters.mjs';
5
+ // Field-name collection
6
+ /**
7
+ * Returns a Set of every public property name explicitly declared as a
8
+ * PropertyDefinition (class field) in the class body.
9
+ *
10
+ * Private fields are omitted: `this.#foo` produces a PrivateIdentifier node,
11
+ * not an Identifier, so those assignments can never reach the checks that
12
+ * consult this set.
13
+ * @param class_node - ClassDeclaration or ClassExpression node.
14
+ * @returns Set of declared field names.
15
+ */
16
+ function collectClassFieldNames(class_node) {
17
+ const names = new Set();
18
+ for (const member of class_node.body?.body ?? []) {
19
+ if (member.type !== 'PropertyDefinition') {
20
+ continue;
21
+ }
22
+ const { key } = member;
23
+ if (!key) {
24
+ continue;
25
+ }
26
+ if (key.type === 'Identifier' && typeof key.name === 'string') {
27
+ names.add(key.name);
28
+ }
29
+ else if (key.type === 'Literal') {
30
+ names.add(String(key.value));
31
+ }
32
+ // PrivateIdentifier (#foo) deliberately omitted – see note above.
33
+ }
34
+ return names;
35
+ }
36
+ // Scope predicates
37
+ /**
38
+ * `True` when execution is directly inside either:
39
+ * - the constructor body of an HTMLElement subclass, or
40
+ * - an instance field initializer of an HTMLElement subclass,
41
+ * AND there is no nested function or arrow function in between.
42
+ *
43
+ * Both 'constructor' and 'field' entries are only pushed when isHTMLEl is
44
+ * true on the enclosing class, so we can check the top alone.
45
+ * @param state - Current scope state.
46
+ * @returns True when currently in an active Custom Element scope.
47
+ */
48
+ export function isActiveScope(state) {
49
+ const top = state.stack[state.stack.length - 1];
50
+ return top?.kind === 'constructor' || top?.kind === 'field';
51
+ }
52
+ /**
53
+ * Returns a human-readable phrase describing the currently active scope,
54
+ * for use as the `{{location}}` template variable in rule messages.
55
+ *
56
+ * Examples:
57
+ * "the constructor"
58
+ * "the field initializer for 'myField'"
59
+ * "the field initializer for '#privateField'".
60
+ *
61
+ * Falls back to "the constructor" if called outside an active scope.
62
+ * @param state - Current scope state.
63
+ * @returns Human-readable scope location string.
64
+ */
65
+ export function getActiveScopeLocation(state) {
66
+ const top = state.stack[state.stack.length - 1];
67
+ if (top?.kind === 'field') {
68
+ return `the field initializer for '${top.fieldName}'`;
69
+ }
70
+ return 'the constructor';
71
+ }
72
+ /**
73
+ * Returns the `fieldNames` set of the innermost enclosing class on the stack,
74
+ * or an empty Set when called outside any class body.
75
+ *
76
+ * Use this inside an `isActiveScope` guard to check whether the developer
77
+ * explicitly declared a given property name as a class field.
78
+ * @param state - Current scope state.
79
+ * @returns Set of class field names from the nearest enclosing class.
80
+ */
81
+ export function getClassFieldNames(state) {
82
+ for (let i = state.stack.length - 1; i >= 0; i--) {
83
+ // eslint-disable-next-line security/detect-object-injection
84
+ const entry = state.stack[i];
85
+ if (entry?.kind === 'class') {
86
+ return entry.fieldNames;
87
+ }
88
+ }
89
+ return new Set();
90
+ }
91
+ // Visitor handlers
92
+ /**
93
+ * Pushes a 'class' scope entry when entering a class declaration or expression.
94
+ * @param state - Current scope state.
95
+ * @param node - Class declaration or expression node (as unknown from ESLint).
96
+ */
97
+ function onClassEnter(state, node) {
98
+ const class_node = node;
99
+ const super_class = class_node.superClass;
100
+ const is_html_el = super_class?.type === 'Identifier' && typeof super_class.name === 'string' && state.base_classes.includes(super_class.name);
101
+ state.stack.push({
102
+ kind: 'class',
103
+ isHTMLEl: is_html_el,
104
+ fieldNames: collectClassFieldNames(class_node),
105
+ });
106
+ }
107
+ /**
108
+ * Pops the 'class' scope entry when leaving a class.
109
+ * @param state - Current scope state.
110
+ */
111
+ function onClassExit(state) {
112
+ state.stack.pop();
113
+ }
114
+ /**
115
+ * Pushes a 'field' scope entry when entering a non-static instance field
116
+ * initializer that belongs directly to an HTMLElement subclass.
117
+ *
118
+ * Conditions:
119
+ * - The PropertyDefinition must not be static.
120
+ * - It must have an initializer (value !== null) — without one there is nothing to analyze.
121
+ * - The direct parent class entry on the stack must have isHTMLEl === true.
122
+ * @param state - Current scope state.
123
+ * @param node - PropertyDefinition node (as unknown from ESLint).
124
+ */
125
+ function onPropertyDefinitionEnter(state, node) {
126
+ const prop = node;
127
+ if (prop.static === true || prop.value === null) {
128
+ return;
129
+ }
130
+ const top = state.stack[state.stack.length - 1];
131
+ if (top?.kind !== 'class' || !top.isHTMLEl) {
132
+ return;
133
+ }
134
+ // Determine a readable field name for messages.
135
+ let field_name;
136
+ const { key } = prop;
137
+ if (!key) {
138
+ return;
139
+ }
140
+ if (key.type === 'Identifier') {
141
+ field_name = typeof key.name === 'string' ? key.name : '(computed)';
142
+ }
143
+ else if (key.type === 'PrivateIdentifier') {
144
+ field_name = typeof key.name === 'string' ? `#${key.name}` : '#(unknown)';
145
+ }
146
+ else if (key.type === 'Literal') {
147
+ field_name = String(key.value);
148
+ }
149
+ else {
150
+ field_name = '(computed)';
151
+ }
152
+ state.stack.push({
153
+ kind: 'field',
154
+ fieldName: field_name,
155
+ });
156
+ }
157
+ /**
158
+ * Pops the 'field' scope entry when leaving a property definition.
159
+ * @param state - Current scope state.
160
+ * @param node - PropertyDefinition node (as unknown from ESLint).
161
+ */
162
+ function onPropertyDefinitionExit(state, node) {
163
+ const prop = node;
164
+ if (prop.static === true || prop.value === null) {
165
+ return;
166
+ }
167
+ if (state.stack[state.stack.length - 1]?.kind === 'field') {
168
+ state.stack.pop();
169
+ }
170
+ }
171
+ /**
172
+ * FunctionExpression covers both regular methods and the constructor body.
173
+ * - constructor body → push 'constructor'
174
+ * - any other fn inside constructor or field initializer → push 'fn'.
175
+ * @param state - Current scope state.
176
+ * @param node - FunctionExpression node (as unknown from ESLint).
177
+ */
178
+ function onFunctionEnter(state, node) {
179
+ const fn = node;
180
+ const is_constructor_body = fn.parent?.type === 'MethodDefinition' && fn.parent.kind === 'constructor';
181
+ if (is_constructor_body) {
182
+ const top_class = [...state.stack].reverse()
183
+ .find((e) => {
184
+ return e.kind === 'class';
185
+ });
186
+ if (top_class?.kind === 'class' && top_class.isHTMLEl) {
187
+ state.stack.push({ kind: 'constructor' });
188
+ }
189
+ }
190
+ else if (state.stack.some((e) => {
191
+ return e.kind === 'constructor' || e.kind === 'field';
192
+ })) {
193
+ // Nested regular function inside constructor or field initializer.
194
+ // `this` is rebound, so all CE checks inside it must be suppressed.
195
+ state.stack.push({ kind: 'fn' });
196
+ }
197
+ }
198
+ /**
199
+ * Pops the scope entry pushed by `onFunctionEnter` when leaving a function.
200
+ * @param state - Current scope state.
201
+ * @param node - FunctionExpression node (as unknown from ESLint).
202
+ */
203
+ function onFunctionExit(state, node) {
204
+ const fn = node;
205
+ const top = state.stack[state.stack.length - 1];
206
+ const is_constructor_body = fn.parent?.type === 'MethodDefinition' && fn.parent.kind === 'constructor';
207
+ if ((is_constructor_body && top?.kind === 'constructor')
208
+ || (!is_constructor_body && top?.kind === 'fn')) {
209
+ state.stack.pop();
210
+ }
211
+ }
212
+ /**
213
+ * Arrow functions inherit `this` lexically, so they can still access the
214
+ * element — but their body is deferred (callback), so checks must be
215
+ * suppressed. Push 'fn' whenever we're inside a constructor or field init.
216
+ * @param state - Current scope state.
217
+ */
218
+ function onArrowEnter(state) {
219
+ if (state.stack.some((e) => {
220
+ return e.kind === 'constructor' || e.kind === 'field';
221
+ })) {
222
+ state.stack.push({ kind: 'fn' });
223
+ }
224
+ }
225
+ /**
226
+ * Pops the 'fn' scope entry pushed by `onArrowEnter`.
227
+ * @param state - Current scope state.
228
+ */
229
+ function onArrowExit(state) {
230
+ if (state.stack[state.stack.length - 1]?.kind === 'fn') {
231
+ state.stack.pop();
232
+ }
233
+ }
234
+ // Visitor builder
235
+ /**
236
+ * Returns the ESLint visitor entries that maintain the scope stack.
237
+ * Spread these into your `create()` return value alongside rule-specific
238
+ * visitors.
239
+ * @param state - Scope state with stack and base_classes.
240
+ * @returns Record of ESLint visitor callbacks for scope tracking.
241
+ */
242
+ export function buildScopeVisitors(state) {
243
+ return {
244
+ 'ClassDeclaration': adaptNodeHandler(state, onClassEnter),
245
+ 'ClassExpression': adaptNodeHandler(state, onClassEnter),
246
+ 'ClassDeclaration:exit': adaptStateHandler(state, onClassExit),
247
+ 'ClassExpression:exit': adaptStateHandler(state, onClassExit),
248
+ 'PropertyDefinition': adaptNodeHandler(state, onPropertyDefinitionEnter),
249
+ 'PropertyDefinition:exit': adaptNodeHandler(state, onPropertyDefinitionExit),
250
+ 'FunctionExpression': adaptNodeHandler(state, onFunctionEnter),
251
+ 'FunctionExpression:exit': adaptNodeHandler(state, onFunctionExit),
252
+ 'ArrowFunctionExpression': adaptStateHandler(state, onArrowEnter),
253
+ 'ArrowFunctionExpression:exit': adaptStateHandler(state, onArrowExit),
254
+ };
255
+ }
256
+ // Shared option schema
257
+ /** JSON-Schema fragment accepted by all CE constructor/lifecycle rules. */
258
+ export const baseClassesSchema = [
259
+ {
260
+ type: 'object',
261
+ properties: {
262
+ baseClasses: {
263
+ type: 'array',
264
+ items: { type: 'string' },
265
+ minItems: 1,
266
+ },
267
+ },
268
+ additionalProperties: false,
269
+ },
270
+ ];
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@simbiat/eslint-plugin-simbiat",
3
+ "version": "1.0.0",
4
+ "description": "Custom ESLint rules used in simbiat.eu project: class-field initializers, custom-element constructor constraints, and typed querySelector.",
5
+ "author": {
6
+ "name": "Dmitrii Kustov",
7
+ "email": "upport@simbiat.eu",
8
+ "url": "https://www.simbiat.eu"
9
+ },
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "default": "./dist/Plugin.mjs",
14
+ "import": "./dist/Plugin.mjs",
15
+ "types": "./dist/Plugin.d.mts"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "LICENSE",
22
+ "package.json",
23
+ "README.md"
24
+ ],
25
+ "engines": {
26
+ "node": "^20.10.0 || >=21.0.0"
27
+ },
28
+ "peerDependencies": {
29
+ "eslint": ">=9.38.0"
30
+ },
31
+ "keywords": [
32
+ "eslint",
33
+ "eslintplugin ",
34
+ "eslint-plugin",
35
+ "custom-elements",
36
+ "typescript",
37
+ "class-fields"
38
+ ],
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/simbiat/eslint-plugin-simbiat.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/simbiat/eslint-plugin-simbiat/issues"
46
+ },
47
+ "scripts": {
48
+ "prepare": "tsc"
49
+ }
50
+ }