kensington-eslint-plugin 0.1.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/README.md ADDED
@@ -0,0 +1,347 @@
1
+ # kensington-eslint-plugin
2
+
3
+ ESLint rules for [kensington](https://github.com/beezwax/kensington) signal correctness.
4
+
5
+ Catches common reactive programming mistakes — read/write loops, writes inside computed derivations, orphaned effects, and async subscription pitfalls — at lint time rather than at runtime.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ npm install --save-dev kensington-eslint-plugin
11
+ ```
12
+
13
+ Requires ESLint 9+ and Node 18+.
14
+
15
+ ## Usage
16
+
17
+ Add the recommended config to your `eslint.config.js`:
18
+
19
+ ```js
20
+ import kensington from 'kensington-eslint-plugin';
21
+
22
+ export default [
23
+ kensington.configs.recommended,
24
+ // ...your other configs
25
+ ];
26
+ ```
27
+
28
+ Or enable rules individually:
29
+
30
+ ```js
31
+ import kensington from 'kensington-eslint-plugin';
32
+
33
+ export default [
34
+ {
35
+ plugins: { kensington },
36
+ rules: {
37
+ 'kensington/no-set-in-computed': 'error',
38
+ 'kensington/no-self-read-write': 'error',
39
+ // ...
40
+ },
41
+ },
42
+ ];
43
+ ```
44
+
45
+ ## Editor and tooling support
46
+
47
+ Because this is a standard ESLint plugin, it works anywhere ESLint runs — no extra configuration needed:
48
+
49
+ - **Editors** — VS Code, JetBrains IDEs (RubyMine, WebStorm, etc.), Neovim, and any editor with an ESLint language server show inline errors automatically once the plugin is configured.
50
+ - **CI** — run `eslint --max-warnings 0` in any pipeline to enforce rules on every push.
51
+ - **Pre-commit hooks** — works with `lint-staged` or any hook runner that invokes ESLint.
52
+ - **Programmatic use** — available via the ESLint Node.js API (`new ESLint()`) for custom tooling.
53
+
54
+ ## Rules
55
+
56
+ | Rule | Description | Recommended |
57
+ |------|-------------|-------------|
58
+ | [`no-set-in-computed`](#no-set-in-computed) | Disallow `.set()` inside a `computed()` body | error |
59
+ | [`no-self-read-write`](#no-self-read-write) | Disallow reading and writing the same signal in the same reactive run | error |
60
+ | [`no-set-on-computed`](#no-set-on-computed) | Disallow `.set()` on a computed signal | error |
61
+ | [`no-new-signal-in-effect`](#no-new-signal-in-effect) | Disallow creating a new `signal()` inside an `effect()` body | error |
62
+ | [`no-effect-in-computed`](#no-effect-in-computed) | Disallow calling `effect()` inside a `computed()` body | error |
63
+ | [`no-signal-async-write`](#no-signal-async-write) | Disallow writing a signal in an async callback when it was read in the enclosing `effect()` | warn |
64
+ | [`no-ignored-effect-return`](#no-ignored-effect-return) | Require capturing the return value of `effect()` inside a function | warn |
65
+ | [`prefer-value-in-async`](#prefer-value-in-async) | Prefer `.value` over `.get()` inside async callbacks within an `effect()` | warn |
66
+ | [`no-new-computed-in-effect`](#no-new-computed-in-effect) | Disallow creating a new `computed()` inside an `effect()` body | error |
67
+ | [`no-new-signal-in-computed`](#no-new-signal-in-computed) | Disallow creating a new `signal()` inside a `computed()` body | error |
68
+ | [`no-unsafe-literal`](#no-unsafe-literal) | Disallow `.unsafeLiteral()` calls that bypass XSS protection | error |
69
+ | [`no-new-computed-in-computed`](#no-new-computed-in-computed) | Disallow creating a new `computed()` inside a `computed()` body | error |
70
+ | [`no-effect-in-effect`](#no-effect-in-effect) | Disallow creating a new `effect()` inside an `effect()` body | error |
71
+ | [`no-async-effect`](#no-async-effect) | Disallow async callbacks passed to `effect()` | error |
72
+ | [`no-async-computed`](#no-async-computed) | Disallow async callbacks passed to `computed()` | error |
73
+
74
+ ---
75
+
76
+ ### `no-set-in-computed`
77
+
78
+ Computed functions must be pure derivations. Calling `.set()` inside one causes a write during a read pass.
79
+
80
+ ```js
81
+ // Bad
82
+ const doubled = computed(() => {
83
+ sideEffect.set(true); // error
84
+ return count.get() * 2;
85
+ });
86
+
87
+ // Good — move the write into an effect
88
+ effect(() => {
89
+ sideEffect.set(doubled.get() > 10);
90
+ });
91
+ ```
92
+
93
+ ---
94
+
95
+ ### `no-self-read-write`
96
+
97
+ Reading a signal with `.get()` subscribes to it. Writing it with `.set()` in the same run re-triggers the run, creating an infinite loop.
98
+
99
+ ```js
100
+ // Bad
101
+ effect(() => {
102
+ const val = count.get();
103
+ count.set(val + 1); // error — triggers the effect again
104
+ });
105
+
106
+ // Good — use .value to read without subscribing
107
+ effect(() => {
108
+ const val = count.value;
109
+ count.set(val + 1);
110
+ });
111
+ ```
112
+
113
+ ---
114
+
115
+ ### `no-set-on-computed`
116
+
117
+ Computed signals are read-only. Kensington throws at runtime if you call `.set()` on one; this catches it statically.
118
+
119
+ ```js
120
+ const doubled = computed(() => count.get() * 2);
121
+ doubled.set(10); // error — use signal() for writable state
122
+ ```
123
+
124
+ ---
125
+
126
+ ### `no-new-signal-in-effect`
127
+
128
+ Each effect run creates a fresh signal with no cleanup path. The signal should be declared outside the effect.
129
+
130
+ ```js
131
+ // Bad
132
+ effect(() => {
133
+ const local = signal(0); // error — orphaned on every run
134
+ });
135
+
136
+ // Good
137
+ const local = signal(0);
138
+ effect(() => {
139
+ local.set(local.get() + 1);
140
+ });
141
+ ```
142
+
143
+ ---
144
+
145
+ ### `no-effect-in-computed`
146
+
147
+ Computed functions must be pure. An `effect()` call inside one runs on every re-evaluation and its handle is dropped, making cleanup impossible.
148
+
149
+ ```js
150
+ // Bad
151
+ const doubled = computed(() => {
152
+ effect(() => console.log('hi')); // error
153
+ return count.get() * 2;
154
+ });
155
+ ```
156
+
157
+ ---
158
+
159
+ ### `no-signal-async-write`
160
+
161
+ If a signal is read via `.get()` in an effect and then written in an async callback, the write re-triggers the effect after each async resolution.
162
+
163
+ ```js
164
+ // Bad
165
+ effect(() => {
166
+ const val = count.get(); // subscribes
167
+ setTimeout(() => {
168
+ count.set(val + 1); // error — re-triggers the effect
169
+ }, 100);
170
+ });
171
+
172
+ // Good — use .value to read without subscribing
173
+ effect(() => {
174
+ setTimeout(() => {
175
+ count.set(count.value + 1);
176
+ }, 100);
177
+ });
178
+ ```
179
+
180
+ ---
181
+
182
+ ### `no-ignored-effect-return`
183
+
184
+ `effect()` returns `{ pause, resume, stop }`. Discarding the return value inside a function makes cleanup impossible, leaking the subscription across calls.
185
+
186
+ ```js
187
+ // Bad
188
+ function setup() {
189
+ effect(() => console.log(count.get())); // warn — can't stop it
190
+ }
191
+
192
+ // Good
193
+ function setup() {
194
+ const fx = effect(() => console.log(count.get()));
195
+ return () => fx.stop();
196
+ }
197
+ ```
198
+
199
+ Module-level effects are intentionally long-lived and are not flagged.
200
+
201
+ ---
202
+
203
+ ### `prefer-value-in-async`
204
+
205
+ Once an effect's synchronous body completes, async callbacks run outside its reactive context. `.get()` registers no subscription there — `.value` makes that explicit.
206
+
207
+ ```js
208
+ // Bad
209
+ effect(() => {
210
+ fetch('/api').then(() => {
211
+ console.log(count.get()); // warn — no subscription is registered
212
+ });
213
+ });
214
+
215
+ // Good
216
+ effect(() => {
217
+ fetch('/api').then(() => {
218
+ console.log(count.value);
219
+ });
220
+ });
221
+ ```
222
+
223
+ ---
224
+
225
+ ### `no-new-computed-in-effect`
226
+
227
+ Creating `computed()` inside an `effect()` creates a new orphaned derived signal on every run. The previous one silently loses its subscriber with no cleanup.
228
+
229
+ ```js
230
+ // Bad
231
+ effect(() => {
232
+ const doubled = computed(() => count.get() * 2); // error — orphaned every run
233
+ console.log(doubled.get());
234
+ });
235
+
236
+ // Good
237
+ const doubled = computed(() => count.get() * 2);
238
+ effect(() => { console.log(doubled.get()); });
239
+ ```
240
+
241
+ ---
242
+
243
+ ### `no-new-signal-in-computed`
244
+
245
+ Creating `signal()` inside `computed()` creates a new orphaned signal on every recompute.
246
+
247
+ ```js
248
+ // Bad
249
+ const c = computed(() => {
250
+ const temp = signal(0); // error — orphaned every recompute
251
+ return temp.get() + base.get();
252
+ });
253
+
254
+ // Good
255
+ const temp = signal(0);
256
+ const c = computed(() => temp.get() + base.get());
257
+ ```
258
+
259
+ ---
260
+
261
+ ### `no-unsafe-literal`
262
+
263
+ `.unsafeLiteral()` injects raw HTML with no script-tag validation. Use `.literal()` instead, which validates the string before injecting it.
264
+
265
+ ```js
266
+ // Bad
267
+ t.unsafeLiteral(userContent); // error — bypasses XSS protection
268
+
269
+ // Good
270
+ t.literal(userContent);
271
+ ```
272
+
273
+ ---
274
+
275
+ ### `no-new-computed-in-computed`
276
+
277
+ Creating `computed()` inside a `computed()` body creates a new orphaned derived signal on every recompute.
278
+
279
+ ```js
280
+ // Bad
281
+ const outer = computed(() => {
282
+ const inner = computed(() => count.get() * 2); // error — orphaned every recompute
283
+ return inner.get() + 1;
284
+ });
285
+
286
+ // Good
287
+ const inner = computed(() => count.get() * 2);
288
+ const outer = computed(() => inner.get() + 1);
289
+ ```
290
+
291
+ ---
292
+
293
+ ### `no-effect-in-effect`
294
+
295
+ Creating `effect()` inside an `effect()` body means every re-run of the outer effect adds a new inner effect without stopping the previous one — subscriptions accumulate indefinitely. Capturing the return handle does not fix this; the previous handle would need to be explicitly stopped at the top of each run.
296
+
297
+ ```js
298
+ // Bad
299
+ effect(() => {
300
+ const items = list.get();
301
+ effect(() => console.log(items)); // error — previous inner effect never stopped
302
+ });
303
+
304
+ // Good — restructure as a single effect
305
+ effect(() => {
306
+ console.log(list.get());
307
+ });
308
+ ```
309
+
310
+ ---
311
+
312
+ ### `no-async-effect`
313
+
314
+ The effect system runs callbacks synchronously and ignores the returned `Promise`. Any `.get()` calls after the first `await` run outside the reactive context and register no subscription. Errors thrown inside the async body are also silently swallowed.
315
+
316
+ ```js
317
+ // Bad
318
+ effect(async () => { // error
319
+ const data = await fetch(`/api/${id.get()}`).then(r => r.json());
320
+ title.set(data.title); // runs outside reactive context
321
+ });
322
+
323
+ // Good — keep reactive reads synchronous, push async work into .then()
324
+ effect(() => {
325
+ fetch(`/api/${id.get()}`).then(r => r.json()).then(data => title.set(data.title));
326
+ });
327
+ ```
328
+
329
+ ---
330
+
331
+ ### `no-async-computed`
332
+
333
+ The reactive system runs `computed()` callbacks synchronously. An async callback returns a `Promise` immediately, so the computed value is always a `Promise` object rather than the intended derived value.
334
+
335
+ ```js
336
+ // Bad — computed value is a Promise, not the resolved data
337
+ const data = computed(async () => { // error
338
+ return await fetch('/api').then(r => r.json());
339
+ });
340
+ t.p(data); // renders "[object Promise]"
341
+
342
+ // Good — signal for the result, effect to populate it
343
+ const data = signal(null);
344
+ effect(() => {
345
+ fetch('/api').then(r => r.json()).then(v => data.set(v));
346
+ });
347
+ ```
package/index.js ADDED
@@ -0,0 +1,60 @@
1
+ import noSetInComputed from './rules/no-set-in-computed.js';
2
+ import noSelfReadWrite from './rules/no-self-read-write.js';
3
+ import noSignalAsyncWrite from './rules/no-signal-async-write.js';
4
+ import noSetOnComputed from './rules/no-set-on-computed.js';
5
+ import noNewSignalInEffect from './rules/no-new-signal-in-effect.js';
6
+ import noEffectInComputed from './rules/no-effect-in-computed.js';
7
+ import noIgnoredEffectReturn from './rules/no-ignored-effect-return.js';
8
+ import preferValueInAsync from './rules/prefer-value-in-async.js';
9
+ import noNewComputedInEffect from './rules/no-new-computed-in-effect.js';
10
+ import noNewSignalInComputed from './rules/no-new-signal-in-computed.js';
11
+ import noUnsafeLiteral from './rules/no-unsafe-literal.js';
12
+ import noNewComputedInComputed from './rules/no-new-computed-in-computed.js';
13
+ import noEffectInEffect from './rules/no-effect-in-effect.js';
14
+ import noAsyncEffect from './rules/no-async-effect.js';
15
+ import noAsyncComputed from './rules/no-async-computed.js';
16
+
17
+ const plugin = {
18
+ meta: { name: 'eslint-plugin-kensington' },
19
+ rules: {
20
+ 'no-set-in-computed': noSetInComputed,
21
+ 'no-self-read-write': noSelfReadWrite,
22
+ 'no-signal-async-write': noSignalAsyncWrite,
23
+ 'no-set-on-computed': noSetOnComputed,
24
+ 'no-new-signal-in-effect': noNewSignalInEffect,
25
+ 'no-effect-in-computed': noEffectInComputed,
26
+ 'no-ignored-effect-return': noIgnoredEffectReturn,
27
+ 'prefer-value-in-async': preferValueInAsync,
28
+ 'no-new-computed-in-effect': noNewComputedInEffect,
29
+ 'no-new-signal-in-computed': noNewSignalInComputed,
30
+ 'no-unsafe-literal': noUnsafeLiteral,
31
+ 'no-new-computed-in-computed': noNewComputedInComputed,
32
+ 'no-effect-in-effect': noEffectInEffect,
33
+ 'no-async-effect': noAsyncEffect,
34
+ 'no-async-computed': noAsyncComputed,
35
+ },
36
+ configs: {},
37
+ };
38
+
39
+ plugin.configs.recommended = {
40
+ plugins: { kensington: plugin },
41
+ rules: {
42
+ 'kensington/no-set-in-computed': 'error',
43
+ 'kensington/no-self-read-write': 'error',
44
+ 'kensington/no-signal-async-write': 'warn',
45
+ 'kensington/no-set-on-computed': 'error',
46
+ 'kensington/no-new-signal-in-effect': 'error',
47
+ 'kensington/no-effect-in-computed': 'error',
48
+ 'kensington/no-ignored-effect-return': 'warn',
49
+ 'kensington/prefer-value-in-async': 'warn',
50
+ 'kensington/no-new-computed-in-effect': 'error',
51
+ 'kensington/no-new-signal-in-computed': 'error',
52
+ 'kensington/no-unsafe-literal': 'error',
53
+ 'kensington/no-new-computed-in-computed': 'error',
54
+ 'kensington/no-effect-in-effect': 'error',
55
+ 'kensington/no-async-effect': 'error',
56
+ 'kensington/no-async-computed': 'error',
57
+ },
58
+ };
59
+
60
+ export default plugin;
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "kensington-eslint-plugin",
3
+ "version": "0.1.2",
4
+ "description": "ESLint rules for kensington signal correctness",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "test": "node --test tests/no-set-in-computed.test.js tests/no-self-read-write.test.js tests/no-signal-async-write.test.js tests/no-set-on-computed.test.js tests/no-new-signal-in-effect.test.js tests/no-effect-in-computed.test.js tests/no-ignored-effect-return.test.js tests/prefer-value-in-async.test.js tests/no-new-computed-in-effect.test.js tests/no-new-signal-in-computed.test.js tests/no-unsafe-literal.test.js tests/no-new-computed-in-computed.test.js tests/no-effect-in-effect.test.js tests/no-async-effect.test.js tests/no-async-computed.test.js"
9
+ },
10
+ "peerDependencies": {
11
+ "eslint": ">=9"
12
+ },
13
+ "devDependencies": {
14
+ "eslint": "^9.39.4"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/ryanlsimms/kensington-eslint-plugin"
22
+ },
23
+ "license": "ISC",
24
+ "files": [
25
+ "index.js",
26
+ "rules"
27
+ ]
28
+ }
@@ -0,0 +1,47 @@
1
+ // Reports computed() calls whose callback is declared async. The reactive system runs the
2
+ // callback synchronously — an async callback returns a Promise immediately, so the computed
3
+ // value is always a Promise object rather than the intended derived value.
4
+ export default {
5
+ meta: {
6
+ type: 'problem',
7
+ docs: {
8
+ description: 'disallow async callbacks passed to computed()',
9
+ },
10
+ messages: {
11
+ noAsyncComputed:
12
+ 'computed() callback is async. The reactive system runs callbacks synchronously, so the ' +
13
+ 'computed value will be a Promise rather than the intended derived value. ' +
14
+ 'Use a signal for the result and an effect() to populate it asynchronously instead.',
15
+ },
16
+ },
17
+
18
+ create(context) {
19
+ const computedNames = new Set();
20
+
21
+ return {
22
+ ImportDeclaration(node) {
23
+ if (node.source.value !== 'kensington') { return; }
24
+ for (const spec of node.specifiers) {
25
+ if (spec.type !== 'ImportSpecifier') { continue; }
26
+ if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
27
+ }
28
+ },
29
+
30
+ CallExpression(node) {
31
+ if (
32
+ node.callee.type !== 'Identifier' ||
33
+ !computedNames.has(node.callee.name) ||
34
+ node.arguments.length === 0
35
+ ) { return; }
36
+
37
+ const cb = node.arguments[0];
38
+ if (
39
+ (cb.type === 'ArrowFunctionExpression' || cb.type === 'FunctionExpression') &&
40
+ cb.async
41
+ ) {
42
+ context.report({ node, messageId: 'noAsyncComputed' });
43
+ }
44
+ },
45
+ };
46
+ },
47
+ };
@@ -0,0 +1,49 @@
1
+ // Reports effect() calls whose callback is declared async. The effect system runs the
2
+ // callback synchronously and ignores the returned Promise — any .get() calls after the
3
+ // first await run outside the reactive context and register no subscription. Errors thrown
4
+ // inside the async body are also silently swallowed.
5
+ export default {
6
+ meta: {
7
+ type: 'problem',
8
+ docs: {
9
+ description: 'disallow async callbacks passed to effect()',
10
+ },
11
+ messages: {
12
+ noAsyncEffect:
13
+ 'effect() callback is async. The effect system runs callbacks synchronously and ignores ' +
14
+ 'the returned Promise. Any signal reads after the first await register no subscription, ' +
15
+ 'and errors thrown inside the async body are silently swallowed. ' +
16
+ 'Move async work into a .then() chain inside a synchronous callback instead.',
17
+ },
18
+ },
19
+
20
+ create(context) {
21
+ const effectNames = new Set();
22
+
23
+ return {
24
+ ImportDeclaration(node) {
25
+ if (node.source.value !== 'kensington') { return; }
26
+ for (const spec of node.specifiers) {
27
+ if (spec.type !== 'ImportSpecifier') { continue; }
28
+ if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
29
+ }
30
+ },
31
+
32
+ CallExpression(node) {
33
+ if (
34
+ node.callee.type !== 'Identifier' ||
35
+ !effectNames.has(node.callee.name) ||
36
+ node.arguments.length === 0
37
+ ) { return; }
38
+
39
+ const cb = node.arguments[0];
40
+ if (
41
+ (cb.type === 'ArrowFunctionExpression' || cb.type === 'FunctionExpression') &&
42
+ cb.async
43
+ ) {
44
+ context.report({ node, messageId: 'noAsyncEffect' });
45
+ }
46
+ },
47
+ };
48
+ },
49
+ };
@@ -0,0 +1,72 @@
1
+ // Reports effect() called inside a computed() callback. Computed functions must be
2
+ // pure derivations — side effects inside them run on every re-evaluation and the
3
+ // returned effect handle is dropped, making cleanup impossible.
4
+ export default {
5
+ meta: {
6
+ type: 'problem',
7
+ docs: {
8
+ description: 'disallow calling effect() inside a computed() body',
9
+ },
10
+ messages: {
11
+ noEffectInComputed:
12
+ 'effect() called inside a computed() body. Computed functions must be pure derivations. ' +
13
+ 'Move the effect() call outside, or restructure using only signal reads.',
14
+ },
15
+ },
16
+
17
+ create(context) {
18
+ const effectNames = new Set();
19
+ const computedNames = new Set();
20
+ // Each entry is 'computed', 'effect', or 'other'.
21
+ const fnStack = [];
22
+
23
+ return {
24
+ ImportDeclaration(node) {
25
+ if (node.source.value !== 'kensington') { return; }
26
+ for (const spec of node.specifiers) {
27
+ if (spec.type !== 'ImportSpecifier') { continue; }
28
+ if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
29
+ if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
30
+ }
31
+ },
32
+
33
+ ':matches(ArrowFunctionExpression, FunctionExpression)'(node) {
34
+ const { parent } = node;
35
+ if (
36
+ parent.type === 'CallExpression' &&
37
+ parent.arguments[0] === node &&
38
+ parent.callee.type === 'Identifier'
39
+ ) {
40
+ if (computedNames.has(parent.callee.name)) {
41
+ fnStack.push('computed');
42
+ return;
43
+ }
44
+ if (effectNames.has(parent.callee.name)) {
45
+ fnStack.push('effect');
46
+ return;
47
+ }
48
+ }
49
+ fnStack.push('other');
50
+ },
51
+
52
+ ':matches(ArrowFunctionExpression, FunctionExpression):exit'() {
53
+ fnStack.pop();
54
+ },
55
+
56
+ CallExpression(node) {
57
+ if (
58
+ node.callee.type !== 'Identifier' ||
59
+ !effectNames.has(node.callee.name)
60
+ ) { return; }
61
+
62
+ for (let i = fnStack.length - 1; i >= 0; i--) {
63
+ if (fnStack[i] === 'computed') {
64
+ context.report({ node, messageId: 'noEffectInComputed' });
65
+ return;
66
+ }
67
+ if (fnStack[i] === 'effect') { return; }
68
+ }
69
+ },
70
+ };
71
+ },
72
+ };
@@ -0,0 +1,74 @@
1
+ // Reports effect() called inside an effect() callback. Every re-run of the outer effect
2
+ // creates a new inner effect — the previous one is never stopped, so subscriptions accumulate.
3
+ // Capturing the return handle does not help; you would need to call .stop() on the previous
4
+ // handle at the top of each run, which the no-ignored-effect-return rule does not enforce.
5
+ export default {
6
+ meta: {
7
+ type: 'problem',
8
+ docs: {
9
+ description: 'disallow creating a new effect() inside an effect() body',
10
+ },
11
+ messages: {
12
+ noEffectInEffect:
13
+ 'effect() called inside an effect() body. Each outer re-run creates a new inner effect ' +
14
+ 'without stopping the previous one, causing subscriptions to accumulate. ' +
15
+ 'Declare the effect at the same level or restructure using a single effect.',
16
+ },
17
+ },
18
+
19
+ create(context) {
20
+ const effectNames = new Set();
21
+ const computedNames = new Set();
22
+ // Each entry is 'effect', 'computed', or 'other' — innermost frame is last.
23
+ const fnStack = [];
24
+
25
+ return {
26
+ ImportDeclaration(node) {
27
+ if (node.source.value !== 'kensington') { return; }
28
+ for (const spec of node.specifiers) {
29
+ if (spec.type !== 'ImportSpecifier') { continue; }
30
+ if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
31
+ if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
32
+ }
33
+ },
34
+
35
+ ':matches(ArrowFunctionExpression, FunctionExpression)'(node) {
36
+ const { parent } = node;
37
+ if (
38
+ parent.type === 'CallExpression' &&
39
+ parent.arguments[0] === node &&
40
+ parent.callee.type === 'Identifier'
41
+ ) {
42
+ if (effectNames.has(parent.callee.name)) {
43
+ fnStack.push('effect');
44
+ return;
45
+ }
46
+ if (computedNames.has(parent.callee.name)) {
47
+ fnStack.push('computed');
48
+ return;
49
+ }
50
+ }
51
+ fnStack.push('other');
52
+ },
53
+
54
+ ':matches(ArrowFunctionExpression, FunctionExpression):exit'() {
55
+ fnStack.pop();
56
+ },
57
+
58
+ CallExpression(node) {
59
+ if (
60
+ node.callee.type !== 'Identifier' ||
61
+ !effectNames.has(node.callee.name)
62
+ ) { return; }
63
+
64
+ for (let i = fnStack.length - 1; i >= 0; i--) {
65
+ if (fnStack[i] === 'effect') {
66
+ context.report({ node, messageId: 'noEffectInEffect' });
67
+ return;
68
+ }
69
+ if (fnStack[i] === 'computed') { return; }
70
+ }
71
+ },
72
+ };
73
+ },
74
+ };