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 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) | Disallow creating a new `signal()` inside a `computed()` body | error |
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) | Disallow creating a new `computed()` inside a `computed()` body | error |
102
- | [`no-effect-in-effect`](#no-effect-in-effect) | Disallow creating a new `effect()` inside an `effect()` body | error |
103
- | [`no-async-effect`](#no-async-effect) | Disallow async callbacks passed to `effect()` | error |
104
- | [`no-async-computed`](#no-async-computed) | Disallow async callbacks passed to `computed()` | error |
105
- | [`prefer-boolean-attribute-true`](#prefer-boolean-attribute-true) | Prefer `true` over `''` for boolean HTML attributes | style |
106
- | [`prefer-camelcase-attrs`](#prefer-camelcase-attrs) | Prefer camelCase identifier keys over quoted kebab-case | style |
107
- | [`prefer-style-object`](#prefer-style-object) | Prefer a `style` object over a CSS string | style |
108
- | [`prefer-nested-attr-groups`](#prefer-nested-attr-groups) | Prefer nested form when attrs share a kebab prefix | style |
109
- | [`prefer-array-for-multiline-content`](#prefer-array-for-multiline-content) | Require array brackets around multi-line tag content | style |
110
- | [`attrs-on-call-line`](#attrs-on-call-line) | Attributes object must hug the tag call on both ends | style |
111
- | [`attrs-canonical-shape`](#attrs-canonical-shape) | Attributes object must be inline or canonically stacked | style |
112
- | [`consistent-content-layout`](#consistent-content-layout) | Tag content must hug the attrs `}` and the call's `)` | style |
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 outer = computed(() => {
333
- const inner = computed(() => count.get() * 2); // error. Orphaned every recompute.
334
- return inner.get() + 1;
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
- // Good
338
- const inner = computed(() => count.get() * 2);
339
- const outer = computed(() => inner.get() + 1);
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) 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).
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 content trails on the closing-paren line, no array needed
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',