kensington-eslint-plugin 0.1.2 → 0.2.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
@@ -70,6 +70,8 @@ Because this is a standard ESLint plugin, it works anywhere ESLint runs — no e
70
70
  | [`no-effect-in-effect`](#no-effect-in-effect) | Disallow creating a new `effect()` inside an `effect()` body | error |
71
71
  | [`no-async-effect`](#no-async-effect) | Disallow async callbacks passed to `effect()` | error |
72
72
  | [`no-async-computed`](#no-async-computed) | Disallow async callbacks passed to `computed()` | error |
73
+ | [`no-set-in-transform`](#no-set-in-transform) | Disallow `.set()` inside a `.transform()` callback | error |
74
+ | [`no-set-on-transform`](#no-set-on-transform) | Disallow `.set()` on a transform-derived signal | error |
73
75
 
74
76
  ---
75
77
 
@@ -345,3 +347,37 @@ effect(() => {
345
347
  fetch('/api').then(r => r.json()).then(v => data.set(v));
346
348
  });
347
349
  ```
350
+
351
+ ---
352
+
353
+ ### `no-set-in-transform`
354
+
355
+ Transform callbacks must be pure derivations. Calling `.set()` inside one causes a write during a read pass, the same class of bug as `.set()` inside `computed()`.
356
+
357
+ ```js
358
+ // Bad
359
+ const rows = items.transform(list => {
360
+ selectedId.set(null); // error
361
+ return list.map(item => t.li(item.name));
362
+ });
363
+
364
+ // Good — move the write into a separate effect
365
+ effect(() => {
366
+ if (!items.get().length) { selectedId.set(null); }
367
+ });
368
+ ```
369
+
370
+ ---
371
+
372
+ ### `no-set-on-transform`
373
+
374
+ Transform-derived signals are read-only. Calling `.set()` on one throws at runtime; this rule catches it statically.
375
+
376
+ ```js
377
+ // Bad
378
+ const doubled = count.transform(v => v * 2);
379
+ doubled.set(10); // error — transform results are read-only
380
+
381
+ // Good — write to the source signal instead
382
+ count.set(5);
383
+ ```
package/index.js CHANGED
@@ -13,6 +13,8 @@ import noNewComputedInComputed from './rules/no-new-computed-in-computed.js';
13
13
  import noEffectInEffect from './rules/no-effect-in-effect.js';
14
14
  import noAsyncEffect from './rules/no-async-effect.js';
15
15
  import noAsyncComputed from './rules/no-async-computed.js';
16
+ import noSetInTransform from './rules/no-set-in-transform.js';
17
+ import noSetOnTransform from './rules/no-set-on-transform.js';
16
18
 
17
19
  const plugin = {
18
20
  meta: { name: 'eslint-plugin-kensington' },
@@ -32,6 +34,8 @@ const plugin = {
32
34
  'no-effect-in-effect': noEffectInEffect,
33
35
  'no-async-effect': noAsyncEffect,
34
36
  'no-async-computed': noAsyncComputed,
37
+ 'no-set-in-transform': noSetInTransform,
38
+ 'no-set-on-transform': noSetOnTransform,
35
39
  },
36
40
  configs: {},
37
41
  };
@@ -54,6 +58,8 @@ plugin.configs.recommended = {
54
58
  'kensington/no-effect-in-effect': 'error',
55
59
  'kensington/no-async-effect': 'error',
56
60
  'kensington/no-async-computed': 'error',
61
+ 'kensington/no-set-in-transform': 'error',
62
+ 'kensington/no-set-on-transform': 'error',
57
63
  },
58
64
  };
59
65
 
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "kensington-eslint-plugin",
3
- "version": "0.1.2",
3
+ "version": "0.2.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-computed.test.js tests/no-self-read-write.test.js tests/no-signal-async-write.test.js tests/no-set-on-computed.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"
8
+ "test": "node --test tests/no-set-in-computed.test.js tests/no-self-read-write.test.js tests/no-signal-async-write.test.js tests/no-set-on-computed.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/no-set-in-transform.test.js tests/no-set-on-transform.test.js"
9
9
  },
10
10
  "peerDependencies": {
11
11
  "eslint": ">=9"
@@ -20,6 +20,13 @@
20
20
  "type": "git",
21
21
  "url": "https://github.com/ryanlsimms/kensington-eslint-plugin"
22
22
  },
23
+ "keywords": [
24
+ "eslint",
25
+ "eslintplugin",
26
+ "eslint-plugin",
27
+ "kensington",
28
+ "signals"
29
+ ],
23
30
  "license": "ISC",
24
31
  "files": [
25
32
  "index.js",
@@ -0,0 +1,80 @@
1
+ // Reports .set() calls inside a .transform() callback.
2
+ // Stops searching when a nested effect() or computed() is reached (separate reactive context).
3
+ export default {
4
+ meta: {
5
+ type: 'problem',
6
+ docs: {
7
+ description: 'disallow .set() inside a .transform() callback',
8
+ },
9
+ messages: {
10
+ noSetInTransform:
11
+ '.set() called inside a .transform() callback. Transform functions must be pure derivations. ' +
12
+ 'Move the write into a separate effect() instead.',
13
+ },
14
+ },
15
+
16
+ create(context) {
17
+ const computedNames = new Set();
18
+ const effectNames = new Set();
19
+ // Each entry is 'transform', 'computed', 'effect', or 'other' — innermost frame is last.
20
+ const fnStack = [];
21
+
22
+ return {
23
+ ImportDeclaration(node) {
24
+ if (node.source.value !== 'kensington') { return; }
25
+ for (const spec of node.specifiers) {
26
+ if (spec.type !== 'ImportSpecifier') { continue; }
27
+ if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
28
+ if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
29
+ }
30
+ },
31
+
32
+ ':matches(ArrowFunctionExpression, FunctionExpression)'(node) {
33
+ const { parent } = node;
34
+ if (parent.type === 'CallExpression' && parent.arguments[0] === node) {
35
+ if (
36
+ parent.callee.type === 'MemberExpression' &&
37
+ parent.callee.property.type === 'Identifier' &&
38
+ parent.callee.property.name === 'transform'
39
+ ) {
40
+ fnStack.push('transform');
41
+ return;
42
+ }
43
+ if (parent.callee.type === 'Identifier') {
44
+ if (computedNames.has(parent.callee.name)) {
45
+ fnStack.push('computed');
46
+ return;
47
+ }
48
+ if (effectNames.has(parent.callee.name)) {
49
+ fnStack.push('effect');
50
+ return;
51
+ }
52
+ }
53
+ }
54
+ fnStack.push('other');
55
+ },
56
+
57
+ ':matches(ArrowFunctionExpression, FunctionExpression):exit'() {
58
+ fnStack.pop();
59
+ },
60
+
61
+ CallExpression(node) {
62
+ if (
63
+ node.callee.type !== 'MemberExpression' ||
64
+ node.callee.object.type !== 'Identifier' ||
65
+ node.callee.property.type !== 'Identifier' ||
66
+ node.callee.property.name !== 'set' ||
67
+ node.arguments.length !== 1
68
+ ) { return; }
69
+
70
+ for (let i = fnStack.length - 1; i >= 0; i--) {
71
+ if (fnStack[i] === 'transform') {
72
+ context.report({ node, messageId: 'noSetInTransform' });
73
+ return;
74
+ }
75
+ if (fnStack[i] === 'effect' || fnStack[i] === 'computed') { return; }
76
+ }
77
+ },
78
+ };
79
+ },
80
+ };
@@ -0,0 +1,48 @@
1
+ // Reports .set() on a variable directly assigned from a .transform() call.
2
+ // Transform-derived signals are read-only; kensington throws at runtime, but this catches it statically.
3
+ export default {
4
+ meta: {
5
+ type: 'problem',
6
+ docs: {
7
+ description: 'disallow .set() on a transform-derived signal',
8
+ },
9
+ messages: {
10
+ noSetOnTransform:
11
+ "'{{name}}' is a transform-derived signal and cannot be written with .set(). " +
12
+ 'Use signal() for writable state.',
13
+ },
14
+ },
15
+
16
+ create(context) {
17
+ const transformBindings = new Set();
18
+
19
+ return {
20
+ VariableDeclarator(node) {
21
+ if (
22
+ node.id.type !== 'Identifier' ||
23
+ !node.init ||
24
+ node.init.type !== 'CallExpression' ||
25
+ node.init.callee.type !== 'MemberExpression' ||
26
+ node.init.callee.property.type !== 'Identifier' ||
27
+ node.init.callee.property.name !== 'transform'
28
+ ) { return; }
29
+ transformBindings.add(node.id.name);
30
+ },
31
+
32
+ CallExpression(node) {
33
+ if (
34
+ node.callee.type !== 'MemberExpression' ||
35
+ node.callee.object.type !== 'Identifier' ||
36
+ node.callee.property.type !== 'Identifier' ||
37
+ node.callee.property.name !== 'set' ||
38
+ node.arguments.length !== 1
39
+ ) { return; }
40
+
41
+ const name = node.callee.object.name;
42
+ if (transformBindings.has(name)) {
43
+ context.report({ node, messageId: 'noSetOnTransform', data: { name } });
44
+ }
45
+ },
46
+ };
47
+ },
48
+ };