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 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 two extra rules:
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 `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.
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 | warn | error |
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`
@@ -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 even its name
8
- // may change or be removed in any future release without notice. Do not
9
- // build tooling on top of it yet. The first release that documents this
10
- // tool in README.md is the release that commits to its contract.
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
- // Cross-file static analyzer for the kensington helper-function trap. Parses
13
- // every .ts/.tsx/.js/.jsx file under the given roots, builds a project-wide
14
- // call graph (across imports), and reports every unkeyed signal()/computed()/
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
- // Complements the single-file ESLint rule (`no-helper-function-trap`). The
19
- // ESLint rule catches the case where the helper and the reactive callback live
20
- // in the same file. This script catches the case where they live in different
21
- // files connected by imports.
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
- // kensington-check-reactive [paths...] --quiet # exit-code only, no output
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
- // Exits 0 on no findings, 1 on findings, 2 on script error.
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 unkeyed signal()/computed()/.transform()\n'
552
- + 'calls inside helper functions reachable from a reactive callback. This\n'
553
- + 'binary is shipped for early testing only. The CLI surface, output format,\n'
554
- + 'and even its presence in the package may change without notice. Do not\n'
555
- + 'build tooling on top of it until it appears in README.md.\n'
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
- process.stdout.write(
598
- `${f.file}:${f.line}:${f.column}: warning: ${f.primitive}() unkeyed in \`${f.fnName}\` (${f.reason})\n`,
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.5.1",
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/no-set-in-derivation.test.js tests/no-self-read-write.test.js tests/no-signal-async-write.test.js tests/no-set-on-derived-signal.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 tests/prefer-boolean-attribute-true.test.js tests/prefer-camelcase-attrs.test.js tests/prefer-style-object.test.js tests/prefer-nested-attr-groups.test.js tests/prefer-array-for-multiline-content.test.js tests/attrs-on-call-line.test.js tests/attrs-canonical-shape.test.js tests/consistent-content-layout.test.js tests/no-out-of-scope-reactive-reference.test.js tests/no-helper-function-trap.test.js tests/require-reactive-key.test.js tests/check-reactive.test.js"
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 !== 'kensington') {
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
- for (const spec of node.specifiers) {
142
- if (spec.type !== 'ImportSpecifier') {
143
- continue;
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
- // Reports unkeyed computed() or .transform() calls inside a computed() callback. Each
2
- // recompute creates a new orphaned derived signal with no cleanup path. Pass a stable key
3
- // as the second argument (e.g. computed(fn, item.id) or sig.transform(fn, item.id)) to
4
- // scope the derived signal to the surrounding computed so the same instance is reused
5
- // across re-runs.
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 DOM node will be replaced ' +
15
- 'on every outer re-render. Pass a stable key as the second argument ' +
16
- '(e.g. computed(fn, item.id)) so the same instance is reused across computed re-runs.',
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 DOM node will be replaced ' +
19
- 'on every outer re-render. Pass a stable key as the second argument ' +
20
- '(e.g. sig.transform(fn, item.id)) so the same instance is reused across computed re-runs.',
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() calls inside a computed() body',
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 !== 'kensington') { return; }
31
- for (const spec of node.specifiers) {
32
- if (spec.type !== 'ImportSpecifier') { continue; }
33
- if (spec.imported.name === 'signal') { signalNames.add(spec.local.name); }
34
- if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
35
- if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
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
- node.callee.type !== 'Identifier' ||
65
- !signalNames.has(node.callee.name)
66
- ) { return; }
67
- // A key was supplied this is the intended pattern, not a problem.
68
- if (node.arguments.length >= 2) { return; }
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({ node, messageId: 'noNewSignalInComputed' });
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 effect run creates a new
2
- // orphaned signal with no cleanup path, which is almost always a bug the signal
3
- // should be declared outside the effect.
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 !== 'kensington') { return; }
27
- for (const spec of node.specifiers) {
28
- if (spec.type !== 'ImportSpecifier') { continue; }
29
- if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
30
- if (spec.imported.name === 'signal') { signalNames.add(spec.local.name); }
31
- if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
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
- node.callee.type !== 'Identifier' ||
61
- !signalNames.has(node.callee.name)
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({ node, messageId: 'noNewSignalInEffect' });
83
+ context.report({
84
+ node,
85
+ messageId: isLiveSignal ? 'noNewLiveSignalInEffect' : 'noNewSignalInEffect',
86
+ });
67
87
  return;
68
88
  }
69
89
  if (fnStack[i] === 'computed') { return; }