kensington-eslint-plugin 0.3.1 → 0.4.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
@@ -96,9 +96,10 @@ Because this is a standard ESLint plugin, it works anywhere ESLint runs with no
96
96
  | [`no-ignored-effect-return`](#no-ignored-effect-return) | Require capturing the return value of `effect()` inside a function | warn |
97
97
  | [`prefer-value-in-async`](#prefer-value-in-async) | Prefer `.value` over `.get()` inside async callbacks within an `effect()` | warn |
98
98
  | [`no-new-computed-in-effect`](#no-new-computed-in-effect) | Disallow creating a new `computed()` inside an `effect()` body | error |
99
- | [`no-new-signal-in-computed`](#no-new-signal-in-computed) | Disallow creating a new `signal()` inside a `computed()` body | error |
99
+ | [`no-new-signal-in-computed`](#no-new-signal-in-computed) | Require a stable key for `signal()` calls inside a `computed()` body | error |
100
100
  | [`no-unsafe-literal`](#no-unsafe-literal) | Disallow `.unsafeLiteral()` calls that bypass XSS protection | error |
101
- | [`no-new-computed-in-computed`](#no-new-computed-in-computed) | Disallow creating a new `computed()` inside a `computed()` body | error |
101
+ | [`no-new-computed-in-computed`](#no-new-computed-in-computed) | Require a stable key for `computed()` and `.transform()` calls inside a `computed()` body | warn |
102
+ | [`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 |
102
103
  | [`no-effect-in-effect`](#no-effect-in-effect) | Disallow creating a new `effect()` inside an `effect()` body | error |
103
104
  | [`no-async-effect`](#no-async-effect) | Disallow async callbacks passed to `effect()` | error |
104
105
  | [`no-async-computed`](#no-async-computed) | Disallow async callbacks passed to `computed()` | error |
@@ -325,18 +326,86 @@ t.literal(userContent);
325
326
 
326
327
  ### `no-new-computed-in-computed`
327
328
 
328
- Creating `computed()` inside a `computed()` body creates a new orphaned derived signal on every recompute.
329
+ Creating `computed()` or `.transform()` inside a `computed()` body without a key creates a new orphaned derived signal on every recompute. Pass a stable key as the second argument to reuse the same instance across outer re-runs.
329
330
 
330
331
  ```js
331
- // Bad
332
- const outer = computed(() => {
333
- const inner = computed(() => count.get() * 2); // error. Orphaned every recompute.
334
- return inner.get() + 1;
335
- });
332
+ // Bad. New instance on every outer re-run, inner state lost
333
+ const list = computed(() =>
334
+ items.get().map(item => {
335
+ const cls = computed(() => filter.get() === item.cat ? 'on' : ''); // warn
336
+ return t.li({ dataKey: item.id, class: cls }, item.name);
337
+ })
338
+ );
336
339
 
337
- // Good
338
- const inner = computed(() => count.get() * 2);
339
- const outer = computed(() => inner.get() + 1);
340
+ // Bad. Same problem with .transform()
341
+ const list = computed(() =>
342
+ items.get().map(item =>
343
+ t.li({ dataKey: item.id, class: filter.transform(f => f === item.cat ? 'on' : '') }, item.name) // warn
344
+ )
345
+ );
346
+
347
+ // Good. Keyed computed
348
+ const list = computed(() =>
349
+ items.get().map(item =>
350
+ t.li({ dataKey: item.id, class: computed(() => filter.get() === item.cat ? 'on' : '', item.id) }, item.name)
351
+ )
352
+ );
353
+
354
+ // Good. Keyed transform
355
+ const list = computed(() =>
356
+ items.get().map(item =>
357
+ t.li({ dataKey: item.id, class: filter.transform(f => f === item.cat ? 'on' : '', item.id) }, item.name)
358
+ )
359
+ );
360
+
361
+ // Also good. Declare outside when fn has no per-item closure
362
+ const upper = computed(() => name.get().toUpperCase());
363
+ const outer = computed(() => upper.get() + '!');
364
+ ```
365
+
366
+ ---
367
+
368
+ ### `no-out-of-scope-reactive-reference`
369
+
370
+ A reactive primitive (`signal()`, `computed()`, or `.transform()`) created inside a `computed()` body is owned by the surrounding computed. The owner can stop it at any time. When a re-run doesn't access the key, the instance is swept from the registry and stopped. Any reference held outside the owner's scope silently drops subscribers and produces out-of-sync state.
371
+
372
+ Two inline-consumption patterns are safe and allowed:
373
+
374
+ 1. The result is consumed by an immediate method chain (`.get()`, `.transform()`, `.toString()`, etc.). The chain consumes the instance; the instance itself never escapes.
375
+ 2. The result is passed directly to a tag call as content or an attribute value. The DOM binding effect created by `toElement()` is part of the owner's own render cycle, so its lifetime is tied to the DOM.
376
+
377
+ ```js
378
+ // Bad. Instance escapes via module-level cache; external code can hold a dead signal
379
+ const editingSignals = new Map();
380
+ const list = computed(() =>
381
+ items.get().map(item => {
382
+ const editing = signal(false, item.id);
383
+ editingSignals.set(item.id, editing); // warn
384
+ return t.li({ dataKey: item.id, class: editing.transform(v => v ? 'on' : '') }, item.name);
385
+ })
386
+ );
387
+
388
+ // Bad. Instance returned from map; consumers see a stale signal after a sweep
389
+ const list = computed(() =>
390
+ items.get().map(item => computed(() => item.v * 2, item.id)) // warn
391
+ );
392
+
393
+ // Good. Consumed via method chain
394
+ const list = computed(() =>
395
+ items.get().map(item =>
396
+ computed(() => filter.get() === item.cat ? 'on' : '', item.id).get()
397
+ )
398
+ );
399
+
400
+ // Good. Passed directly to a tag; DOM binding owns lifetime
401
+ const list = computed(() =>
402
+ items.get().map(item =>
403
+ t.li({
404
+ dataKey: item.id,
405
+ class: computed(() => filter.get() === item.cat ? 'on' : '', item.id),
406
+ }, item.name)
407
+ )
408
+ );
340
409
  ```
341
410
 
342
411
  ---
@@ -470,7 +539,7 @@ Auto-fixable when the group's members are contiguous in the source. Non-contiguo
470
539
 
471
540
  ### `prefer-array-for-multiline-content`
472
541
 
473
- Mirrors what `html-to-kensington` emits: when a tag's content can't fit on the same line as the opening paren, it goes in an array, even when it's the only item. The array form makes line-by-line edits easier (no need to add `[ ]` when adding a sibling).
542
+ Mirrors what `html-to-kensington` emits: when a tag's content occupies its own line(s). Separated from both the opening and closing paren. It goes in an array, even when it's the only item. The array form makes line-by-line edits easier (no need to add `[ ]` when adding a sibling).
474
543
 
475
544
  ```js
476
545
  // Bad
@@ -482,6 +551,12 @@ t.div({ class: 'x' },
482
551
  t.div({ class: 'x' }, [
483
552
  t.p('only'),
484
553
  ]);
554
+
555
+ // Also good. Content trails on the closing-paren line, no array needed
556
+ t.a({
557
+ href: 'https://example.com',
558
+ target: '_blank',
559
+ }, 'VS Code');
485
560
  ```
486
561
 
487
562
  Auto-fixable. Single-line calls (`t.div({…}, t.p('inner'))`) are left alone.
package/index.js CHANGED
@@ -11,6 +11,7 @@ import noNewSignalInComputed from './rules/no-new-signal-in-computed.js';
11
11
  import noUnsafeLiteral from './rules/no-unsafe-literal.js';
12
12
  import noNewComputedInComputed from './rules/no-new-computed-in-computed.js';
13
13
  import noEffectInEffect from './rules/no-effect-in-effect.js';
14
+ import noOutOfScopeReactiveReference from './rules/no-out-of-scope-reactive-reference.js';
14
15
  import noAsyncEffect from './rules/no-async-effect.js';
15
16
  import noAsyncComputed from './rules/no-async-computed.js';
16
17
  import preferBooleanAttributeTrue from './rules/prefer-boolean-attribute-true.js';
@@ -38,6 +39,7 @@ const plugin = {
38
39
  'no-unsafe-literal': noUnsafeLiteral,
39
40
  'no-new-computed-in-computed': noNewComputedInComputed,
40
41
  'no-effect-in-effect': noEffectInEffect,
42
+ 'no-out-of-scope-reactive-reference': noOutOfScopeReactiveReference,
41
43
  'no-async-effect': noAsyncEffect,
42
44
  'no-async-computed': noAsyncComputed,
43
45
  'prefer-boolean-attribute-true': preferBooleanAttributeTrue,
@@ -66,10 +68,11 @@ plugin.configs.recommended = {
66
68
  'kensington/no-new-computed-in-effect': 'error',
67
69
  'kensington/no-new-signal-in-computed': 'error',
68
70
  'kensington/no-unsafe-literal': 'error',
69
- 'kensington/no-new-computed-in-computed': 'error',
71
+ 'kensington/no-new-computed-in-computed': 'warn',
70
72
  'kensington/no-effect-in-effect': 'error',
71
73
  'kensington/no-async-effect': 'error',
72
74
  'kensington/no-async-computed': 'error',
75
+ 'kensington/no-out-of-scope-reactive-reference': 'warn',
73
76
  },
74
77
  };
75
78
 
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "kensington-eslint-plugin",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "ESLint rules for kensington signal correctness",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "scripts": {
8
- "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"
8
+ "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"
9
9
  },
10
10
  "peerDependencies": {
11
11
  "eslint": ">=9"
@@ -1,22 +1,30 @@
1
- // Reports computed() called inside a computed() callback. Each recompute creates a new
2
- // orphaned derived signal with no cleanup path declare the computed outside.
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.
3
6
  export default {
4
7
  meta: {
5
- type: 'problem',
8
+ type: 'suggestion',
6
9
  docs: {
7
- description: 'disallow creating a new computed() inside a computed() body',
10
+ description: 'require a stable key for computed() and .transform() calls inside a computed() body',
8
11
  },
9
12
  messages: {
10
13
  noNewComputedInComputed:
11
- 'computed() called inside a computed() body. Each recompute creates a new orphaned derived signal. ' +
12
- 'Declare the computed outside instead.',
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.',
17
+ 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.',
13
21
  },
14
22
  },
15
23
 
16
24
  create(context) {
17
25
  const computedNames = new Set();
18
26
  const effectNames = new Set();
19
- // Each entry is 'computed', 'effect', or 'other' innermost frame is last.
27
+ // Each entry is 'computed', 'effect', or 'other'. Innermost frame is last.
20
28
  const fnStack = [];
21
29
 
22
30
  return {
@@ -53,14 +61,28 @@ export default {
53
61
  },
54
62
 
55
63
  CallExpression(node) {
64
+ let messageId;
56
65
  if (
57
- node.callee.type !== 'Identifier' ||
58
- !computedNames.has(node.callee.name)
59
- ) { return; }
66
+ node.callee.type === 'Identifier'
67
+ && computedNames.has(node.callee.name)
68
+ ) {
69
+ messageId = 'noNewComputedInComputed';
70
+ } else if (
71
+ node.callee.type === 'MemberExpression'
72
+ && !node.callee.computed
73
+ && node.callee.property.type === 'Identifier'
74
+ && node.callee.property.name === 'transform'
75
+ ) {
76
+ messageId = 'noNewTransformInComputed';
77
+ } else {
78
+ return;
79
+ }
80
+ // A key was supplied. This is the intended keyed pattern, not a problem.
81
+ if (node.arguments.length >= 2) { return; }
60
82
 
61
83
  for (let i = fnStack.length - 1; i >= 0; i--) {
62
84
  if (fnStack[i] === 'computed') {
63
- context.report({ node, messageId: 'noNewComputedInComputed' });
85
+ context.report({ node, messageId });
64
86
  return;
65
87
  }
66
88
  if (fnStack[i] === 'effect') { return; }
@@ -0,0 +1,160 @@
1
+ // Reports reactive primitives (signal, computed, .transform) that are created inside a
2
+ // `computed()` callback but escape its scope. Assigned to a variable that survives the
3
+ // callback, returned for external use, captured by module-level state, etc. Even with a
4
+ // stable key, the owning computed can stop the inner instance at any time (when its key
5
+ // isn't accessed during a re-run), so external references silently drop subscribers and
6
+ // produce out-of-sync state.
7
+ //
8
+ // Two consumption patterns are safe and allowed:
9
+ // 1. Calling .get() immediately. Extracts the value as a plain JS value
10
+ // 2. Passing the result directly to a tag call as content or an attribute value.
11
+ // the DOM-binding effect created by toElement() is flagged as internal at runtime
12
+ // and is part of the owner's own render cycle, so its lifetime is tied to the DOM.
13
+
14
+ // Returns true when `node` is being used as an argument to a tag-builder method call
15
+ // (t.li(node), t.div({ class: node }), t.span([..., node, ...])). Recurses through
16
+ // containing object/array literals to handle attribute objects and content arrays.
17
+ function isTagArgument(node) {
18
+ let current = node.parent;
19
+ let inner = node;
20
+ while (current) {
21
+ if (current.type === 'CallExpression') {
22
+ const c = current.callee;
23
+ if (
24
+ c.type === 'MemberExpression'
25
+ && !c.computed
26
+ && c.object.type === 'Identifier'
27
+ && c.property.type === 'Identifier'
28
+ && /^[a-z]/.test(c.property.name)
29
+ ) {
30
+ if (current.arguments.includes(inner)) { return true; }
31
+ }
32
+ return false;
33
+ }
34
+ if (
35
+ current.type === 'ArrayExpression'
36
+ || current.type === 'ObjectExpression'
37
+ || current.type === 'Property'
38
+ || current.type === 'SpreadElement'
39
+ ) {
40
+ inner = current;
41
+ current = current.parent;
42
+ continue;
43
+ }
44
+ return false;
45
+ }
46
+ return false;
47
+ }
48
+
49
+ export default {
50
+ meta: {
51
+ type: 'suggestion',
52
+ docs: {
53
+ description: 'disallow referencing a signal/computed/transform from outside the computed scope where it was created',
54
+ },
55
+ messages: {
56
+ noOutOfScopeSignal:
57
+ 'signal() created inside a computed() body is referenced out of scope. ' +
58
+ 'The instance is owned by the surrounding computed and may be stopped at any time. ' +
59
+ 'Consume inline: call .get() on it, or pass it directly to a tag.',
60
+ noOutOfScopeComputed:
61
+ 'computed() created inside a computed() body is referenced out of scope. ' +
62
+ 'The instance is owned by the surrounding computed and may be stopped at any time. ' +
63
+ 'Consume inline: call .get() on it, or pass it directly to a tag.',
64
+ noOutOfScopeTransform:
65
+ '.transform() called inside a computed() body is referenced out of scope. ' +
66
+ 'The instance is owned by the surrounding computed and may be stopped at any time. ' +
67
+ 'Consume inline: call .get() on it, or pass it directly to a tag.',
68
+ },
69
+ },
70
+
71
+ create(context) {
72
+ const signalNames = new Set();
73
+ const computedNames = new Set();
74
+ const effectNames = new Set();
75
+ // Each entry is 'computed', 'effect', or 'other'. Innermost frame is last.
76
+ const fnStack = [];
77
+
78
+ return {
79
+ ImportDeclaration(node) {
80
+ if (node.source.value !== 'kensington') { return; }
81
+ for (const spec of node.specifiers) {
82
+ if (spec.type !== 'ImportSpecifier') { continue; }
83
+ if (spec.imported.name === 'signal') { signalNames.add(spec.local.name); }
84
+ if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
85
+ if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
86
+ }
87
+ },
88
+
89
+ ':matches(ArrowFunctionExpression, FunctionExpression)'(node) {
90
+ const { parent } = node;
91
+ if (
92
+ parent.type === 'CallExpression' &&
93
+ parent.arguments[0] === node &&
94
+ parent.callee.type === 'Identifier'
95
+ ) {
96
+ if (computedNames.has(parent.callee.name)) {
97
+ fnStack.push('computed');
98
+ return;
99
+ }
100
+ if (effectNames.has(parent.callee.name)) {
101
+ fnStack.push('effect');
102
+ return;
103
+ }
104
+ }
105
+ fnStack.push('other');
106
+ },
107
+
108
+ ':matches(ArrowFunctionExpression, FunctionExpression):exit'() {
109
+ fnStack.pop();
110
+ },
111
+
112
+ CallExpression(node) {
113
+ let messageId;
114
+ if (
115
+ node.callee.type === 'Identifier'
116
+ && signalNames.has(node.callee.name)
117
+ ) {
118
+ messageId = 'noOutOfScopeSignal';
119
+ } else if (
120
+ node.callee.type === 'Identifier'
121
+ && computedNames.has(node.callee.name)
122
+ ) {
123
+ messageId = 'noOutOfScopeComputed';
124
+ } else if (
125
+ node.callee.type === 'MemberExpression'
126
+ && !node.callee.computed
127
+ && node.callee.property.type === 'Identifier'
128
+ && node.callee.property.name === 'transform'
129
+ ) {
130
+ messageId = 'noOutOfScopeTransform';
131
+ } else {
132
+ return;
133
+ }
134
+
135
+ // Must be inside a computed callback. Inside an effect is a different concern.
136
+ let insideComputed = false;
137
+ for (let i = fnStack.length - 1; i >= 0; i--) {
138
+ if (fnStack[i] === 'computed') { insideComputed = true; break; }
139
+ if (fnStack[i] === 'effect') { return; }
140
+ }
141
+ if (!insideComputed) { return; }
142
+
143
+ // Safe: result is consumed immediately by a method chain. Call.get(),
144
+ // call.transform(...), call.toString(), etc. The chain consumes the instance;
145
+ // the instance itself never escapes the scope.
146
+ const { parent } = node;
147
+ if (
148
+ parent.type === 'MemberExpression'
149
+ && !parent.computed
150
+ && parent.object === node
151
+ ) { return; }
152
+
153
+ // Safe: passed directly to a tag call as content or an attribute value.
154
+ if (isTagArgument(node)) { return; }
155
+
156
+ context.report({ node, messageId });
157
+ },
158
+ };
159
+ },
160
+ };
@@ -1,6 +1,8 @@
1
- // Tag content on a separate line from the call's opening paren must be wrapped
2
- // in an array, even when it's the only item. Mirrors what html-to-kensington
3
- // emits when a tag's content can't fit on a single line.
1
+ // Tag content that occupies its own line(s) — separated from both the call's
2
+ // opening paren and its closing paren must be wrapped in an array, even when
3
+ // it's the only item. Mirrors what html-to-kensington emits when a tag's
4
+ // content can't fit on a single line. Content that trails on the closing-paren
5
+ // line stays bare.
4
6
 
5
7
  import { isTagCall, getObjectNames, objectNamesSchema } from './_utils.js';
6
8
 
@@ -48,15 +50,18 @@ export default {
48
50
  if (content.type === 'SpreadElement') { return; }
49
51
 
50
52
  // The "opening line" is where the open paren sits — same line as the
51
- // callee's last token. Content on that line stays bare.
53
+ // callee's last token. Content that touches either the opening-paren
54
+ // line or the closing-paren line stays bare.
52
55
  const openParenLine = node.callee.loc.end.line;
53
56
  if (content.loc.start.line <= openParenLine) { return; }
54
57
 
58
+ const closeParen = sourceCode.getLastToken(node);
59
+ if (closeParen && closeParen.value === ')' && content.loc.end.line >= closeParen.loc.start.line) { return; }
60
+
55
61
  context.report({
56
62
  node: content,
57
63
  messageId: 'wrapInArray',
58
64
  *fix(fixer) {
59
- const closeParen = sourceCode.getLastToken(node);
60
65
  const tokenAfterContent = sourceCode.getTokenAfter(content);
61
66
  const hasTrailingComma = tokenAfterContent
62
67
  && tokenAfterContent.value === ','