kensington-eslint-plugin 0.1.2
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 +347 -0
- package/index.js +60 -0
- package/package.json +28 -0
- package/rules/no-async-computed.js +47 -0
- package/rules/no-async-effect.js +49 -0
- package/rules/no-effect-in-computed.js +72 -0
- package/rules/no-effect-in-effect.js +74 -0
- package/rules/no-ignored-effect-return.js +60 -0
- package/rules/no-new-computed-in-computed.js +71 -0
- package/rules/no-new-computed-in-effect.js +71 -0
- package/rules/no-new-signal-in-computed.js +73 -0
- package/rules/no-new-signal-in-effect.js +74 -0
- package/rules/no-self-read-write.js +81 -0
- package/rules/no-set-in-computed.js +74 -0
- package/rules/no-set-on-computed.js +56 -0
- package/rules/no-signal-async-write.js +105 -0
- package/rules/no-unsafe-literal.js +28 -0
- package/rules/prefer-value-in-async.js +92 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Reports effect() calls whose return value is discarded inside a function body.
|
|
2
|
+
// The return value is { pause, resume, stop } — dropping it makes cleanup impossible,
|
|
3
|
+
// causing subscription leaks when the surrounding function runs more than once.
|
|
4
|
+
// Module-level effects are intentionally long-lived and are not flagged.
|
|
5
|
+
|
|
6
|
+
function isInsideFunction(node) {
|
|
7
|
+
let current = node.parent;
|
|
8
|
+
while (current) {
|
|
9
|
+
if (
|
|
10
|
+
current.type === 'FunctionDeclaration' ||
|
|
11
|
+
current.type === 'FunctionExpression' ||
|
|
12
|
+
current.type === 'ArrowFunctionExpression'
|
|
13
|
+
) { return true; }
|
|
14
|
+
current = current.parent;
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
meta: {
|
|
21
|
+
type: 'suggestion',
|
|
22
|
+
docs: {
|
|
23
|
+
description: 'require capturing the return value of effect() when called inside a function',
|
|
24
|
+
},
|
|
25
|
+
messages: {
|
|
26
|
+
noIgnoredEffectReturn:
|
|
27
|
+
'The return value of effect() is discarded. Inside a function, capture it so you can call ' +
|
|
28
|
+
'.stop() for cleanup — otherwise each call creates a new subscription that can never be removed.',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
create(context) {
|
|
33
|
+
const effectNames = new Set();
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
ImportDeclaration(node) {
|
|
37
|
+
if (node.source.value !== 'kensington') { return; }
|
|
38
|
+
for (const spec of node.specifiers) {
|
|
39
|
+
if (spec.type !== 'ImportSpecifier') { continue; }
|
|
40
|
+
if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
CallExpression(node) {
|
|
45
|
+
if (
|
|
46
|
+
node.callee.type !== 'Identifier' ||
|
|
47
|
+
!effectNames.has(node.callee.name)
|
|
48
|
+
) { return; }
|
|
49
|
+
|
|
50
|
+
// Return value is used unless the call is a bare ExpressionStatement.
|
|
51
|
+
if (node.parent.type !== 'ExpressionStatement') { return; }
|
|
52
|
+
|
|
53
|
+
// Only flag inside a function body — module-level effects are intentional.
|
|
54
|
+
if (!isInsideFunction(node)) { return; }
|
|
55
|
+
|
|
56
|
+
context.report({ node, messageId: 'noIgnoredEffectReturn' });
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
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.
|
|
3
|
+
export default {
|
|
4
|
+
meta: {
|
|
5
|
+
type: 'problem',
|
|
6
|
+
docs: {
|
|
7
|
+
description: 'disallow creating a new computed() inside a computed() body',
|
|
8
|
+
},
|
|
9
|
+
messages: {
|
|
10
|
+
noNewComputedInComputed:
|
|
11
|
+
'computed() called inside a computed() body. Each recompute creates a new orphaned derived signal. ' +
|
|
12
|
+
'Declare the computed outside instead.',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
create(context) {
|
|
17
|
+
const computedNames = new Set();
|
|
18
|
+
const effectNames = new Set();
|
|
19
|
+
// Each entry is '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 (
|
|
35
|
+
parent.type === 'CallExpression' &&
|
|
36
|
+
parent.arguments[0] === node &&
|
|
37
|
+
parent.callee.type === 'Identifier'
|
|
38
|
+
) {
|
|
39
|
+
if (computedNames.has(parent.callee.name)) {
|
|
40
|
+
fnStack.push('computed');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (effectNames.has(parent.callee.name)) {
|
|
44
|
+
fnStack.push('effect');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
fnStack.push('other');
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
':matches(ArrowFunctionExpression, FunctionExpression):exit'() {
|
|
52
|
+
fnStack.pop();
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
CallExpression(node) {
|
|
56
|
+
if (
|
|
57
|
+
node.callee.type !== 'Identifier' ||
|
|
58
|
+
!computedNames.has(node.callee.name)
|
|
59
|
+
) { return; }
|
|
60
|
+
|
|
61
|
+
for (let i = fnStack.length - 1; i >= 0; i--) {
|
|
62
|
+
if (fnStack[i] === 'computed') {
|
|
63
|
+
context.report({ node, messageId: 'noNewComputedInComputed' });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (fnStack[i] === 'effect') { return; }
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Reports computed() called inside an effect() callback. Each effect run creates a new
|
|
2
|
+
// orphaned derived signal with no cleanup path — declare the computed outside the effect.
|
|
3
|
+
export default {
|
|
4
|
+
meta: {
|
|
5
|
+
type: 'problem',
|
|
6
|
+
docs: {
|
|
7
|
+
description: 'disallow creating a new computed() inside an effect() body',
|
|
8
|
+
},
|
|
9
|
+
messages: {
|
|
10
|
+
noNewComputedInEffect:
|
|
11
|
+
'computed() called inside an effect() body. Each effect run creates a new orphaned derived signal. ' +
|
|
12
|
+
'Declare the computed outside the effect instead.',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
create(context) {
|
|
17
|
+
const effectNames = new Set();
|
|
18
|
+
const computedNames = new Set();
|
|
19
|
+
// Each entry is 'effect', 'computed', 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 === 'effect') { effectNames.add(spec.local.name); }
|
|
28
|
+
if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
':matches(ArrowFunctionExpression, FunctionExpression)'(node) {
|
|
33
|
+
const { parent } = node;
|
|
34
|
+
if (
|
|
35
|
+
parent.type === 'CallExpression' &&
|
|
36
|
+
parent.arguments[0] === node &&
|
|
37
|
+
parent.callee.type === 'Identifier'
|
|
38
|
+
) {
|
|
39
|
+
if (effectNames.has(parent.callee.name)) {
|
|
40
|
+
fnStack.push('effect');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (computedNames.has(parent.callee.name)) {
|
|
44
|
+
fnStack.push('computed');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
fnStack.push('other');
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
':matches(ArrowFunctionExpression, FunctionExpression):exit'() {
|
|
52
|
+
fnStack.pop();
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
CallExpression(node) {
|
|
56
|
+
if (
|
|
57
|
+
node.callee.type !== 'Identifier' ||
|
|
58
|
+
!computedNames.has(node.callee.name)
|
|
59
|
+
) { return; }
|
|
60
|
+
|
|
61
|
+
for (let i = fnStack.length - 1; i >= 0; i--) {
|
|
62
|
+
if (fnStack[i] === 'effect') {
|
|
63
|
+
context.report({ node, messageId: 'noNewComputedInEffect' });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (fnStack[i] === 'computed') { return; }
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Reports signal() called inside a computed() callback. Each recompute creates a new
|
|
2
|
+
// orphaned signal with no cleanup path — declare the signal outside the computed.
|
|
3
|
+
export default {
|
|
4
|
+
meta: {
|
|
5
|
+
type: 'problem',
|
|
6
|
+
docs: {
|
|
7
|
+
description: 'disallow creating a new signal() inside a computed() body',
|
|
8
|
+
},
|
|
9
|
+
messages: {
|
|
10
|
+
noNewSignalInComputed:
|
|
11
|
+
'signal() called inside a computed() body. Each recompute creates a new orphaned signal. ' +
|
|
12
|
+
'Declare the signal outside the computed instead.',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
create(context) {
|
|
17
|
+
const signalNames = new Set();
|
|
18
|
+
const computedNames = new Set();
|
|
19
|
+
const effectNames = new Set();
|
|
20
|
+
// Each entry is 'computed', 'effect', or 'other' — innermost frame is last.
|
|
21
|
+
const fnStack = [];
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
ImportDeclaration(node) {
|
|
25
|
+
if (node.source.value !== 'kensington') { return; }
|
|
26
|
+
for (const spec of node.specifiers) {
|
|
27
|
+
if (spec.type !== 'ImportSpecifier') { continue; }
|
|
28
|
+
if (spec.imported.name === 'signal') { signalNames.add(spec.local.name); }
|
|
29
|
+
if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
|
|
30
|
+
if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
':matches(ArrowFunctionExpression, FunctionExpression)'(node) {
|
|
35
|
+
const { parent } = node;
|
|
36
|
+
if (
|
|
37
|
+
parent.type === 'CallExpression' &&
|
|
38
|
+
parent.arguments[0] === node &&
|
|
39
|
+
parent.callee.type === 'Identifier'
|
|
40
|
+
) {
|
|
41
|
+
if (computedNames.has(parent.callee.name)) {
|
|
42
|
+
fnStack.push('computed');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (effectNames.has(parent.callee.name)) {
|
|
46
|
+
fnStack.push('effect');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
fnStack.push('other');
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
':matches(ArrowFunctionExpression, FunctionExpression):exit'() {
|
|
54
|
+
fnStack.pop();
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
CallExpression(node) {
|
|
58
|
+
if (
|
|
59
|
+
node.callee.type !== 'Identifier' ||
|
|
60
|
+
!signalNames.has(node.callee.name)
|
|
61
|
+
) { return; }
|
|
62
|
+
|
|
63
|
+
for (let i = fnStack.length - 1; i >= 0; i--) {
|
|
64
|
+
if (fnStack[i] === 'computed') {
|
|
65
|
+
context.report({ node, messageId: 'noNewSignalInComputed' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (fnStack[i] === 'effect') { return; }
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
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.
|
|
4
|
+
export default {
|
|
5
|
+
meta: {
|
|
6
|
+
type: 'problem',
|
|
7
|
+
docs: {
|
|
8
|
+
description: 'disallow creating a new signal() inside an effect() body',
|
|
9
|
+
},
|
|
10
|
+
messages: {
|
|
11
|
+
noNewSignalInEffect:
|
|
12
|
+
'signal() called inside an effect() body. Each effect run creates a new orphaned signal. ' +
|
|
13
|
+
'Declare the signal outside the effect instead.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
create(context) {
|
|
18
|
+
const effectNames = new Set();
|
|
19
|
+
const signalNames = new Set();
|
|
20
|
+
const computedNames = new Set();
|
|
21
|
+
// Each entry is 'effect', 'computed', or 'other'.
|
|
22
|
+
const fnStack = [];
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
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); }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
':matches(ArrowFunctionExpression, FunctionExpression)'(node) {
|
|
36
|
+
const { parent } = node;
|
|
37
|
+
if (
|
|
38
|
+
parent.type === 'CallExpression' &&
|
|
39
|
+
parent.arguments[0] === node &&
|
|
40
|
+
parent.callee.type === 'Identifier'
|
|
41
|
+
) {
|
|
42
|
+
if (effectNames.has(parent.callee.name)) {
|
|
43
|
+
fnStack.push('effect');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (computedNames.has(parent.callee.name)) {
|
|
47
|
+
fnStack.push('computed');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
fnStack.push('other');
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
':matches(ArrowFunctionExpression, FunctionExpression):exit'() {
|
|
55
|
+
fnStack.pop();
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
CallExpression(node) {
|
|
59
|
+
if (
|
|
60
|
+
node.callee.type !== 'Identifier' ||
|
|
61
|
+
!signalNames.has(node.callee.name)
|
|
62
|
+
) { return; }
|
|
63
|
+
|
|
64
|
+
for (let i = fnStack.length - 1; i >= 0; i--) {
|
|
65
|
+
if (fnStack[i] === 'effect') {
|
|
66
|
+
context.report({ node, messageId: 'noNewSignalInEffect' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (fnStack[i] === 'computed') { return; }
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Reports .get() and .set() on the same binding within the top-level body of
|
|
2
|
+
// an effect() or computed() callback. Nested functions are excluded — the async
|
|
3
|
+
// case is covered by the no-signal-async-write rule.
|
|
4
|
+
export default {
|
|
5
|
+
meta: {
|
|
6
|
+
type: 'problem',
|
|
7
|
+
docs: {
|
|
8
|
+
description: 'disallow reading and writing the same signal in the same effect or computed run',
|
|
9
|
+
},
|
|
10
|
+
messages: {
|
|
11
|
+
noSelfReadWrite:
|
|
12
|
+
"'{{name}}' is read via .get() and written via .set() in the same {{contextType}} run. " +
|
|
13
|
+
'This creates a reactive loop — the write re-triggers the run. ' +
|
|
14
|
+
'Use .value instead of .get() to read the current value without subscribing.',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
create(context) {
|
|
19
|
+
const effectNames = new Set();
|
|
20
|
+
const computedNames = new Set();
|
|
21
|
+
// Each frame: { type: 'effect'|'computed'|'other', reads: Set<string> }
|
|
22
|
+
const fnStack = [];
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
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 === 'computed') { computedNames.add(spec.local.name); }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
':matches(ArrowFunctionExpression, FunctionExpression)'(node) {
|
|
35
|
+
const { parent } = node;
|
|
36
|
+
if (
|
|
37
|
+
parent.type === 'CallExpression' &&
|
|
38
|
+
parent.arguments[0] === node &&
|
|
39
|
+
parent.callee.type === 'Identifier'
|
|
40
|
+
) {
|
|
41
|
+
if (effectNames.has(parent.callee.name)) {
|
|
42
|
+
fnStack.push({ type: 'effect', reads: new Set() });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (computedNames.has(parent.callee.name)) {
|
|
46
|
+
fnStack.push({ type: 'computed', reads: new Set() });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
fnStack.push({ type: 'other', reads: new Set() });
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
':matches(ArrowFunctionExpression, FunctionExpression):exit'() {
|
|
54
|
+
fnStack.pop();
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
CallExpression(node) {
|
|
58
|
+
const top = fnStack[fnStack.length - 1];
|
|
59
|
+
if (!top || top.type === 'other') { return; }
|
|
60
|
+
if (
|
|
61
|
+
node.callee.type !== 'MemberExpression' ||
|
|
62
|
+
node.callee.object.type !== 'Identifier' ||
|
|
63
|
+
node.callee.property.type !== 'Identifier'
|
|
64
|
+
) { return; }
|
|
65
|
+
|
|
66
|
+
const name = node.callee.object.name;
|
|
67
|
+
const method = node.callee.property.name;
|
|
68
|
+
|
|
69
|
+
if (method === 'get' && node.arguments.length === 0) {
|
|
70
|
+
top.reads.add(name);
|
|
71
|
+
} else if (method === 'set' && node.arguments.length === 1 && top.reads.has(name)) {
|
|
72
|
+
context.report({
|
|
73
|
+
node,
|
|
74
|
+
messageId: 'noSelfReadWrite',
|
|
75
|
+
data: { name, contextType: top.type },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Reports .set() calls inside a computed() callback imported from kensington.
|
|
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 computed() body',
|
|
8
|
+
},
|
|
9
|
+
messages: {
|
|
10
|
+
noSetInComputed:
|
|
11
|
+
'.set() called inside a computed() body. Computed 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 '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 (
|
|
35
|
+
parent.type === 'CallExpression' &&
|
|
36
|
+
parent.arguments[0] === node &&
|
|
37
|
+
parent.callee.type === 'Identifier'
|
|
38
|
+
) {
|
|
39
|
+
if (computedNames.has(parent.callee.name)) {
|
|
40
|
+
fnStack.push('computed');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (effectNames.has(parent.callee.name)) {
|
|
44
|
+
fnStack.push('effect');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
fnStack.push('other');
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
':matches(ArrowFunctionExpression, FunctionExpression):exit'() {
|
|
52
|
+
fnStack.pop();
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
CallExpression(node) {
|
|
56
|
+
if (
|
|
57
|
+
node.callee.type !== 'MemberExpression' ||
|
|
58
|
+
node.callee.object.type !== 'Identifier' ||
|
|
59
|
+
node.callee.property.type !== 'Identifier' ||
|
|
60
|
+
node.callee.property.name !== 'set' ||
|
|
61
|
+
node.arguments.length !== 1
|
|
62
|
+
) { return; }
|
|
63
|
+
|
|
64
|
+
for (let i = fnStack.length - 1; i >= 0; i--) {
|
|
65
|
+
if (fnStack[i] === 'computed') {
|
|
66
|
+
context.report({ node, messageId: 'noSetInComputed' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (fnStack[i] === 'effect') { return; }
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Reports .set() on a variable that was directly assigned from computed() imported from kensington.
|
|
2
|
+
// Computed 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 computed signal',
|
|
8
|
+
},
|
|
9
|
+
messages: {
|
|
10
|
+
noSetOnComputed:
|
|
11
|
+
"'{{name}}' is a computed signal and cannot be written with .set(). " +
|
|
12
|
+
'Use signal() for writable state.',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
create(context) {
|
|
17
|
+
const computedNames = new Set();
|
|
18
|
+
const computedBindings = new Set();
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
ImportDeclaration(node) {
|
|
22
|
+
if (node.source.value !== 'kensington') { return; }
|
|
23
|
+
for (const spec of node.specifiers) {
|
|
24
|
+
if (spec.type !== 'ImportSpecifier') { continue; }
|
|
25
|
+
if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
VariableDeclarator(node) {
|
|
30
|
+
if (
|
|
31
|
+
node.id.type !== 'Identifier' ||
|
|
32
|
+
!node.init ||
|
|
33
|
+
node.init.type !== 'CallExpression' ||
|
|
34
|
+
node.init.callee.type !== 'Identifier' ||
|
|
35
|
+
!computedNames.has(node.init.callee.name)
|
|
36
|
+
) { return; }
|
|
37
|
+
computedBindings.add(node.id.name);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
CallExpression(node) {
|
|
41
|
+
if (
|
|
42
|
+
node.callee.type !== 'MemberExpression' ||
|
|
43
|
+
node.callee.object.type !== 'Identifier' ||
|
|
44
|
+
node.callee.property.type !== 'Identifier' ||
|
|
45
|
+
node.callee.property.name !== 'set' ||
|
|
46
|
+
node.arguments.length !== 1
|
|
47
|
+
) { return; }
|
|
48
|
+
|
|
49
|
+
const name = node.callee.object.name;
|
|
50
|
+
if (computedBindings.has(name)) {
|
|
51
|
+
context.report({ node, messageId: 'noSetOnComputed', data: { name } });
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Reports .set() on a binding that was read via .get() in the enclosing effect() body,
|
|
2
|
+
// when the .set() is inside an async callback (setTimeout, rAF, .then, etc.).
|
|
3
|
+
// The async boundary means the write happens after the effect completes — re-triggering
|
|
4
|
+
// it on every async resolution and creating an invisible reactive loop.
|
|
5
|
+
|
|
6
|
+
const ASYNC_GLOBAL_FNS = new Set([
|
|
7
|
+
'setTimeout', 'setInterval', 'setImmediate',
|
|
8
|
+
'queueMicrotask', 'requestAnimationFrame', 'requestIdleCallback',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const ASYNC_METHODS = new Set(['then', 'catch', 'finally']);
|
|
12
|
+
|
|
13
|
+
function isAsyncParent(fnNode) {
|
|
14
|
+
const { parent } = fnNode;
|
|
15
|
+
if (parent.type !== 'CallExpression' || !parent.arguments.includes(fnNode)) { return false; }
|
|
16
|
+
const { callee } = parent;
|
|
17
|
+
if (callee.type === 'Identifier' && ASYNC_GLOBAL_FNS.has(callee.name)) { return true; }
|
|
18
|
+
if (
|
|
19
|
+
callee.type === 'MemberExpression' &&
|
|
20
|
+
callee.property.type === 'Identifier' &&
|
|
21
|
+
ASYNC_METHODS.has(callee.property.name)
|
|
22
|
+
) { return true; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default {
|
|
27
|
+
meta: {
|
|
28
|
+
type: 'problem',
|
|
29
|
+
docs: {
|
|
30
|
+
description: 'disallow writing a signal in an async callback when it was read in the enclosing effect()',
|
|
31
|
+
},
|
|
32
|
+
messages: {
|
|
33
|
+
noSignalAsyncWrite:
|
|
34
|
+
"'{{name}}' is read via .get() in the effect body and written via .set() in an async callback. " +
|
|
35
|
+
'This re-triggers the effect after each async operation, creating a potential reactive loop. ' +
|
|
36
|
+
'Use .value instead of .get() to read without subscribing, or guard the write with a condition check.',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
create(context) {
|
|
41
|
+
const effectNames = new Set();
|
|
42
|
+
// Each frame: { type: 'effectTop'|'asyncCb'|'other', reads: Set<string>|null }
|
|
43
|
+
// asyncCb shares the reads reference from its parent effectTop so .get() calls
|
|
44
|
+
// registered before the async boundary are visible when checking .set() inside it.
|
|
45
|
+
const fnStack = [];
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
ImportDeclaration(node) {
|
|
49
|
+
if (node.source.value !== 'kensington') { return; }
|
|
50
|
+
for (const spec of node.specifiers) {
|
|
51
|
+
if (spec.type !== 'ImportSpecifier') { continue; }
|
|
52
|
+
if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
':matches(ArrowFunctionExpression, FunctionExpression)'(node) {
|
|
57
|
+
const { parent } = node;
|
|
58
|
+
if (
|
|
59
|
+
parent.type === 'CallExpression' &&
|
|
60
|
+
parent.arguments[0] === node &&
|
|
61
|
+
parent.callee.type === 'Identifier' &&
|
|
62
|
+
effectNames.has(parent.callee.name)
|
|
63
|
+
) {
|
|
64
|
+
fnStack.push({ type: 'effectTop', reads: new Set() });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const top = fnStack[fnStack.length - 1];
|
|
69
|
+
if (top && (top.type === 'effectTop' || top.type === 'asyncCb') && isAsyncParent(node)) {
|
|
70
|
+
fnStack.push({ type: 'asyncCb', reads: top.reads });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fnStack.push({ type: 'other', reads: null });
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
':matches(ArrowFunctionExpression, FunctionExpression):exit'() {
|
|
78
|
+
fnStack.pop();
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
CallExpression(node) {
|
|
82
|
+
const top = fnStack[fnStack.length - 1];
|
|
83
|
+
if (!top || top.type === 'other') { return; }
|
|
84
|
+
if (
|
|
85
|
+
node.callee.type !== 'MemberExpression' ||
|
|
86
|
+
node.callee.object.type !== 'Identifier' ||
|
|
87
|
+
node.callee.property.type !== 'Identifier'
|
|
88
|
+
) { return; }
|
|
89
|
+
|
|
90
|
+
const name = node.callee.object.name;
|
|
91
|
+
const method = node.callee.property.name;
|
|
92
|
+
|
|
93
|
+
if (method === 'get' && node.arguments.length === 0 && top.type === 'effectTop') {
|
|
94
|
+
top.reads.add(name);
|
|
95
|
+
} else if (method === 'set' && node.arguments.length === 1 && top.type === 'asyncCb' && top.reads.has(name)) {
|
|
96
|
+
context.report({
|
|
97
|
+
node,
|
|
98
|
+
messageId: 'noSignalAsyncWrite',
|
|
99
|
+
data: { name },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
};
|