kensington-eslint-plugin 0.3.2 → 0.5.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/README.md +174 -36
- package/bin/check-reactive.js +611 -0
- package/index.js +25 -1
- package/package.json +11 -4
- package/rules/no-helper-function-trap.js +278 -0
- package/rules/no-new-computed-in-computed.js +33 -11
- package/rules/no-out-of-scope-reactive-reference.js +160 -0
- package/rules/require-reactive-key.js +96 -0
package/README.md
CHANGED
|
@@ -42,6 +42,24 @@ export default [
|
|
|
42
42
|
];
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
The `strict` config opts in to maximum-safety reactive correctness. It extends `recommended`, promotes every reactive-correctness `warn` rule to `error`, and adds two extra rules:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
import kensington from 'kensington-eslint-plugin';
|
|
49
|
+
|
|
50
|
+
export default [
|
|
51
|
+
kensington.configs.strict,
|
|
52
|
+
// ...your other configs
|
|
53
|
+
];
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
What `strict` changes on top of `recommended`:
|
|
57
|
+
|
|
58
|
+
- **Adds `require-reactive-key`** (error). Paranoid mode. Flags every unkeyed `signal()`/`computed()`/`.transform()` call site, period. Not in `recommended` at any level. Keys are no-ops at module scope and required inside reactive scopes, so passing one always is safer than auditing call-site reachability. Suppress per call site with `eslint-disable-next-line kensington/require-reactive-key` when a top-level signal is known never to move into a reactive scope.
|
|
59
|
+
- **Promotes from `warn` to `error`**. `no-signal-async-write`, `no-ignored-effect-return`, `prefer-value-in-async`, `no-new-computed-in-computed`, `no-out-of-scope-reactive-reference`, `no-helper-function-trap`. All real reactive-correctness issues; strict mode chooses zero silent misses over tolerance of false positives.
|
|
60
|
+
|
|
61
|
+
Use `strict` if you want CI to fail on any reactive-correctness issue, or if you're using an agent-driven workflow that benefits from harder enforcement. Use `recommended` for production codebases that prefer the warnings as guidance.
|
|
62
|
+
|
|
45
63
|
The `style` config is opt-in and bundles the formatting rules at `warn` level:
|
|
46
64
|
|
|
47
65
|
```js
|
|
@@ -85,31 +103,34 @@ Because this is a standard ESLint plugin, it works anywhere ESLint runs with no
|
|
|
85
103
|
|
|
86
104
|
## Rules
|
|
87
105
|
|
|
88
|
-
| Rule | Description | Recommended |
|
|
89
|
-
|
|
90
|
-
| [`no-set-in-derivation`](#no-set-in-derivation) | Disallow `.set()` inside a `computed()` body or `.transform()` callback | error |
|
|
91
|
-
| [`no-self-read-write`](#no-self-read-write) | Disallow reading and writing the same signal in the same reactive run | error |
|
|
92
|
-
| [`no-set-on-derived-signal`](#no-set-on-derived-signal) | Disallow `.set()` on a derived (computed or transform) signal | error |
|
|
93
|
-
| [`no-new-signal-in-effect`](#no-new-signal-in-effect) | Disallow creating a new `signal()` inside an `effect()` body | error |
|
|
94
|
-
| [`no-effect-in-computed`](#no-effect-in-computed) | Disallow calling `effect()` inside a `computed()` body | error |
|
|
95
|
-
| [`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 |
|
|
96
|
-
| [`no-ignored-effect-return`](#no-ignored-effect-return) | Require capturing the return value of `effect()` inside a function | warn |
|
|
97
|
-
| [`prefer-value-in-async`](#prefer-value-in-async) | Prefer `.value` over `.get()` inside async callbacks within an `effect()` | warn |
|
|
98
|
-
| [`no-new-computed-in-effect`](#no-new-computed-in-effect) | Disallow creating a new `computed()` inside an `effect()` body | error |
|
|
99
|
-
| [`no-new-signal-in-computed`](#no-new-signal-in-computed) |
|
|
100
|
-
| [`no-unsafe-literal`](#no-unsafe-literal) | Disallow `.unsafeLiteral()` calls that bypass XSS protection | error |
|
|
101
|
-
| [`no-new-computed-in-computed`](#no-new-computed-in-computed) |
|
|
102
|
-
| [`no-
|
|
103
|
-
| [`no-
|
|
104
|
-
| [`no-async-
|
|
105
|
-
| [`
|
|
106
|
-
| [`
|
|
107
|
-
| [`
|
|
108
|
-
| [`prefer-
|
|
109
|
-
| [`prefer-
|
|
110
|
-
| [`
|
|
111
|
-
| [`
|
|
112
|
-
| [`
|
|
106
|
+
| Rule | Description | Recommended | Strict |
|
|
107
|
+
|------|-------------|-------------|--------|
|
|
108
|
+
| [`no-set-in-derivation`](#no-set-in-derivation) | Disallow `.set()` inside a `computed()` body or `.transform()` callback | error | error |
|
|
109
|
+
| [`no-self-read-write`](#no-self-read-write) | Disallow reading and writing the same signal in the same reactive run | error | error |
|
|
110
|
+
| [`no-set-on-derived-signal`](#no-set-on-derived-signal) | Disallow `.set()` on a derived (computed or transform) signal | error | error |
|
|
111
|
+
| [`no-new-signal-in-effect`](#no-new-signal-in-effect) | Disallow creating a new `signal()` inside an `effect()` body | error | error |
|
|
112
|
+
| [`no-effect-in-computed`](#no-effect-in-computed) | Disallow calling `effect()` inside a `computed()` body | error | error |
|
|
113
|
+
| [`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 | error |
|
|
114
|
+
| [`no-ignored-effect-return`](#no-ignored-effect-return) | Require capturing the return value of `effect()` inside a function | warn | error |
|
|
115
|
+
| [`prefer-value-in-async`](#prefer-value-in-async) | Prefer `.value` over `.get()` inside async callbacks within an `effect()` | warn | error |
|
|
116
|
+
| [`no-new-computed-in-effect`](#no-new-computed-in-effect) | Disallow creating a new `computed()` inside an `effect()` body | error | error |
|
|
117
|
+
| [`no-new-signal-in-computed`](#no-new-signal-in-computed) | Require a stable key for `signal()` calls inside a `computed()` body | error | error |
|
|
118
|
+
| [`no-unsafe-literal`](#no-unsafe-literal) | Disallow `.unsafeLiteral()` calls that bypass XSS protection | error | error |
|
|
119
|
+
| [`no-new-computed-in-computed`](#no-new-computed-in-computed) | Require a stable key for `computed()` and `.transform()` calls inside a `computed()` body | warn | error |
|
|
120
|
+
| [`no-out-of-scope-reactive-reference`](#no-out-of-scope-reactive-reference) | Disallow referencing a `signal()`, `computed()`, or `.transform()` from outside the computed scope where it was created | warn | error |
|
|
121
|
+
| [`no-effect-in-effect`](#no-effect-in-effect) | Disallow creating a new `effect()` inside an `effect()` body | error | error |
|
|
122
|
+
| [`no-async-effect`](#no-async-effect) | Disallow async callbacks passed to `effect()` | error | error |
|
|
123
|
+
| [`no-async-computed`](#no-async-computed) | Disallow async callbacks passed to `computed()` | error | error |
|
|
124
|
+
| [`no-helper-function-trap`](#no-helper-function-trap) | Require a stable key for `signal()`/`computed()`/`.transform()` inside helpers reachable from a reactive callback in the same file | warn | error |
|
|
125
|
+
| [`require-reactive-key`](#require-reactive-key) | Require a stable key on every `signal()`/`computed()`/`.transform()` call site, regardless of context | off | error |
|
|
126
|
+
| [`prefer-boolean-attribute-true`](#prefer-boolean-attribute-true) | Prefer `true` over `''` for boolean HTML attributes | style | style |
|
|
127
|
+
| [`prefer-camelcase-attrs`](#prefer-camelcase-attrs) | Prefer camelCase identifier keys over quoted kebab-case | style | style |
|
|
128
|
+
| [`prefer-style-object`](#prefer-style-object) | Prefer a `style` object over a CSS string | style | style |
|
|
129
|
+
| [`prefer-nested-attr-groups`](#prefer-nested-attr-groups) | Prefer nested form when attrs share a kebab prefix | style | style |
|
|
130
|
+
| [`prefer-array-for-multiline-content`](#prefer-array-for-multiline-content) | Require array brackets around multi-line tag content | style | style |
|
|
131
|
+
| [`attrs-on-call-line`](#attrs-on-call-line) | Attributes object must hug the tag call on both ends | style | style |
|
|
132
|
+
| [`attrs-canonical-shape`](#attrs-canonical-shape) | Attributes object must be inline or canonically stacked | style | style |
|
|
133
|
+
| [`consistent-content-layout`](#consistent-content-layout) | Tag content must hug the attrs `}` and the call's `)` | style | style |
|
|
113
134
|
|
|
114
135
|
---
|
|
115
136
|
|
|
@@ -325,18 +346,86 @@ t.literal(userContent);
|
|
|
325
346
|
|
|
326
347
|
### `no-new-computed-in-computed`
|
|
327
348
|
|
|
328
|
-
Creating `computed()` inside a `computed()` body creates a new orphaned derived signal on every recompute.
|
|
349
|
+
Creating `computed()` or `.transform()` inside a `computed()` body without a key creates a new orphaned derived signal on every recompute. Pass a stable key as the second argument to reuse the same instance across outer re-runs.
|
|
329
350
|
|
|
330
351
|
```js
|
|
331
|
-
// Bad
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
});
|
|
352
|
+
// Bad. New instance on every outer re-run, inner state lost
|
|
353
|
+
const list = computed(() =>
|
|
354
|
+
items.get().map(item => {
|
|
355
|
+
const cls = computed(() => filter.get() === item.cat ? 'on' : ''); // warn
|
|
356
|
+
return t.li({ dataKey: item.id, class: cls }, item.name);
|
|
357
|
+
})
|
|
358
|
+
);
|
|
336
359
|
|
|
337
|
-
//
|
|
338
|
-
const
|
|
339
|
-
|
|
360
|
+
// Bad. Same problem with .transform()
|
|
361
|
+
const list = computed(() =>
|
|
362
|
+
items.get().map(item =>
|
|
363
|
+
t.li({ dataKey: item.id, class: filter.transform(f => f === item.cat ? 'on' : '') }, item.name) // warn
|
|
364
|
+
)
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
// Good. Keyed computed
|
|
368
|
+
const list = computed(() =>
|
|
369
|
+
items.get().map(item =>
|
|
370
|
+
t.li({ dataKey: item.id, class: computed(() => filter.get() === item.cat ? 'on' : '', item.id) }, item.name)
|
|
371
|
+
)
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
// Good. Keyed transform
|
|
375
|
+
const list = computed(() =>
|
|
376
|
+
items.get().map(item =>
|
|
377
|
+
t.li({ dataKey: item.id, class: filter.transform(f => f === item.cat ? 'on' : '', item.id) }, item.name)
|
|
378
|
+
)
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// Also good. Declare outside when fn has no per-item closure
|
|
382
|
+
const upper = computed(() => name.get().toUpperCase());
|
|
383
|
+
const outer = computed(() => upper.get() + '!');
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
### `no-out-of-scope-reactive-reference`
|
|
389
|
+
|
|
390
|
+
A reactive primitive (`signal()`, `computed()`, or `.transform()`) created inside a `computed()` body is owned by the surrounding computed. The owner can stop it at any time. When a re-run doesn't access the key, the instance is swept from the registry and stopped. Any reference held outside the owner's scope silently drops subscribers and produces out-of-sync state.
|
|
391
|
+
|
|
392
|
+
Two inline-consumption patterns are safe and allowed:
|
|
393
|
+
|
|
394
|
+
1. The result is consumed by an immediate method chain (`.get()`, `.transform()`, `.toString()`, etc.). The chain consumes the instance; the instance itself never escapes.
|
|
395
|
+
2. The result is passed directly to a tag call as content or an attribute value. The DOM binding effect created by `toElement()` is part of the owner's own render cycle, so its lifetime is tied to the DOM.
|
|
396
|
+
|
|
397
|
+
```js
|
|
398
|
+
// Bad. Instance escapes via module-level cache; external code can hold a dead signal
|
|
399
|
+
const editingSignals = new Map();
|
|
400
|
+
const list = computed(() =>
|
|
401
|
+
items.get().map(item => {
|
|
402
|
+
const editing = signal(false, item.id);
|
|
403
|
+
editingSignals.set(item.id, editing); // warn
|
|
404
|
+
return t.li({ dataKey: item.id, class: editing.transform(v => v ? 'on' : '') }, item.name);
|
|
405
|
+
})
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// Bad. Instance returned from map; consumers see a stale signal after a sweep
|
|
409
|
+
const list = computed(() =>
|
|
410
|
+
items.get().map(item => computed(() => item.v * 2, item.id)) // warn
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
// Good. Consumed via method chain
|
|
414
|
+
const list = computed(() =>
|
|
415
|
+
items.get().map(item =>
|
|
416
|
+
computed(() => filter.get() === item.cat ? 'on' : '', item.id).get()
|
|
417
|
+
)
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
// Good. Passed directly to a tag; DOM binding owns lifetime
|
|
421
|
+
const list = computed(() =>
|
|
422
|
+
items.get().map(item =>
|
|
423
|
+
t.li({
|
|
424
|
+
dataKey: item.id,
|
|
425
|
+
class: computed(() => filter.get() === item.cat ? 'on' : '', item.id),
|
|
426
|
+
}, item.name)
|
|
427
|
+
)
|
|
428
|
+
);
|
|
340
429
|
```
|
|
341
430
|
|
|
342
431
|
---
|
|
@@ -399,6 +488,55 @@ effect(() => {
|
|
|
399
488
|
|
|
400
489
|
---
|
|
401
490
|
|
|
491
|
+
### `no-helper-function-trap`
|
|
492
|
+
|
|
493
|
+
Catches the call-stack version of the helper-function trap that the existing `no-new-signal-in-computed` and `no-new-computed-in-computed` rules miss. Those rules only flag lexical positions (the call is written directly inside a `computed(() => ...)` body in the source). This rule does single-file call-graph analysis. For every `signal()`/`computed()`/`.transform()` call without a key inside a named function, the rule checks whether that function is reachable (directly or transitively) from a reactive callback in the same file. Reactive callbacks recognized. function args to `computed(fn)`, `effect(fn)`, `signal.transform(fn)`, and `signal.mapWithKey(key, fn)`. Both inline arrow callbacks (`mapWithKey('id', x => row(x))`) and bare-identifier callbacks (`mapWithKey('id', row)`) are recognized.
|
|
494
|
+
|
|
495
|
+
```js
|
|
496
|
+
// Bad. row() is a plain helper, so signal() looks top-level in the source,
|
|
497
|
+
// but row() is called from inside mapWithKey's mapFn. The signal runs in the
|
|
498
|
+
// per-key computed at runtime.
|
|
499
|
+
function row(item) {
|
|
500
|
+
const highlight = signal(false);
|
|
501
|
+
return t.li({ class: highlight }, item.name);
|
|
502
|
+
}
|
|
503
|
+
const list = items.mapWithKey('id', item => row(item));
|
|
504
|
+
|
|
505
|
+
// Good. Key scopes the signal to the surrounding computed so the same
|
|
506
|
+
// instance is reused across re-runs.
|
|
507
|
+
function row(item) {
|
|
508
|
+
const highlight = signal(false, item.id);
|
|
509
|
+
return t.li({ class: highlight }, item.name);
|
|
510
|
+
}
|
|
511
|
+
const list = items.mapWithKey('id', item => row(item));
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Single-file analysis only. A helper defined in `cell.ts` and called from a reactive callback in `grid.ts` is NOT flagged by this rule on `cell.ts` (the call site is invisible). For cross-file coverage use `require-reactive-key`, which flags every unkeyed call site regardless of context.
|
|
515
|
+
|
|
516
|
+
False-positive surface. Helpers reachable from a reactive callback are flagged, even if they are ALSO called from non-reactive sites. The conservative choice is correct: if any call path enters a reactive scope, the key is needed.
|
|
517
|
+
|
|
518
|
+
---
|
|
519
|
+
|
|
520
|
+
### `require-reactive-key`
|
|
521
|
+
|
|
522
|
+
Paranoid mode. Flags every unkeyed `signal()`/`computed()`/`.transform()` call site, full stop. Keys are no-ops at module scope (the key argument is ignored when not inside a reactive scope) and required inside reactive scopes, so passing one always is safer than auditing call-site reachability.
|
|
523
|
+
|
|
524
|
+
```js
|
|
525
|
+
// Flagged. Pass a key.
|
|
526
|
+
const count = signal(0);
|
|
527
|
+
const doubled = computed(() => count.get() * 2);
|
|
528
|
+
const half = count.transform(v => v / 2);
|
|
529
|
+
|
|
530
|
+
// Suppress per call site if the call is genuinely top-level and you do not
|
|
531
|
+
// want the noise.
|
|
532
|
+
// eslint-disable-next-line kensington/require-reactive-key
|
|
533
|
+
const theme = signal('light');
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
Off in the recommended config (too noisy for production codebases that legitimately scatter top-level signals). On in the `strict` config. Intended for agent-driven workflows, refactor-prone codebases, and projects that want maximum safety against later lifting code into a reactive callback.
|
|
537
|
+
|
|
538
|
+
---
|
|
539
|
+
|
|
402
540
|
### `prefer-boolean-attribute-true`
|
|
403
541
|
|
|
404
542
|
The HTML spec lists ~30 boolean attributes (`disabled`, `checked`, `hidden`, `selected`, etc.). Kensington treats `true` as "present" and `false`/`null`/`undefined` as "absent". An empty string is a confusing way to spell the same thing.
|
|
@@ -470,7 +608,7 @@ Auto-fixable when the group's members are contiguous in the source. Non-contiguo
|
|
|
470
608
|
|
|
471
609
|
### `prefer-array-for-multiline-content`
|
|
472
610
|
|
|
473
|
-
Mirrors what `html-to-kensington` emits: when a tag's content occupies its own line(s)
|
|
611
|
+
Mirrors what `html-to-kensington` emits: when a tag's content occupies its own line(s). Separated from both the opening and closing paren. It goes in an array, even when it's the only item. The array form makes line-by-line edits easier (no need to add `[ ]` when adding a sibling).
|
|
474
612
|
|
|
475
613
|
```js
|
|
476
614
|
// Bad
|
|
@@ -483,7 +621,7 @@ t.div({ class: 'x' }, [
|
|
|
483
621
|
t.p('only'),
|
|
484
622
|
]);
|
|
485
623
|
|
|
486
|
-
// Also good
|
|
624
|
+
// Also good. Content trails on the closing-paren line, no array needed
|
|
487
625
|
t.a({
|
|
488
626
|
href: 'https://example.com',
|
|
489
627
|
target: '_blank',
|