kensington-eslint-plugin 0.5.1 → 0.6.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 +93 -4
- package/bin/check-reactive.js +212 -25
- package/index.js +11 -3
- package/package.json +2 -2
- package/rules/_utils.js +14 -0
- package/rules/no-async-set.js +50 -0
- package/rules/no-helper-function-trap.js +38 -11
- package/rules/no-new-computed-in-computed.js +22 -12
- package/rules/no-new-signal-in-computed.js +41 -14
- package/rules/no-new-signal-in-effect.js +35 -15
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ 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
|
|
45
|
+
The `strict` config opts in to maximum-safety reactive correctness. It extends `recommended`, promotes every reactive-correctness `warn` rule to `error`, and adds `no-helper-function-trap` at `error` (single-file call-graph analysis that catches unkeyed `signal()` / `computed()` / `.transform()` inside helpers reachable from a reactive callback — the trap most lexical rules miss).
|
|
46
46
|
|
|
47
47
|
```js
|
|
48
48
|
import kensington from 'kensington-eslint-plugin';
|
|
@@ -55,11 +55,22 @@ export default [
|
|
|
55
55
|
|
|
56
56
|
What `strict` changes on top of `recommended`:
|
|
57
57
|
|
|
58
|
-
- **Adds `
|
|
59
|
-
- **Promotes from `warn` to `error
|
|
58
|
+
- **Adds `no-helper-function-trap`** (error). The most valuable single rule the plugin ships.
|
|
59
|
+
- **Promotes from `warn` to `error`**: `no-signal-async-write`, `no-ignored-effect-return`, `prefer-value-in-async`, `no-out-of-scope-reactive-reference`. Real reactive-correctness issues; strict mode chooses zero silent misses over tolerance of false positives.
|
|
60
60
|
|
|
61
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
62
|
|
|
63
|
+
### Optional: `require-reactive-key`
|
|
64
|
+
|
|
65
|
+
A separate rule, not enabled by either config. Flags every unkeyed `signal()` / `computed()` / `.transform()` call site, period — including at module scope. Keys are no-ops at module scope and required inside reactive scopes, so passing one always is safer than auditing call-site reachability. But in practice the rule generates mechanical retrofits that don't catch real bugs (the keys it forces at module scope have no runtime effect). Opt in explicitly when you want refactor-safety enforcement so that a later lift into a reactive scope finds the key already in place:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
export default [
|
|
69
|
+
kensington.configs.strict,
|
|
70
|
+
{ rules: { 'kensington/require-reactive-key': 'error' } },
|
|
71
|
+
];
|
|
72
|
+
```
|
|
73
|
+
|
|
63
74
|
The `style` config is opt-in and bundles the formatting rules at `warn` level:
|
|
64
75
|
|
|
65
76
|
```js
|
|
@@ -116,11 +127,12 @@ Because this is a standard ESLint plugin, it works anywhere ESLint runs with no
|
|
|
116
127
|
| [`no-new-computed-in-effect`](#no-new-computed-in-effect) | Disallow creating a new `computed()` inside an `effect()` body | error | error |
|
|
117
128
|
| [`no-new-signal-in-computed`](#no-new-signal-in-computed) | Require a stable key for `signal()` calls inside a `computed()` body | error | error |
|
|
118
129
|
| [`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 |
|
|
130
|
+
| [`no-new-computed-in-computed`](#no-new-computed-in-computed) | Require a stable key for `computed()` and `.transform()` calls inside a `computed()` body (**deprecated**. use `no-out-of-scope-reactive-reference`) | off | off |
|
|
120
131
|
| [`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
132
|
| [`no-effect-in-effect`](#no-effect-in-effect) | Disallow creating a new `effect()` inside an `effect()` body | error | error |
|
|
122
133
|
| [`no-async-effect`](#no-async-effect) | Disallow async callbacks passed to `effect()` | error | error |
|
|
123
134
|
| [`no-async-computed`](#no-async-computed) | Disallow async callbacks passed to `computed()` | error | error |
|
|
135
|
+
| [`no-async-set`](#no-async-set) | Disallow passing an async function to `.set()` on a signal | error | error |
|
|
124
136
|
| [`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
137
|
| [`require-reactive-key`](#require-reactive-key) | Require a stable key on every `signal()`/`computed()`/`.transform()` call site, regardless of context | off | error |
|
|
126
138
|
| [`prefer-boolean-attribute-true`](#prefer-boolean-attribute-true) | Prefer `true` over `''` for boolean HTML attributes | style | style |
|
|
@@ -212,6 +224,21 @@ effect(() => {
|
|
|
212
224
|
});
|
|
213
225
|
```
|
|
214
226
|
|
|
227
|
+
Also fires for `liveSignal()` imports from `kensington/live`, `kensington/live/client`, or `kensington/live/server`. The lazy-registry creation on first sight happens inside the effect's reactive scope and trips the runtime warning.
|
|
228
|
+
|
|
229
|
+
```js
|
|
230
|
+
// Bad
|
|
231
|
+
import { effect } from 'kensington';
|
|
232
|
+
import { liveSignal } from 'kensington/live';
|
|
233
|
+
effect(() => {
|
|
234
|
+
const c = liveSignal(0, 'counter'); // error. Registry lookup inside reactive scope.
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Good. Declare at module scope, or eager-seed via queueMicrotask outside.
|
|
238
|
+
const c = liveSignal(0, 'counter');
|
|
239
|
+
effect(() => { c.set(c.get() + 1); });
|
|
240
|
+
```
|
|
241
|
+
|
|
215
242
|
---
|
|
216
243
|
|
|
217
244
|
### `no-effect-in-computed`
|
|
@@ -328,6 +355,21 @@ const temp = signal(0);
|
|
|
328
355
|
const c = computed(() => temp.get() + base.get());
|
|
329
356
|
```
|
|
330
357
|
|
|
358
|
+
Also fires for `liveSignal()` calls inside a `computed()` body, from any of `kensington/live`, `kensington/live/client`, or `kensington/live/server`. liveSignal's second argument is always a name (not optional), so the rule fires regardless of args length. The trap is the first-call lazy creation inside the reactive scope.
|
|
359
|
+
|
|
360
|
+
```js
|
|
361
|
+
// Bad
|
|
362
|
+
import { computed } from 'kensington';
|
|
363
|
+
import { liveSignal } from 'kensington/live';
|
|
364
|
+
const c = computed(() => {
|
|
365
|
+
return liveSignal(0, 'foo').get(); // error. Lazy-registry creation inside reactive scope.
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Good. Declare at module scope, or eager-seed via queueMicrotask outside.
|
|
369
|
+
const foo = liveSignal(0, 'foo');
|
|
370
|
+
const c = computed(() => foo.get());
|
|
371
|
+
```
|
|
372
|
+
|
|
331
373
|
---
|
|
332
374
|
|
|
333
375
|
### `no-unsafe-literal`
|
|
@@ -346,6 +388,10 @@ t.literal(userContent);
|
|
|
346
388
|
|
|
347
389
|
### `no-new-computed-in-computed`
|
|
348
390
|
|
|
391
|
+
**Deprecated.** Removed from the `recommended` and `strict` configs. The kensington runtime now defers the `computed-in-computed` and `transform-in-computed` warnings to subscription time and only fires when a user `effect` or user `computed` subscribes to the inner. Inline consumption as an attribute, class, text, or prop slot is silent by design, so the purely-lexical flag this rule emitted became mostly false positives. The remaining real concern — a nested inner that escapes the surrounding computed's scope — is covered by [`no-out-of-scope-reactive-reference`](#no-out-of-scope-reactive-reference), which uses a full escape classifier. Teams that want every unkeyed call site flagged can opt into [`require-reactive-key`](#require-reactive-key).
|
|
392
|
+
|
|
393
|
+
The rule stays registered so existing configs that reference it don't error, but enabling it is no longer recommended.
|
|
394
|
+
|
|
349
395
|
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.
|
|
350
396
|
|
|
351
397
|
```js
|
|
@@ -488,6 +534,25 @@ effect(() => {
|
|
|
488
534
|
|
|
489
535
|
---
|
|
490
536
|
|
|
537
|
+
### `no-async-set`
|
|
538
|
+
|
|
539
|
+
`.set()` stores whatever value the updater returns. An async function returns a `Promise` immediately, so the Promise object itself becomes the signal's value. Reads then see a `Thenable` where consumers expect `T`. For a `liveSignal`, the Promise serializes to `"{}"` on the wire, corrupting every subscriber's view.
|
|
540
|
+
|
|
541
|
+
The pattern almost always means "do async work then update the signal". Await the async work first and then call `.set(resolvedValue)`, or schedule the write from an `effect()`.
|
|
542
|
+
|
|
543
|
+
```js
|
|
544
|
+
// Bad. Signal value becomes a Promise, not the resolved user.
|
|
545
|
+
user.set(async (prev) => { // error
|
|
546
|
+
return await fetchUser(prev.id);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// Good. Resolve first, then set the final value.
|
|
550
|
+
const next = await fetchUser(user.value.id);
|
|
551
|
+
user.set(next);
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
---
|
|
555
|
+
|
|
491
556
|
### `no-helper-function-trap`
|
|
492
557
|
|
|
493
558
|
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.
|
|
@@ -515,6 +580,30 @@ Single-file analysis only. A helper defined in `cell.ts` and called from a react
|
|
|
515
580
|
|
|
516
581
|
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
582
|
|
|
583
|
+
Also fires for `liveSignal()` calls inside such helpers, from any of `kensington/live`, `kensington/live/client`, or `kensington/live/server`. The trap is the same shape (first-call lazy creation inside the surrounding reactive scope), and liveSignal's second argument is always a name (not optional), so the rule fires regardless of args length. The fix is to declare the liveSignal at module scope, or eager-seed it via `queueMicrotask` outside the reactive scope.
|
|
584
|
+
|
|
585
|
+
```js
|
|
586
|
+
// Bad. getRow is a lazy registry; the first call for a given id creates the
|
|
587
|
+
// transport entry inside the per-key computed run by mapWithKey.
|
|
588
|
+
import { liveSignal } from 'kensington/live';
|
|
589
|
+
function getRow(id) { return liveSignal(0, 'row:' + id); }
|
|
590
|
+
const list = items.mapWithKey('id', item => getRow(item.id));
|
|
591
|
+
|
|
592
|
+
// Good. Eager-seed once outside the reactive scope.
|
|
593
|
+
const rows = new Map();
|
|
594
|
+
function getRow(id) {
|
|
595
|
+
let s = rows.get(id);
|
|
596
|
+
if (s === undefined) { s = liveSignal(0, 'row:' + id); rows.set(id, s); }
|
|
597
|
+
return s;
|
|
598
|
+
}
|
|
599
|
+
// In an addConnectedCallback or top-of-component effect:
|
|
600
|
+
effect(() => {
|
|
601
|
+
const ids = items.get().map(i => i.id);
|
|
602
|
+
queueMicrotask(() => { for (const id of ids) { getRow(id); } });
|
|
603
|
+
});
|
|
604
|
+
const list = items.mapWithKey('id', item => getRow(item.id));
|
|
605
|
+
```
|
|
606
|
+
|
|
518
607
|
---
|
|
519
608
|
|
|
520
609
|
### `require-reactive-key`
|
package/bin/check-reactive.js
CHANGED
|
@@ -1,32 +1,114 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// kensington-check-reactive
|
|
3
|
+
// ===========================================================================
|
|
3
4
|
//
|
|
4
5
|
// EXPERIMENTAL. NOT YET RELEASED.
|
|
5
6
|
// This binary ships in the published package but is intentionally not
|
|
6
7
|
// documented in README.md or CHANGELOG.md. The CLI flags, output format,
|
|
7
|
-
// suppression-comment syntax, presence in the package, and
|
|
8
|
-
// may change or be removed in any future release without
|
|
9
|
-
// build tooling on top of it yet. The first release that
|
|
10
|
-
// tool in README.md is the release that commits to its
|
|
8
|
+
// detection rules, suppression-comment syntax, presence in the package, and
|
|
9
|
+
// even its name may change or be removed in any future release without
|
|
10
|
+
// notice. Do not build tooling on top of it yet. The first release that
|
|
11
|
+
// documents this tool in README.md is the release that commits to its
|
|
12
|
+
// contract. Until then THIS COMMENT BLOCK is the canonical description of
|
|
13
|
+
// what the command does.
|
|
11
14
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// .transform() call site inside a function reachable from a reactive callback
|
|
16
|
-
// anywhere in the project.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// What it does
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
17
18
|
//
|
|
18
|
-
//
|
|
19
|
-
// ESLint
|
|
20
|
-
//
|
|
21
|
-
//
|
|
19
|
+
// Cross-file static analyzer for two classes of kensington reactive bugs that
|
|
20
|
+
// in-file ESLint rules cannot catch on their own. Parses every .ts/.tsx/.js/
|
|
21
|
+
// .jsx/.mjs/.cjs file under the given roots, builds a project-wide call
|
|
22
|
+
// graph across imports (named imports, default imports, re-exports, and
|
|
23
|
+
// re-export-all), and reports two kinds of findings:
|
|
24
|
+
//
|
|
25
|
+
// 1. Unkeyed reactive primitive inside a reachable helper.
|
|
26
|
+
// Any signal(), computed(), or .transform() call site (no key argument)
|
|
27
|
+
// inside a NAMED function that is reachable from a reactive callback
|
|
28
|
+
// (computed(fn), effect(fn), signal.transform(fn), signal.mapWithKey(key,
|
|
29
|
+
// mapFn)) anywhere in the project. The kensington-eslint-plugin rule
|
|
30
|
+
// `no-helper-function-trap` catches this for helpers defined in the same
|
|
31
|
+
// file as the reactive callback. This script catches the cross-file case
|
|
32
|
+
// where the helper is imported.
|
|
33
|
+
//
|
|
34
|
+
// Reported with kind: 'unkeyed-in-reactive-callback'.
|
|
35
|
+
//
|
|
36
|
+
// 2. Duplicate keyed-primitive call with mismatched primitive initial.
|
|
37
|
+
// Two or more call sites that pass the same literal string key to
|
|
38
|
+
// signal(initial, 'key') (or the same literal string name to
|
|
39
|
+
// liveSignal(initial, 'name') from kensington/live) but with different
|
|
40
|
+
// primitive literal initial values. The second caller's initial is
|
|
41
|
+
// silently ignored at runtime — the registry returns the existing
|
|
42
|
+
// signal with its current value — so without this static check the bug
|
|
43
|
+
// surfaces later as a wrong-value UI surprise. Object and array
|
|
44
|
+
// initials are skipped (false-positive risk on
|
|
45
|
+
// structurally-equal-but-reference-different cases). signal and
|
|
46
|
+
// liveSignal are grouped separately because their collision namespaces
|
|
47
|
+
// differ.
|
|
48
|
+
//
|
|
49
|
+
// Reported with kind: 'duplicate-key-initial-mismatch'.
|
|
50
|
+
//
|
|
51
|
+
// Static analysis only. Both detectors require literal-string keys and
|
|
52
|
+
// (for #2) primitive literal initials to fire. Dynamic keys built at runtime
|
|
53
|
+
// (`cell:${addr}`) and dynamic initials (getCurrentUser()) are uncatchable
|
|
54
|
+
// statically; the kensington runtime emits paired throttled warnings for
|
|
55
|
+
// those cases at call time.
|
|
56
|
+
//
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Suppression
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
//
|
|
61
|
+
// Per-call-site escape hatch. Add either form on the offending line or on
|
|
62
|
+
// the line above it:
|
|
63
|
+
//
|
|
64
|
+
// // kensington-check-reactive-ignore
|
|
65
|
+
// // check-reactive-ignore
|
|
66
|
+
//
|
|
67
|
+
// The line-above form also suppresses the next code line, which covers tight
|
|
68
|
+
// declaration groups. Intended for the lazy-registry pattern (the script
|
|
69
|
+
// can't tell whether the lazy creation has been pre-seeded by the consumer)
|
|
70
|
+
// and the rare legitimate cross-file initial mismatch.
|
|
71
|
+
//
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Usage (subject to change)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
22
75
|
//
|
|
23
|
-
// Usage (subject to change):
|
|
24
76
|
// kensington-check-reactive [paths...]
|
|
77
|
+
// Scan and print human-readable findings to stdout.
|
|
78
|
+
//
|
|
25
79
|
// kensington-check-reactive [paths...] --json
|
|
26
|
-
//
|
|
80
|
+
// Print findings as { findings: [...] } JSON. Each finding has a
|
|
81
|
+
// `kind` field distinguishing the two detection types.
|
|
82
|
+
//
|
|
83
|
+
// kensington-check-reactive [paths...] --quiet
|
|
84
|
+
// Exit-code only. No stdout.
|
|
85
|
+
//
|
|
27
86
|
// kensington-check-reactive --help
|
|
87
|
+
// Brief help.
|
|
88
|
+
//
|
|
89
|
+
// Paths default to `.` (the current working directory). Skipped directory
|
|
90
|
+
// names: node_modules, .git, dist, build, cjs, .next, .wrangler, public,
|
|
91
|
+
// coverage.
|
|
92
|
+
//
|
|
93
|
+
// Exit codes: 0 on no findings, 1 on findings, 2 on script error
|
|
94
|
+
// (e.g. no source files found, fatal parse error in the analyzer).
|
|
28
95
|
//
|
|
29
|
-
//
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Recommended invocation
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
//
|
|
100
|
+
// Chain into the project's lint script so every `npm run lint` runs the
|
|
101
|
+
// check alongside ESLint:
|
|
102
|
+
//
|
|
103
|
+
// "lint": "eslint . && kensington-check-reactive src --quiet"
|
|
104
|
+
//
|
|
105
|
+
// --quiet keeps the script exit-code-only; ESLint's own output stays
|
|
106
|
+
// visible, and a non-zero exit fails the script. Drop --quiet to print
|
|
107
|
+
// findings inline above the lint output.
|
|
108
|
+
//
|
|
109
|
+
// Programmatic entry point: `analyzeProject(roots, opts)` is exported at the
|
|
110
|
+
// bottom of this file. Returns `{ findings, fileCount }` without writing to
|
|
111
|
+
// stdout or calling process.exit. Suitable for editor integrations and tests.
|
|
30
112
|
|
|
31
113
|
/* global process */
|
|
32
114
|
import { readFileSync, statSync, readdirSync } from 'node:fs';
|
|
@@ -178,6 +260,15 @@ function analyzeFile(file) {
|
|
|
178
260
|
const signalNames = new Set();
|
|
179
261
|
const computedNames = new Set();
|
|
180
262
|
const effectNames = new Set();
|
|
263
|
+
// Live-signal names from `kensington/live`. Tracked separately because the
|
|
264
|
+
// collision namespace differs from regular keyed signals: liveSignal names
|
|
265
|
+
// are global across the app, regular keyed signal keys are per-computed.
|
|
266
|
+
const liveSignalNames = new Set();
|
|
267
|
+
// Per-file literal-key + literal-initial calls. Aggregated cross-file in
|
|
268
|
+
// `findDuplicateKeyInitialMismatches` to surface collisions where two
|
|
269
|
+
// unrelated call sites share a literal key/name but pass different
|
|
270
|
+
// primitive initials.
|
|
271
|
+
const keyedLiteralInits = [];
|
|
181
272
|
|
|
182
273
|
// Track which function we are currently inside (named/binding) and how
|
|
183
274
|
// deeply nested in a reactive callback we are.
|
|
@@ -271,6 +362,8 @@ function analyzeFile(file) {
|
|
|
271
362
|
if (imported === 'signal') { signalNames.add(local); }
|
|
272
363
|
else if (imported === 'computed') { computedNames.add(local); }
|
|
273
364
|
else if (imported === 'effect') { effectNames.add(local); }
|
|
365
|
+
} else if (node.source.value === 'kensington/live') {
|
|
366
|
+
if (imported === 'liveSignal') { liveSignalNames.add(local); }
|
|
274
367
|
}
|
|
275
368
|
} else if (spec.type === 'ImportDefaultSpecifier') {
|
|
276
369
|
imports.set(spec.local.name, { sourceFile, exportedName: 'default' });
|
|
@@ -354,6 +447,29 @@ function analyzeFile(file) {
|
|
|
354
447
|
const callee = node.callee;
|
|
355
448
|
const hasKey = node.arguments.length >= 2;
|
|
356
449
|
|
|
450
|
+
// Capture (literal-key, literal-initial) pairs for cross-file collision detection.
|
|
451
|
+
// signal(initial, 'literal-key') and liveSignal(initial, 'literal-name') only.
|
|
452
|
+
// Computed and .transform don't have an "initial" so collisions there
|
|
453
|
+
// can't be mismatched in the same way.
|
|
454
|
+
if (node.arguments.length === 2) {
|
|
455
|
+
const arg0 = node.arguments[0];
|
|
456
|
+
const arg1 = node.arguments[1];
|
|
457
|
+
const isStringLiteralKey = arg1 && arg1.type === 'Literal' && typeof arg1.value === 'string';
|
|
458
|
+
const isPrimitiveLiteralInitial = arg0 && arg0.type === 'Literal'
|
|
459
|
+
&& (arg0.value === null || ['string', 'number', 'boolean'].includes(typeof arg0.value));
|
|
460
|
+
if (isStringLiteralKey && isPrimitiveLiteralInitial) {
|
|
461
|
+
let primitive = null;
|
|
462
|
+
if (callee.type === 'Identifier' && signalNames.has(callee.name)) { primitive = 'signal'; }
|
|
463
|
+
else if (callee.type === 'Identifier' && liveSignalNames.has(callee.name)) { primitive = 'liveSignal'; }
|
|
464
|
+
if (primitive !== null) {
|
|
465
|
+
const loc = node.loc ? { line: node.loc.start.line, column: node.loc.start.column + 1 } : { line: 0, column: 0 };
|
|
466
|
+
if (!suppressedLines.has(loc.line)) {
|
|
467
|
+
keyedLiteralInits.push({ primitive, key: arg1.value, initial: arg0.value, loc });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
357
473
|
// Reactive-callback bare-identifier detection (callback IS an identifier,
|
|
358
474
|
// not a function expression).
|
|
359
475
|
function detectBareIdent(arg, _reason) {
|
|
@@ -403,7 +519,7 @@ function analyzeFile(file) {
|
|
|
403
519
|
|
|
404
520
|
walk(ast, null);
|
|
405
521
|
|
|
406
|
-
return { file, imports, exports, funcs, reactiveLocalEntries };
|
|
522
|
+
return { file, imports, exports, funcs, reactiveLocalEntries, keyedLiteralInits };
|
|
407
523
|
}
|
|
408
524
|
|
|
409
525
|
// === Cross-file resolution + propagation ===================================
|
|
@@ -511,6 +627,7 @@ function report(index, reachable) {
|
|
|
511
627
|
const reason = reachable.get(key);
|
|
512
628
|
for (const hit of fn.unkeyedCalls) {
|
|
513
629
|
findings.push({
|
|
630
|
+
kind: 'unkeyed-in-reactive-callback',
|
|
514
631
|
file: relPath(file),
|
|
515
632
|
line: hit.loc.line,
|
|
516
633
|
column: hit.loc.column,
|
|
@@ -521,10 +638,63 @@ function report(index, reachable) {
|
|
|
521
638
|
}
|
|
522
639
|
}
|
|
523
640
|
}
|
|
641
|
+
for (const f of findDuplicateKeyInitialMismatches(index)) {
|
|
642
|
+
findings.push(f);
|
|
643
|
+
}
|
|
524
644
|
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column);
|
|
525
645
|
return findings;
|
|
526
646
|
}
|
|
527
647
|
|
|
648
|
+
// Cross-file aggregation. Groups every `signal(literal, 'literal-key')` and
|
|
649
|
+
// `liveSignal(literal, 'literal-name')` call by (primitive, key) and emits one
|
|
650
|
+
// finding per call site when the group has disagreeing initial values. The
|
|
651
|
+
// two primitives are grouped separately because their collision namespaces
|
|
652
|
+
// differ: liveSignal names are global; regular signal keys are per-computed
|
|
653
|
+
// (but two calls with the same literal key landing in the same outer computed
|
|
654
|
+
// from different files is the bug shape we want to surface).
|
|
655
|
+
function findDuplicateKeyInitialMismatches(index) {
|
|
656
|
+
const groups = new Map(); // `${primitive}::${key}` -> Array<{ file, loc, initial, primitive, key }>
|
|
657
|
+
for (const [file, rec] of index) {
|
|
658
|
+
if (!rec.keyedLiteralInits) { continue; }
|
|
659
|
+
for (const entry of rec.keyedLiteralInits) {
|
|
660
|
+
const groupKey = `${entry.primitive}::${entry.key}`;
|
|
661
|
+
let group = groups.get(groupKey);
|
|
662
|
+
if (group === undefined) { group = []; groups.set(groupKey, group); }
|
|
663
|
+
group.push({ ...entry, file });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const out = [];
|
|
667
|
+
for (const [, group] of groups) {
|
|
668
|
+
if (group.length < 2) { continue; }
|
|
669
|
+
// Find any disagreement among initials. Object.is for primitive comparison.
|
|
670
|
+
const first = group[0].initial;
|
|
671
|
+
const allMatch = group.every(e => Object.is(e.initial, first));
|
|
672
|
+
if (allMatch) { continue; }
|
|
673
|
+
// Disagreement: emit one finding per call site, cross-referencing the group.
|
|
674
|
+
const others = group.map(e => `${relPath(e.file)}:${e.loc.line}:${e.loc.column} (initial=${formatInitial(e.initial)})`);
|
|
675
|
+
for (const entry of group) {
|
|
676
|
+
out.push({
|
|
677
|
+
kind: 'duplicate-key-initial-mismatch',
|
|
678
|
+
file: relPath(entry.file),
|
|
679
|
+
line: entry.loc.line,
|
|
680
|
+
column: entry.loc.column,
|
|
681
|
+
primitive: entry.primitive,
|
|
682
|
+
key: entry.key,
|
|
683
|
+
initial: entry.initial,
|
|
684
|
+
groupSize: group.length,
|
|
685
|
+
otherSites: others.filter(s => !s.startsWith(`${relPath(entry.file)}:${entry.loc.line}:${entry.loc.column}`)),
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return out;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function formatInitial(v) {
|
|
693
|
+
if (v === null) { return 'null'; }
|
|
694
|
+
if (typeof v === 'string') { return JSON.stringify(v); }
|
|
695
|
+
return String(v);
|
|
696
|
+
}
|
|
697
|
+
|
|
528
698
|
// === Public API ============================================================
|
|
529
699
|
|
|
530
700
|
// Programmatic entry. Returns { findings, fileCount }. Pure (no process exit,
|
|
@@ -548,11 +718,17 @@ function printHelp() {
|
|
|
548
718
|
process.stdout.write(
|
|
549
719
|
'kensington-check-reactive (EXPERIMENTAL, NOT YET RELEASED)\n'
|
|
550
720
|
+ '\n'
|
|
551
|
-
+ 'Cross-file static analyzer for
|
|
552
|
-
+ '
|
|
553
|
-
+ '
|
|
554
|
-
+ '
|
|
555
|
-
+ '
|
|
721
|
+
+ 'Cross-file static analyzer for two classes of kensington reactive bugs:\n'
|
|
722
|
+
+ ' 1. Unkeyed signal()/computed()/.transform() inside helpers reachable\n'
|
|
723
|
+
+ ' from a reactive callback anywhere in the project.\n'
|
|
724
|
+
+ ' 2. Duplicate signal(initial, KEY) or liveSignal(initial, NAME) calls\n'
|
|
725
|
+
+ ' with the same literal key/name but different primitive initials.\n'
|
|
726
|
+
+ '\n'
|
|
727
|
+
+ 'This binary is shipped for early testing only. The CLI surface, output\n'
|
|
728
|
+
+ 'format, detection rules, and even its presence in the package may change\n'
|
|
729
|
+
+ 'without notice. Do not build tooling on top of it until it appears in\n'
|
|
730
|
+
+ 'README.md. See the comment header in bin/check-reactive.js for the\n'
|
|
731
|
+
+ 'canonical description until then.\n'
|
|
556
732
|
+ '\n'
|
|
557
733
|
+ 'Usage:\n'
|
|
558
734
|
+ ' kensington-check-reactive [paths...] scan and print findings\n'
|
|
@@ -560,6 +736,9 @@ function printHelp() {
|
|
|
560
736
|
+ ' kensington-check-reactive [paths...] --quiet exit code only, no output\n'
|
|
561
737
|
+ ' kensington-check-reactive --help this message\n'
|
|
562
738
|
+ '\n'
|
|
739
|
+
+ 'Paths default to the current working directory. Suppress per call site\n'
|
|
740
|
+
+ 'with `// kensington-check-reactive-ignore` on or above the line.\n'
|
|
741
|
+
+ '\n'
|
|
563
742
|
+ 'Exits 0 on no findings, 1 on findings, 2 on script error.\n',
|
|
564
743
|
);
|
|
565
744
|
}
|
|
@@ -594,9 +773,17 @@ function main() {
|
|
|
594
773
|
process.stdout.write(`kensington-check-reactive: 0 findings across ${index.size} files\n`);
|
|
595
774
|
} else {
|
|
596
775
|
for (const f of findings) {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
776
|
+
if (f.kind === 'duplicate-key-initial-mismatch') {
|
|
777
|
+
const others = f.otherSites.length ? ` (other sites: ${f.otherSites.join('; ')})` : '';
|
|
778
|
+
process.stdout.write(
|
|
779
|
+
`${f.file}:${f.line}:${f.column}: warning: ${f.primitive}(initial=${formatInitial(f.initial)}, '${f.key}') `
|
|
780
|
+
+ `disagrees with other call sites' initial for the same key${others}\n`,
|
|
781
|
+
);
|
|
782
|
+
} else {
|
|
783
|
+
process.stdout.write(
|
|
784
|
+
`${f.file}:${f.line}:${f.column}: warning: ${f.primitive}() unkeyed in \`${f.fnName}\` (${f.reason})\n`,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
600
787
|
}
|
|
601
788
|
process.stdout.write(`\n${findings.length} finding${findings.length === 1 ? '' : 's'} across ${index.size} files\n`);
|
|
602
789
|
}
|
package/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import attrsCanonicalShape from './rules/attrs-canonical-shape.js';
|
|
|
24
24
|
import consistentContentLayout from './rules/consistent-content-layout.js';
|
|
25
25
|
import noHelperFunctionTrap from './rules/no-helper-function-trap.js';
|
|
26
26
|
import requireReactiveKey from './rules/require-reactive-key.js';
|
|
27
|
+
import noAsyncSet from './rules/no-async-set.js';
|
|
27
28
|
|
|
28
29
|
const plugin = {
|
|
29
30
|
meta: { name: 'eslint-plugin-kensington' },
|
|
@@ -54,6 +55,7 @@ const plugin = {
|
|
|
54
55
|
'consistent-content-layout': consistentContentLayout,
|
|
55
56
|
'no-helper-function-trap': noHelperFunctionTrap,
|
|
56
57
|
'require-reactive-key': requireReactiveKey,
|
|
58
|
+
'no-async-set': noAsyncSet,
|
|
57
59
|
},
|
|
58
60
|
configs: {},
|
|
59
61
|
};
|
|
@@ -72,12 +74,12 @@ plugin.configs.recommended = {
|
|
|
72
74
|
'kensington/no-new-computed-in-effect': 'error',
|
|
73
75
|
'kensington/no-new-signal-in-computed': 'error',
|
|
74
76
|
'kensington/no-unsafe-literal': 'error',
|
|
75
|
-
'kensington/no-new-computed-in-computed': 'warn',
|
|
76
77
|
'kensington/no-effect-in-effect': 'error',
|
|
77
78
|
'kensington/no-async-effect': 'error',
|
|
78
79
|
'kensington/no-async-computed': 'error',
|
|
79
80
|
'kensington/no-out-of-scope-reactive-reference': 'warn',
|
|
80
81
|
'kensington/no-helper-function-trap': 'warn',
|
|
82
|
+
'kensington/no-async-set': 'error',
|
|
81
83
|
},
|
|
82
84
|
};
|
|
83
85
|
|
|
@@ -87,13 +89,19 @@ plugin.configs.strict = {
|
|
|
87
89
|
...plugin.configs.recommended.rules,
|
|
88
90
|
// Promote every reactive-correctness warning to error. Strict mode trades
|
|
89
91
|
// tolerance of false positives for zero silent misses.
|
|
92
|
+
//
|
|
93
|
+
// Note: require-reactive-key is NOT included. The rule flags every unkeyed
|
|
94
|
+
// signal()/computed()/.transform() call site including at module scope,
|
|
95
|
+
// which generates mechanical retrofits that don't catch real bugs (keys are
|
|
96
|
+
// no-ops at module scope). Opt in explicitly with
|
|
97
|
+
// 'kensington/require-reactive-key': 'error'
|
|
98
|
+
// if you want refactor-safety enforcement (a later lift into a reactive
|
|
99
|
+
// scope finds the key already in place).
|
|
90
100
|
'kensington/no-signal-async-write': 'error',
|
|
91
101
|
'kensington/no-ignored-effect-return': 'error',
|
|
92
102
|
'kensington/prefer-value-in-async': 'error',
|
|
93
|
-
'kensington/no-new-computed-in-computed': 'error',
|
|
94
103
|
'kensington/no-out-of-scope-reactive-reference': 'error',
|
|
95
104
|
'kensington/no-helper-function-trap': 'error',
|
|
96
|
-
'kensington/require-reactive-key': 'error',
|
|
97
105
|
},
|
|
98
106
|
};
|
|
99
107
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kensington-eslint-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "ESLint rules for kensington signal correctness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"kensington-check-reactive": "./bin/check-reactive.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node --test tests
|
|
11
|
+
"test": "node --test tests/*.test.js"
|
|
12
12
|
},
|
|
13
13
|
"peerDependencies": {
|
|
14
14
|
"eslint": ">=9"
|
package/rules/_utils.js
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
// Shared helpers used by the formatting rules.
|
|
2
2
|
|
|
3
|
+
// Module specifier paths that export `liveSignal`. Imports from any of these
|
|
4
|
+
// resolve to the same function. Rules that look for reactive-primitive creation
|
|
5
|
+
// inside reactive scopes must recognize all three so liveSignal lazy-registry
|
|
6
|
+
// traps surface the same way plain signal traps do.
|
|
7
|
+
export const KENSINGTON_LIVE_SOURCES = new Set([
|
|
8
|
+
'kensington/live',
|
|
9
|
+
'kensington/live/client',
|
|
10
|
+
'kensington/live/server',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export function isKensingtonLiveSource(value) {
|
|
14
|
+
return KENSINGTON_LIVE_SOURCES.has(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
3
17
|
// HTML boolean attributes per the WHATWG HTML spec. Kebab-case form is the
|
|
4
18
|
// attribute name; camelCase keys (e.g. `formNoValidate`) also match because the
|
|
5
19
|
// tag-call check looks at the key text after camel-to-kebab conversion.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Reports `.set(async fn)` on any signal. An async updater returns a Promise
|
|
2
|
+
// instead of the next value, which:
|
|
3
|
+
// - For a regular signal, stores the Promise object as the value. Reads
|
|
4
|
+
// return the Promise; downstream code that expects T sees a Thenable.
|
|
5
|
+
// - For a liveSignal, serializes the Promise to "{}" on the wire,
|
|
6
|
+
// corrupting every subscriber's view of the value.
|
|
7
|
+
//
|
|
8
|
+
// The async-fn pattern is almost always a sign of "I want to do async work
|
|
9
|
+
// then update the signal." The right shape is to await the async work
|
|
10
|
+
// first, THEN call `.set(value)` with the resolved value. Or use
|
|
11
|
+
// effect()/setTimeout to schedule the write.
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
meta: {
|
|
15
|
+
type: 'problem',
|
|
16
|
+
docs: {
|
|
17
|
+
description:
|
|
18
|
+
'disallow passing an async function to .set() on a signal. The Promise return corrupts the stored value (especially across the wire for liveSignals).',
|
|
19
|
+
},
|
|
20
|
+
messages: {
|
|
21
|
+
noAsyncSet:
|
|
22
|
+
'.set(async fn) is almost always a bug. The function returns a Promise instead of the next value, '
|
|
23
|
+
+ 'which gets stored as-is (and serializes to "{}" on the wire for liveSignals). '
|
|
24
|
+
+ 'Await the async work first, then call `.set(resolvedValue)`. Or use effect() to schedule the write.',
|
|
25
|
+
},
|
|
26
|
+
schema: [],
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
create(context) {
|
|
30
|
+
return {
|
|
31
|
+
CallExpression(node) {
|
|
32
|
+
const callee = node.callee;
|
|
33
|
+
if (
|
|
34
|
+
callee.type !== 'MemberExpression'
|
|
35
|
+
|| callee.computed
|
|
36
|
+
|| callee.property.type !== 'Identifier'
|
|
37
|
+
|| callee.property.name !== 'set'
|
|
38
|
+
) { return; }
|
|
39
|
+
const arg = node.arguments[0];
|
|
40
|
+
if (arg === undefined) { return; }
|
|
41
|
+
if (
|
|
42
|
+
(arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression')
|
|
43
|
+
&& arg.async === true
|
|
44
|
+
) {
|
|
45
|
+
context.report({ node: arg, messageId: 'noAsyncSet' });
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
// from inside a computed/transform/mapFn callback, so at runtime the call runs
|
|
9
9
|
// in a reactive scope).
|
|
10
10
|
//
|
|
11
|
+
// Also reports `liveSignal()` calls inside helpers reachable from a reactive
|
|
12
|
+
// callback. liveSignal's second argument is always a name (not optional), so
|
|
13
|
+
// the rule fires regardless of args length. The trap is the same shape as the
|
|
14
|
+
// plain signal case but in the liveSignal namespace: the first call for a given
|
|
15
|
+
// name creates the transport entry inside the reactive scope.
|
|
16
|
+
import { isKensingtonLiveSource } from './_utils.js';
|
|
17
|
+
//
|
|
11
18
|
// Single-file analysis only. We track only named top-level functions; nested
|
|
12
19
|
// anonymous helpers are out of scope (the lexical rule already covers them).
|
|
13
20
|
//
|
|
@@ -42,12 +49,20 @@ export default {
|
|
|
42
49
|
+ 'surrounding reactive scope at runtime even though the call site looks '
|
|
43
50
|
+ 'top-level here. Pass a stable key as the second argument to scope the '
|
|
44
51
|
+ 'instance to the surrounding reactive scope.',
|
|
52
|
+
helperFunctionTrapLive:
|
|
53
|
+
'liveSignal() call inside `{{fnName}}`, which is called from a reactive '
|
|
54
|
+
+ 'callback in this file ({{reason}}). The first call for this name creates '
|
|
55
|
+
+ 'the transport entry inside the surrounding reactive scope at runtime. '
|
|
56
|
+
+ 'Eager-seed the liveSignal outside the reactive scope (queueMicrotask is '
|
|
57
|
+
+ 'the canonical pattern). See agent-docs/live-signals.md → "liveSignal '
|
|
58
|
+
+ 'inside a reactive callback".',
|
|
45
59
|
},
|
|
46
60
|
schema: [],
|
|
47
61
|
},
|
|
48
62
|
|
|
49
63
|
create(context) {
|
|
50
64
|
const signalNames = new Set();
|
|
65
|
+
const liveSignalNames = new Set();
|
|
51
66
|
const computedNames = new Set();
|
|
52
67
|
const effectNames = new Set();
|
|
53
68
|
|
|
@@ -135,16 +150,24 @@ export default {
|
|
|
135
150
|
|
|
136
151
|
return {
|
|
137
152
|
ImportDeclaration(node) {
|
|
138
|
-
if (node.source.value
|
|
153
|
+
if (node.source.value === 'kensington') {
|
|
154
|
+
for (const spec of node.specifiers) {
|
|
155
|
+
if (spec.type !== 'ImportSpecifier') {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (spec.imported.name === 'signal') { signalNames.add(spec.local.name); }
|
|
159
|
+
if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
|
|
160
|
+
if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
|
|
161
|
+
}
|
|
139
162
|
return;
|
|
140
163
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
164
|
+
if (isKensingtonLiveSource(node.source.value)) {
|
|
165
|
+
for (const spec of node.specifiers) {
|
|
166
|
+
if (spec.type !== 'ImportSpecifier') {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (spec.imported.name === 'liveSignal') { liveSignalNames.add(spec.local.name); }
|
|
144
170
|
}
|
|
145
|
-
if (spec.imported.name === 'signal') { signalNames.add(spec.local.name); }
|
|
146
|
-
if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
|
|
147
|
-
if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
|
|
148
171
|
}
|
|
149
172
|
},
|
|
150
173
|
|
|
@@ -213,9 +236,13 @@ export default {
|
|
|
213
236
|
const fn = currentFn();
|
|
214
237
|
if (fn && !fn.anonymous) {
|
|
215
238
|
if (callee.type === 'Identifier' && signalNames.has(callee.name) && !hasKey) {
|
|
216
|
-
fn.unkeyedCalls.push({ node, primitive: 'signal' });
|
|
239
|
+
fn.unkeyedCalls.push({ node, primitive: 'signal', isLive: false });
|
|
240
|
+
} else if (callee.type === 'Identifier' && liveSignalNames.has(callee.name)) {
|
|
241
|
+
// liveSignal's second arg is a name (always present); flag it
|
|
242
|
+
// regardless of args length. The trap is structural, not key-presence.
|
|
243
|
+
fn.unkeyedCalls.push({ node, primitive: 'liveSignal', isLive: true });
|
|
217
244
|
} else if (callee.type === 'Identifier' && computedNames.has(callee.name) && !hasKey) {
|
|
218
|
-
fn.unkeyedCalls.push({ node, primitive: 'computed' });
|
|
245
|
+
fn.unkeyedCalls.push({ node, primitive: 'computed', isLive: false });
|
|
219
246
|
} else if (
|
|
220
247
|
callee.type === 'MemberExpression'
|
|
221
248
|
&& !callee.computed
|
|
@@ -223,7 +250,7 @@ export default {
|
|
|
223
250
|
&& callee.property.name === 'transform'
|
|
224
251
|
&& !hasKey
|
|
225
252
|
) {
|
|
226
|
-
fn.unkeyedCalls.push({ node, primitive: '.transform' });
|
|
253
|
+
fn.unkeyedCalls.push({ node, primitive: '.transform', isLive: false });
|
|
227
254
|
} else if (callee.type === 'Identifier') {
|
|
228
255
|
fn.callees.add(callee.name);
|
|
229
256
|
}
|
|
@@ -262,7 +289,7 @@ export default {
|
|
|
262
289
|
for (const hit of rec.unkeyedCalls) {
|
|
263
290
|
context.report({
|
|
264
291
|
node: hit.node,
|
|
265
|
-
messageId: 'helperFunctionTrap',
|
|
292
|
+
messageId: hit.isLive ? 'helperFunctionTrapLive' : 'helperFunctionTrap',
|
|
266
293
|
data: { primitive: hit.primitive, fnName: name, reason },
|
|
267
294
|
});
|
|
268
295
|
}
|
|
@@ -1,23 +1,33 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Deprecated. The kensington runtime now defers the computed-in-computed and
|
|
2
|
+
// transform-in-computed warnings to subscription time and only fires when a user
|
|
3
|
+
// effect or user computed subscribes to the inner. Inline consumption as an attribute,
|
|
4
|
+
// class, text, or prop slot is silent by design. That change makes this rule's
|
|
5
|
+
// purely-lexical flag redundant. Escape cases are already covered by
|
|
6
|
+
// `no-out-of-scope-reactive-reference` (which uses a full escape classifier).
|
|
7
|
+
// The `strict` config's `require-reactive-key` still catches every unkeyed call site
|
|
8
|
+
// for teams that want refactor-safety enforcement.
|
|
9
|
+
//
|
|
10
|
+
// The rule remains registered so existing configs that reference it don't error.
|
|
11
|
+
// Removed from the `recommended` and `strict` configs.
|
|
6
12
|
export default {
|
|
7
13
|
meta: {
|
|
8
14
|
type: 'suggestion',
|
|
15
|
+
deprecated: true,
|
|
16
|
+
replacedBy: ['no-out-of-scope-reactive-reference'],
|
|
9
17
|
docs: {
|
|
10
|
-
description: 'require a stable key for computed() and .transform() calls inside a computed() body',
|
|
18
|
+
description: 'require a stable key for computed() and .transform() calls inside a computed() body (deprecated. see no-out-of-scope-reactive-reference)',
|
|
11
19
|
},
|
|
12
20
|
messages: {
|
|
13
21
|
noNewComputedInComputed:
|
|
14
|
-
'computed() called inside a computed() body without a key. The
|
|
15
|
-
'
|
|
16
|
-
'
|
|
22
|
+
'computed() called inside a computed() body without a key. The runtime warns only when ' +
|
|
23
|
+
'a user effect or user computed subscribes to the inner. Inline consumption as an ' +
|
|
24
|
+
'attribute, class, or text slot is silent. Pass a stable key when the inner is held by ' +
|
|
25
|
+
'user subscribers (e.g. computed(fn, item.id)).',
|
|
17
26
|
noNewTransformInComputed:
|
|
18
|
-
'.transform() called inside a computed() body without a key. The
|
|
19
|
-
'
|
|
20
|
-
'
|
|
27
|
+
'.transform() called inside a computed() body without a key. The runtime warns only when ' +
|
|
28
|
+
'a user effect or user computed subscribes to the inner. Inline consumption as an ' +
|
|
29
|
+
'attribute, class, or text slot is silent. Pass a stable key when the inner is held by ' +
|
|
30
|
+
'user subscribers (e.g. sig.transform(fn, item.id)).',
|
|
21
31
|
},
|
|
22
32
|
},
|
|
23
33
|
|
|
@@ -3,11 +3,18 @@
|
|
|
3
3
|
// resets to the initial value on every outer re-render and DOM identity is not preserved.
|
|
4
4
|
// Pass a stable key as the second argument (e.g. signal(false, item.id)) to scope the
|
|
5
5
|
// signal to the surrounding computed so the same instance is reused across re-runs.
|
|
6
|
+
//
|
|
7
|
+
// Also reports liveSignal() calls inside a computed() body. liveSignal always has a name
|
|
8
|
+
// as its second argument so the key-presence check doesn't apply, but the lazy-registry
|
|
9
|
+
// creation on first sight still happens inside the reactive scope and trips the runtime
|
|
10
|
+
// warning. The fix is to eager-seed via queueMicrotask outside the reactive scope.
|
|
11
|
+
import { isKensingtonLiveSource } from './_utils.js';
|
|
12
|
+
|
|
6
13
|
export default {
|
|
7
14
|
meta: {
|
|
8
15
|
type: 'suggestion',
|
|
9
16
|
docs: {
|
|
10
|
-
description: 'require a stable key for signal()
|
|
17
|
+
description: 'require a stable key for signal() and flag liveSignal() inside a computed() body',
|
|
11
18
|
},
|
|
12
19
|
messages: {
|
|
13
20
|
noNewSignalInComputed:
|
|
@@ -15,11 +22,17 @@ export default {
|
|
|
15
22
|
'every outer re-render. Pass a stable key as the second argument ' +
|
|
16
23
|
'(e.g. signal(initial, item.id)) so the same signal instance is reused across ' +
|
|
17
24
|
'computed re-runs.',
|
|
25
|
+
noLiveSignalInComputed:
|
|
26
|
+
'liveSignal() called inside a computed() body. The first call for this name creates '
|
|
27
|
+
+ 'the transport entry inside the reactive scope, tripping the runtime warning. '
|
|
28
|
+
+ 'Eager-seed the liveSignal outside the reactive scope (queueMicrotask is the canonical '
|
|
29
|
+
+ 'pattern). See agent-docs/live-signals.md → "liveSignal inside a reactive callback".',
|
|
18
30
|
},
|
|
19
31
|
},
|
|
20
32
|
|
|
21
33
|
create(context) {
|
|
22
34
|
const signalNames = new Set();
|
|
35
|
+
const liveSignalNames = new Set();
|
|
23
36
|
const computedNames = new Set();
|
|
24
37
|
const effectNames = new Set();
|
|
25
38
|
// Each entry is 'computed', 'effect', or 'other' — innermost frame is last.
|
|
@@ -27,12 +40,20 @@ export default {
|
|
|
27
40
|
|
|
28
41
|
return {
|
|
29
42
|
ImportDeclaration(node) {
|
|
30
|
-
if (node.source.value
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
if (node.source.value === 'kensington') {
|
|
44
|
+
for (const spec of node.specifiers) {
|
|
45
|
+
if (spec.type !== 'ImportSpecifier') { continue; }
|
|
46
|
+
if (spec.imported.name === 'signal') { signalNames.add(spec.local.name); }
|
|
47
|
+
if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
|
|
48
|
+
if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (isKensingtonLiveSource(node.source.value)) {
|
|
53
|
+
for (const spec of node.specifiers) {
|
|
54
|
+
if (spec.type !== 'ImportSpecifier') { continue; }
|
|
55
|
+
if (spec.imported.name === 'liveSignal') { liveSignalNames.add(spec.local.name); }
|
|
56
|
+
}
|
|
36
57
|
}
|
|
37
58
|
},
|
|
38
59
|
|
|
@@ -60,16 +81,22 @@ export default {
|
|
|
60
81
|
},
|
|
61
82
|
|
|
62
83
|
CallExpression(node) {
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
) { return; }
|
|
67
|
-
//
|
|
68
|
-
|
|
84
|
+
if (node.callee.type !== 'Identifier') { return; }
|
|
85
|
+
const isPlainSignal = signalNames.has(node.callee.name);
|
|
86
|
+
const isLiveSignal = liveSignalNames.has(node.callee.name);
|
|
87
|
+
if (!isPlainSignal && !isLiveSignal) { return; }
|
|
88
|
+
// Plain signal: a second-arg key means the call is keyed correctly.
|
|
89
|
+
// liveSignal: the second arg is always a name, but the lazy-registry
|
|
90
|
+
// creation still happens inside the reactive scope on first sight,
|
|
91
|
+
// so the trap applies regardless of args length.
|
|
92
|
+
if (isPlainSignal && node.arguments.length >= 2) { return; }
|
|
69
93
|
|
|
70
94
|
for (let i = fnStack.length - 1; i >= 0; i--) {
|
|
71
95
|
if (fnStack[i] === 'computed') {
|
|
72
|
-
context.report({
|
|
96
|
+
context.report({
|
|
97
|
+
node,
|
|
98
|
+
messageId: isLiveSignal ? 'noLiveSignalInComputed' : 'noNewSignalInComputed',
|
|
99
|
+
});
|
|
73
100
|
return;
|
|
74
101
|
}
|
|
75
102
|
if (fnStack[i] === 'effect') { return; }
|
|
@@ -1,34 +1,51 @@
|
|
|
1
|
-
// Reports signal() called inside an effect() callback. Each
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Reports signal() or liveSignal() called inside an effect() callback. Each
|
|
2
|
+
// effect run creates a new orphaned signal (or a new transport entry, for
|
|
3
|
+
// liveSignal) with no cleanup path, which is almost always a bug. The
|
|
4
|
+
// primitive should be declared outside the effect.
|
|
5
|
+
import { isKensingtonLiveSource } from './_utils.js';
|
|
6
|
+
|
|
4
7
|
export default {
|
|
5
8
|
meta: {
|
|
6
9
|
type: 'problem',
|
|
7
10
|
docs: {
|
|
8
|
-
description: 'disallow creating a new signal() inside an effect() body',
|
|
11
|
+
description: 'disallow creating a new signal() or liveSignal() inside an effect() body',
|
|
9
12
|
},
|
|
10
13
|
messages: {
|
|
11
14
|
noNewSignalInEffect:
|
|
12
15
|
'signal() called inside an effect() body. Each effect run creates a new orphaned signal. ' +
|
|
13
16
|
'Declare the signal outside the effect instead.',
|
|
17
|
+
noNewLiveSignalInEffect:
|
|
18
|
+
'liveSignal() called inside an effect() body. Each effect run looks up (and on first '
|
|
19
|
+
+ 'sight, creates) the transport entry inside the effect\'s reactive scope. Declare the '
|
|
20
|
+
+ 'liveSignal outside the effect, or eager-seed it via queueMicrotask outside the '
|
|
21
|
+
+ 'reactive scope. See agent-docs/live-signals.md → "liveSignal inside a reactive callback".',
|
|
14
22
|
},
|
|
15
23
|
},
|
|
16
24
|
|
|
17
25
|
create(context) {
|
|
18
26
|
const effectNames = new Set();
|
|
19
27
|
const signalNames = new Set();
|
|
28
|
+
const liveSignalNames = new Set();
|
|
20
29
|
const computedNames = new Set();
|
|
21
30
|
// Each entry is 'effect', 'computed', or 'other'.
|
|
22
31
|
const fnStack = [];
|
|
23
32
|
|
|
24
33
|
return {
|
|
25
34
|
ImportDeclaration(node) {
|
|
26
|
-
if (node.source.value
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
if (node.source.value === 'kensington') {
|
|
36
|
+
for (const spec of node.specifiers) {
|
|
37
|
+
if (spec.type !== 'ImportSpecifier') { continue; }
|
|
38
|
+
if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
|
|
39
|
+
if (spec.imported.name === 'signal') { signalNames.add(spec.local.name); }
|
|
40
|
+
if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (isKensingtonLiveSource(node.source.value)) {
|
|
45
|
+
for (const spec of node.specifiers) {
|
|
46
|
+
if (spec.type !== 'ImportSpecifier') { continue; }
|
|
47
|
+
if (spec.imported.name === 'liveSignal') { liveSignalNames.add(spec.local.name); }
|
|
48
|
+
}
|
|
32
49
|
}
|
|
33
50
|
},
|
|
34
51
|
|
|
@@ -56,14 +73,17 @@ export default {
|
|
|
56
73
|
},
|
|
57
74
|
|
|
58
75
|
CallExpression(node) {
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
) { return; }
|
|
76
|
+
if (node.callee.type !== 'Identifier') { return; }
|
|
77
|
+
const isPlainSignal = signalNames.has(node.callee.name);
|
|
78
|
+
const isLiveSignal = liveSignalNames.has(node.callee.name);
|
|
79
|
+
if (!isPlainSignal && !isLiveSignal) { return; }
|
|
63
80
|
|
|
64
81
|
for (let i = fnStack.length - 1; i >= 0; i--) {
|
|
65
82
|
if (fnStack[i] === 'effect') {
|
|
66
|
-
context.report({
|
|
83
|
+
context.report({
|
|
84
|
+
node,
|
|
85
|
+
messageId: isLiveSignal ? 'noNewLiveSignalInEffect' : 'noNewSignalInEffect',
|
|
86
|
+
});
|
|
67
87
|
return;
|
|
68
88
|
}
|
|
69
89
|
if (fnStack[i] === 'computed') { return; }
|