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,6 +1,6 @@
1
1
  {
2
2
  "name": "kensington-eslint-plugin",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "ESLint rules for kensington signal correctness",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -1,15 +1,38 @@
1
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.
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
- // 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.
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
- 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.',
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', 'effect', or 'other'. Innermost frame is last.
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
- 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'
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. Inside an effect is a different concern.
136
- let insideComputed = false;
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') { insideComputed = true; break; }
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 (!insideComputed) { return; }
254
+ if (!computedFrame) { return; }
255
+ const computedCallback = computedFrame.node;
142
256
 
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;
257
+ // Direct-use safe patterns at the creation site.
147
258
  if (
148
- parent.type === 'MemberExpression'
149
- && !parent.computed
150
- && parent.object === node
151
- ) { return; }
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
- // Safe: passed directly to a tag call as content or an attribute value.
154
- if (isTagArgument(node)) { return; }
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
- context.report({ node, messageId });
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
  },