kensington-eslint-plugin 0.5.0 → 0.5.1
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/package.json
CHANGED
|
@@ -1,15 +1,38 @@
|
|
|
1
1
|
// Reports reactive primitives (signal, computed, .transform) that are created inside a
|
|
2
|
-
// `computed()` callback
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// isn't accessed during a re-run), so external references silently drop subscribers and
|
|
6
|
-
// produce out-of-sync state.
|
|
2
|
+
// `computed()` callback and escape its scope. The owning computed can stop the inner
|
|
3
|
+
// instance at any time (when its key isn't accessed during a re-run), so external
|
|
4
|
+
// references silently drop subscribers and produce out-of-sync state.
|
|
7
5
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
6
|
+
// Designed to avoid false positives. The conservative-by-default policy is:
|
|
7
|
+
// - The CREATION site of a signal/computed/.transform inside a computed body is only
|
|
8
|
+
// flagged when the result is used in a way that clearly escapes the callback. The
|
|
9
|
+
// common idiom `const sig = computed(...); return t.div(sig);` (bind to a const,
|
|
10
|
+
// use as a tag argument) is safe and not flagged.
|
|
11
|
+
// - The OFFENDING REFERENCE (not just the creation site) is what the rule flags,
|
|
12
|
+
// so the message points at the actual escape rather than the (safe) creation.
|
|
13
|
+
//
|
|
14
|
+
// Patterns the rule treats as SAFE (none of these fire the rule):
|
|
15
|
+
// 1. Result consumed inline by a method chain (`.get()`, `.transform()`, `.set()`,
|
|
16
|
+
// `.value`, etc.).
|
|
17
|
+
// 2. Result passed directly to a tag call as content or an attribute value.
|
|
18
|
+
// 3. Result passed as a function-call argument (`helper(sig)`, `obj.method(sig)`).
|
|
19
|
+
// We assume the receiving function is synchronous and well-behaved.
|
|
20
|
+
// 4. Result assigned to a `const` declared inside the same computed callback, where
|
|
21
|
+
// every reference to that const is itself a safe pattern (1-3 above or
|
|
22
|
+
// return-from-the-computed-callback-directly).
|
|
23
|
+
//
|
|
24
|
+
// Patterns the rule treats as ESCAPES (these fire the rule):
|
|
25
|
+
// A. Reference appears as the return value of a NESTED function inside the computed
|
|
26
|
+
// callback (e.g. the mapFn of `arr.map(item => sig)`). The signal then becomes
|
|
27
|
+
// part of the outer computed's value, subscribed to by consumers outside the
|
|
28
|
+
// callback scope.
|
|
29
|
+
// B. Reference is assigned to an identifier defined OUTSIDE the computed callback
|
|
30
|
+
// (`outsideVar = sig` where `outsideVar` is from a parent scope or module scope).
|
|
31
|
+
//
|
|
32
|
+
// Returning a signal directly from the computed callback (`return sig`) is the
|
|
33
|
+
// canonical "return-a-signal-from-a-component-function" pattern and is treated as
|
|
34
|
+
// safe; the binding effect on the outer's content is internal and the runtime's
|
|
35
|
+
// `_isInternal` check handles it.
|
|
13
36
|
|
|
14
37
|
// Returns true when `node` is being used as an argument to a tag-builder method call
|
|
15
38
|
// (t.li(node), t.div({ class: node }), t.span([..., node, ...])). Recurses through
|
|
@@ -46,6 +69,110 @@ function isTagArgument(node) {
|
|
|
46
69
|
return false;
|
|
47
70
|
}
|
|
48
71
|
|
|
72
|
+
// Walks up `node`'s parents through array/object/property/spread containers and
|
|
73
|
+
// returns the first ancestor that isn't one of those. Used to classify how a
|
|
74
|
+
// reference is consumed.
|
|
75
|
+
function unwrapContainers(node) {
|
|
76
|
+
let inner = node;
|
|
77
|
+
let current = node.parent;
|
|
78
|
+
while (
|
|
79
|
+
current
|
|
80
|
+
&& (current.type === 'ArrayExpression'
|
|
81
|
+
|| current.type === 'ObjectExpression'
|
|
82
|
+
|| current.type === 'Property'
|
|
83
|
+
|| current.type === 'SpreadElement')
|
|
84
|
+
) {
|
|
85
|
+
inner = current;
|
|
86
|
+
current = current.parent;
|
|
87
|
+
}
|
|
88
|
+
return { inner, parent: current };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Given a node that's KNOWN to be inside a computed callback, walks up to find the
|
|
92
|
+
// nearest enclosing function. Returns that function node (the computed callback OR
|
|
93
|
+
// a nested function inside it).
|
|
94
|
+
function nearestEnclosingFunction(node) {
|
|
95
|
+
let cur = node.parent;
|
|
96
|
+
while (cur) {
|
|
97
|
+
if (
|
|
98
|
+
cur.type === 'ArrowFunctionExpression'
|
|
99
|
+
|| cur.type === 'FunctionExpression'
|
|
100
|
+
|| cur.type === 'FunctionDeclaration'
|
|
101
|
+
) {
|
|
102
|
+
return cur;
|
|
103
|
+
}
|
|
104
|
+
cur = cur.parent;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Classifies a single reference to a kensington-primitive-bound const.
|
|
110
|
+
// Returns 'safe' | 'escape-return' | 'escape-assign' | 'unknown'.
|
|
111
|
+
// safe consumed via method chain, tag arg, function-call arg, OR
|
|
112
|
+
// returned directly from the computed callback itself.
|
|
113
|
+
// escape-return reference is returned from a NESTED function inside the
|
|
114
|
+
// computed callback. The value flows out via the nested function's
|
|
115
|
+
// return.
|
|
116
|
+
// escape-assign reference is assigned to an identifier declared outside the
|
|
117
|
+
// computed callback.
|
|
118
|
+
// unknown reference appears in some shape the rule doesn't recognize.
|
|
119
|
+
// Treated as safe by the no-false-positives policy.
|
|
120
|
+
function classifyReference(refNode, computedCallback) {
|
|
121
|
+
// Method chain: `sig.get()`, `sig.transform(...)`, `sig.set(...)`, `sig.value`.
|
|
122
|
+
if (
|
|
123
|
+
refNode.parent
|
|
124
|
+
&& refNode.parent.type === 'MemberExpression'
|
|
125
|
+
&& refNode.parent.object === refNode
|
|
126
|
+
) {
|
|
127
|
+
return 'safe';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Tag argument (possibly through array/object/spread containers).
|
|
131
|
+
if (isTagArgument(refNode)) {
|
|
132
|
+
return 'safe';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Function-call argument (any function, not just tag calls). The rule
|
|
136
|
+
// intentionally trusts the receiving function rather than chasing flow.
|
|
137
|
+
const { inner, parent } = unwrapContainers(refNode);
|
|
138
|
+
if (parent && parent.type === 'CallExpression' && parent.arguments.includes(inner)) {
|
|
139
|
+
return 'safe';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Assignment expression where the reference is on the right-hand side. If
|
|
143
|
+
// the left-hand-side identifier resolves to a binding OUTSIDE the computed
|
|
144
|
+
// callback, this is a clear escape.
|
|
145
|
+
if (parent && parent.type === 'AssignmentExpression' && parent.right === inner) {
|
|
146
|
+
if (parent.left.type === 'Identifier') {
|
|
147
|
+
return 'escape-assign';
|
|
148
|
+
}
|
|
149
|
+
if (
|
|
150
|
+
parent.left.type === 'MemberExpression'
|
|
151
|
+
&& parent.left.object.type === 'Identifier'
|
|
152
|
+
) {
|
|
153
|
+
// outer.field = sig — escape if `outer` is outside-scope.
|
|
154
|
+
return 'escape-assign';
|
|
155
|
+
}
|
|
156
|
+
return 'unknown';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Return statement. Safe ONLY if the return belongs to the computed callback
|
|
160
|
+
// itself; flagged if it belongs to a nested function inside the callback.
|
|
161
|
+
if (parent && parent.type === 'ReturnStatement') {
|
|
162
|
+
const fn = nearestEnclosingFunction(refNode);
|
|
163
|
+
return fn === computedCallback ? 'safe' : 'escape-return';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Concise-body arrow function whose body IS the reference. Safe only if the
|
|
167
|
+
// arrow IS the computed callback itself; otherwise the reference is the
|
|
168
|
+
// return value of a nested function.
|
|
169
|
+
if (parent && (parent.type === 'ArrowFunctionExpression' || parent.type === 'FunctionExpression') && parent.body === inner) {
|
|
170
|
+
return parent === computedCallback ? 'safe' : 'escape-return';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return 'unknown';
|
|
174
|
+
}
|
|
175
|
+
|
|
49
176
|
export default {
|
|
50
177
|
meta: {
|
|
51
178
|
type: 'suggestion',
|
|
@@ -53,18 +180,14 @@ export default {
|
|
|
53
180
|
description: 'disallow referencing a signal/computed/transform from outside the computed scope where it was created',
|
|
54
181
|
},
|
|
55
182
|
messages: {
|
|
56
|
-
|
|
57
|
-
'
|
|
58
|
-
'The instance is owned by the surrounding computed and may be stopped at any time. '
|
|
59
|
-
'
|
|
60
|
-
|
|
61
|
-
'
|
|
62
|
-
'The instance is owned by the surrounding computed and may be stopped at any time. '
|
|
63
|
-
'
|
|
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.',
|
|
183
|
+
escapeReturn:
|
|
184
|
+
'Reactive primitive created inside a computed() escapes via a nested function\'s return value. '
|
|
185
|
+
+ 'The instance is owned by the surrounding computed and may be stopped at any time. '
|
|
186
|
+
+ 'Either consume inline (call .get()) or hoist the primitive to a parent scope so it has no owner.',
|
|
187
|
+
escapeAssign:
|
|
188
|
+
'Reactive primitive created inside a computed() escapes via assignment to an outside-scope variable. '
|
|
189
|
+
+ 'The instance is owned by the surrounding computed and may be stopped at any time. '
|
|
190
|
+
+ 'Either consume inline or hoist the primitive to a parent scope so it has no owner.',
|
|
68
191
|
},
|
|
69
192
|
},
|
|
70
193
|
|
|
@@ -72,7 +195,7 @@ export default {
|
|
|
72
195
|
const signalNames = new Set();
|
|
73
196
|
const computedNames = new Set();
|
|
74
197
|
const effectNames = new Set();
|
|
75
|
-
// Each entry is 'computed'
|
|
198
|
+
// Each entry is { kind: 'computed'|'effect'|'other', node }. Innermost last.
|
|
76
199
|
const fnStack = [];
|
|
77
200
|
|
|
78
201
|
return {
|
|
@@ -89,20 +212,20 @@ export default {
|
|
|
89
212
|
':matches(ArrowFunctionExpression, FunctionExpression)'(node) {
|
|
90
213
|
const { parent } = node;
|
|
91
214
|
if (
|
|
92
|
-
parent.type === 'CallExpression'
|
|
93
|
-
parent.arguments[0] === node
|
|
94
|
-
parent.callee.type === 'Identifier'
|
|
215
|
+
parent.type === 'CallExpression'
|
|
216
|
+
&& parent.arguments[0] === node
|
|
217
|
+
&& parent.callee.type === 'Identifier'
|
|
95
218
|
) {
|
|
96
219
|
if (computedNames.has(parent.callee.name)) {
|
|
97
|
-
fnStack.push('computed');
|
|
220
|
+
fnStack.push({ kind: 'computed', node });
|
|
98
221
|
return;
|
|
99
222
|
}
|
|
100
223
|
if (effectNames.has(parent.callee.name)) {
|
|
101
|
-
fnStack.push('effect');
|
|
224
|
+
fnStack.push({ kind: 'effect', node });
|
|
102
225
|
return;
|
|
103
226
|
}
|
|
104
227
|
}
|
|
105
|
-
fnStack.push('other');
|
|
228
|
+
fnStack.push({ kind: 'other', node });
|
|
106
229
|
},
|
|
107
230
|
|
|
108
231
|
':matches(ArrowFunctionExpression, FunctionExpression):exit'() {
|
|
@@ -110,50 +233,85 @@ export default {
|
|
|
110
233
|
},
|
|
111
234
|
|
|
112
235
|
CallExpression(node) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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'
|
|
236
|
+
// Is this a signal/computed/.transform call?
|
|
237
|
+
const isSignalCall = node.callee.type === 'Identifier' && signalNames.has(node.callee.name);
|
|
238
|
+
const isComputedCall = node.callee.type === 'Identifier' && computedNames.has(node.callee.name);
|
|
239
|
+
const isTransformCall = node.callee.type === 'MemberExpression'
|
|
126
240
|
&& !node.callee.computed
|
|
127
241
|
&& node.callee.property.type === 'Identifier'
|
|
128
|
-
&& node.callee.property.name === 'transform'
|
|
129
|
-
) {
|
|
130
|
-
messageId = 'noOutOfScopeTransform';
|
|
131
|
-
} else {
|
|
242
|
+
&& node.callee.property.name === 'transform';
|
|
243
|
+
if (!isSignalCall && !isComputedCall && !isTransformCall) {
|
|
132
244
|
return;
|
|
133
245
|
}
|
|
134
246
|
|
|
135
|
-
// Must be inside a computed callback
|
|
136
|
-
|
|
247
|
+
// Must be inside a computed callback (innermost reactive frame). Inside
|
|
248
|
+
// an effect is a different rule's concern.
|
|
249
|
+
let computedFrame = null;
|
|
137
250
|
for (let i = fnStack.length - 1; i >= 0; i--) {
|
|
138
|
-
if (fnStack[i] === 'computed') {
|
|
139
|
-
if (fnStack[i] === 'effect') { return; }
|
|
251
|
+
if (fnStack[i].kind === 'computed') { computedFrame = fnStack[i]; break; }
|
|
252
|
+
if (fnStack[i].kind === 'effect') { return; }
|
|
140
253
|
}
|
|
141
|
-
if (!
|
|
254
|
+
if (!computedFrame) { return; }
|
|
255
|
+
const computedCallback = computedFrame.node;
|
|
142
256
|
|
|
143
|
-
//
|
|
144
|
-
// call.transform(...), call.toString(), etc. The chain consumes the instance;
|
|
145
|
-
// the instance itself never escapes the scope.
|
|
146
|
-
const { parent } = node;
|
|
257
|
+
// Direct-use safe patterns at the creation site.
|
|
147
258
|
if (
|
|
148
|
-
parent
|
|
149
|
-
&&
|
|
150
|
-
&& parent.
|
|
151
|
-
|
|
259
|
+
node.parent
|
|
260
|
+
&& node.parent.type === 'MemberExpression'
|
|
261
|
+
&& !node.parent.computed
|
|
262
|
+
&& node.parent.object === node
|
|
263
|
+
) {
|
|
264
|
+
return; // method chain
|
|
265
|
+
}
|
|
266
|
+
if (isTagArgument(node)) {
|
|
267
|
+
return; // tag content / attribute value
|
|
268
|
+
}
|
|
152
269
|
|
|
153
|
-
//
|
|
154
|
-
if
|
|
270
|
+
// If bound to a const inside the callback, classify each reference. Only
|
|
271
|
+
// flag if at least one reference is a clear escape (escape-return or
|
|
272
|
+
// escape-assign). Everything else is treated as safe.
|
|
273
|
+
if (
|
|
274
|
+
node.parent
|
|
275
|
+
&& node.parent.type === 'VariableDeclarator'
|
|
276
|
+
&& node.parent.init === node
|
|
277
|
+
&& node.parent.id.type === 'Identifier'
|
|
278
|
+
) {
|
|
279
|
+
const varName = node.parent.id.name;
|
|
280
|
+
const scope = context.sourceCode.getScope(node);
|
|
281
|
+
const variable = scope.variables.find(v => v.name === varName)
|
|
282
|
+
|| scope.references.find(r => r.identifier.name === varName)?.resolved;
|
|
283
|
+
if (!variable) {
|
|
284
|
+
return; // can't analyze; trust the no-false-positives policy
|
|
285
|
+
}
|
|
286
|
+
for (const ref of variable.references) {
|
|
287
|
+
// Skip the declaration's own write reference.
|
|
288
|
+
if (ref.identifier === node.parent.id) { continue; }
|
|
289
|
+
const refNode = ref.identifier;
|
|
290
|
+
const verdict = classifyReference(refNode, computedCallback);
|
|
291
|
+
if (verdict === 'escape-return') {
|
|
292
|
+
context.report({ node: refNode, messageId: 'escapeReturn' });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (verdict === 'escape-assign') {
|
|
296
|
+
context.report({ node: refNode, messageId: 'escapeAssign' });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return; // all references are safe (or unknown but trusted)
|
|
301
|
+
}
|
|
155
302
|
|
|
156
|
-
|
|
303
|
+
// Not bound to a const. Classify the creation site itself as if it were
|
|
304
|
+
// a reference (handles the inline return-from-map idiom).
|
|
305
|
+
const verdict = classifyReference(node, computedCallback);
|
|
306
|
+
if (verdict === 'escape-return') {
|
|
307
|
+
context.report({ node, messageId: 'escapeReturn' });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (verdict === 'escape-assign') {
|
|
311
|
+
context.report({ node, messageId: 'escapeAssign' });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// 'safe' or 'unknown' → no report.
|
|
157
315
|
},
|
|
158
316
|
};
|
|
159
317
|
},
|