kensington-eslint-plugin 0.4.0 → 0.5.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
@@ -42,6 +42,24 @@ export default [
42
42
  ];
43
43
  ```
44
44
 
45
+ The `strict` config opts in to maximum-safety reactive correctness. It extends `recommended`, promotes every reactive-correctness `warn` rule to `error`, and adds two extra rules:
46
+
47
+ ```js
48
+ import kensington from 'kensington-eslint-plugin';
49
+
50
+ export default [
51
+ kensington.configs.strict,
52
+ // ...your other configs
53
+ ];
54
+ ```
55
+
56
+ What `strict` changes on top of `recommended`:
57
+
58
+ - **Adds `require-reactive-key`** (error). Paranoid mode. Flags every unkeyed `signal()`/`computed()`/`.transform()` call site, period. Not in `recommended` at any level. Keys are no-ops at module scope and required inside reactive scopes, so passing one always is safer than auditing call-site reachability. Suppress per call site with `eslint-disable-next-line kensington/require-reactive-key` when a top-level signal is known never to move into a reactive scope.
59
+ - **Promotes from `warn` to `error`**. `no-signal-async-write`, `no-ignored-effect-return`, `prefer-value-in-async`, `no-new-computed-in-computed`, `no-out-of-scope-reactive-reference`, `no-helper-function-trap`. All real reactive-correctness issues; strict mode chooses zero silent misses over tolerance of false positives.
60
+
61
+ Use `strict` if you want CI to fail on any reactive-correctness issue, or if you're using an agent-driven workflow that benefits from harder enforcement. Use `recommended` for production codebases that prefer the warnings as guidance.
62
+
45
63
  The `style` config is opt-in and bundles the formatting rules at `warn` level:
46
64
 
47
65
  ```js
@@ -85,32 +103,34 @@ Because this is a standard ESLint plugin, it works anywhere ESLint runs with no
85
103
 
86
104
  ## Rules
87
105
 
88
- | Rule | Description | Recommended |
89
- |------|-------------|-------------|
90
- | [`no-set-in-derivation`](#no-set-in-derivation) | Disallow `.set()` inside a `computed()` body or `.transform()` callback | error |
91
- | [`no-self-read-write`](#no-self-read-write) | Disallow reading and writing the same signal in the same reactive run | error |
92
- | [`no-set-on-derived-signal`](#no-set-on-derived-signal) | Disallow `.set()` on a derived (computed or transform) signal | error |
93
- | [`no-new-signal-in-effect`](#no-new-signal-in-effect) | Disallow creating a new `signal()` inside an `effect()` body | error |
94
- | [`no-effect-in-computed`](#no-effect-in-computed) | Disallow calling `effect()` inside a `computed()` body | error |
95
- | [`no-signal-async-write`](#no-signal-async-write) | Disallow writing a signal in an async callback when it was read in the enclosing `effect()` | warn |
96
- | [`no-ignored-effect-return`](#no-ignored-effect-return) | Require capturing the return value of `effect()` inside a function | warn |
97
- | [`prefer-value-in-async`](#prefer-value-in-async) | Prefer `.value` over `.get()` inside async callbacks within an `effect()` | warn |
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) | Require a stable key for `signal()` calls inside a `computed()` body | error |
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) | 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 |
103
- | [`no-effect-in-effect`](#no-effect-in-effect) | Disallow creating a new `effect()` inside an `effect()` body | error |
104
- | [`no-async-effect`](#no-async-effect) | Disallow async callbacks passed to `effect()` | error |
105
- | [`no-async-computed`](#no-async-computed) | Disallow async callbacks passed to `computed()` | error |
106
- | [`prefer-boolean-attribute-true`](#prefer-boolean-attribute-true) | Prefer `true` over `''` for boolean HTML attributes | style |
107
- | [`prefer-camelcase-attrs`](#prefer-camelcase-attrs) | Prefer camelCase identifier keys over quoted kebab-case | style |
108
- | [`prefer-style-object`](#prefer-style-object) | Prefer a `style` object over a CSS string | style |
109
- | [`prefer-nested-attr-groups`](#prefer-nested-attr-groups) | Prefer nested form when attrs share a kebab prefix | style |
110
- | [`prefer-array-for-multiline-content`](#prefer-array-for-multiline-content) | Require array brackets around multi-line tag content | style |
111
- | [`attrs-on-call-line`](#attrs-on-call-line) | Attributes object must hug the tag call on both ends | style |
112
- | [`attrs-canonical-shape`](#attrs-canonical-shape) | Attributes object must be inline or canonically stacked | style |
113
- | [`consistent-content-layout`](#consistent-content-layout) | Tag content must hug the attrs `}` and the call's `)` | style |
106
+ | Rule | Description | Recommended | Strict |
107
+ |------|-------------|-------------|--------|
108
+ | [`no-set-in-derivation`](#no-set-in-derivation) | Disallow `.set()` inside a `computed()` body or `.transform()` callback | error | error |
109
+ | [`no-self-read-write`](#no-self-read-write) | Disallow reading and writing the same signal in the same reactive run | error | error |
110
+ | [`no-set-on-derived-signal`](#no-set-on-derived-signal) | Disallow `.set()` on a derived (computed or transform) signal | error | error |
111
+ | [`no-new-signal-in-effect`](#no-new-signal-in-effect) | Disallow creating a new `signal()` inside an `effect()` body | error | error |
112
+ | [`no-effect-in-computed`](#no-effect-in-computed) | Disallow calling `effect()` inside a `computed()` body | error | error |
113
+ | [`no-signal-async-write`](#no-signal-async-write) | Disallow writing a signal in an async callback when it was read in the enclosing `effect()` | warn | error |
114
+ | [`no-ignored-effect-return`](#no-ignored-effect-return) | Require capturing the return value of `effect()` inside a function | warn | error |
115
+ | [`prefer-value-in-async`](#prefer-value-in-async) | Prefer `.value` over `.get()` inside async callbacks within an `effect()` | warn | error |
116
+ | [`no-new-computed-in-effect`](#no-new-computed-in-effect) | Disallow creating a new `computed()` inside an `effect()` body | error | error |
117
+ | [`no-new-signal-in-computed`](#no-new-signal-in-computed) | Require a stable key for `signal()` calls inside a `computed()` body | error | error |
118
+ | [`no-unsafe-literal`](#no-unsafe-literal) | Disallow `.unsafeLiteral()` calls that bypass XSS protection | error | error |
119
+ | [`no-new-computed-in-computed`](#no-new-computed-in-computed) | Require a stable key for `computed()` and `.transform()` calls inside a `computed()` body | warn | error |
120
+ | [`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 | error |
121
+ | [`no-effect-in-effect`](#no-effect-in-effect) | Disallow creating a new `effect()` inside an `effect()` body | error | error |
122
+ | [`no-async-effect`](#no-async-effect) | Disallow async callbacks passed to `effect()` | error | error |
123
+ | [`no-async-computed`](#no-async-computed) | Disallow async callbacks passed to `computed()` | error | error |
124
+ | [`no-helper-function-trap`](#no-helper-function-trap) | Require a stable key for `signal()`/`computed()`/`.transform()` inside helpers reachable from a reactive callback in the same file | warn | error |
125
+ | [`require-reactive-key`](#require-reactive-key) | Require a stable key on every `signal()`/`computed()`/`.transform()` call site, regardless of context | off | error |
126
+ | [`prefer-boolean-attribute-true`](#prefer-boolean-attribute-true) | Prefer `true` over `''` for boolean HTML attributes | style | style |
127
+ | [`prefer-camelcase-attrs`](#prefer-camelcase-attrs) | Prefer camelCase identifier keys over quoted kebab-case | style | style |
128
+ | [`prefer-style-object`](#prefer-style-object) | Prefer a `style` object over a CSS string | style | style |
129
+ | [`prefer-nested-attr-groups`](#prefer-nested-attr-groups) | Prefer nested form when attrs share a kebab prefix | style | style |
130
+ | [`prefer-array-for-multiline-content`](#prefer-array-for-multiline-content) | Require array brackets around multi-line tag content | style | style |
131
+ | [`attrs-on-call-line`](#attrs-on-call-line) | Attributes object must hug the tag call on both ends | style | style |
132
+ | [`attrs-canonical-shape`](#attrs-canonical-shape) | Attributes object must be inline or canonically stacked | style | style |
133
+ | [`consistent-content-layout`](#consistent-content-layout) | Tag content must hug the attrs `}` and the call's `)` | style | style |
114
134
 
115
135
  ---
116
136
 
@@ -468,6 +488,55 @@ effect(() => {
468
488
 
469
489
  ---
470
490
 
491
+ ### `no-helper-function-trap`
492
+
493
+ Catches the call-stack version of the helper-function trap that the existing `no-new-signal-in-computed` and `no-new-computed-in-computed` rules miss. Those rules only flag lexical positions (the call is written directly inside a `computed(() => ...)` body in the source). This rule does single-file call-graph analysis. For every `signal()`/`computed()`/`.transform()` call without a key inside a named function, the rule checks whether that function is reachable (directly or transitively) from a reactive callback in the same file. Reactive callbacks recognized. function args to `computed(fn)`, `effect(fn)`, `signal.transform(fn)`, and `signal.mapWithKey(key, fn)`. Both inline arrow callbacks (`mapWithKey('id', x => row(x))`) and bare-identifier callbacks (`mapWithKey('id', row)`) are recognized.
494
+
495
+ ```js
496
+ // Bad. row() is a plain helper, so signal() looks top-level in the source,
497
+ // but row() is called from inside mapWithKey's mapFn. The signal runs in the
498
+ // per-key computed at runtime.
499
+ function row(item) {
500
+ const highlight = signal(false);
501
+ return t.li({ class: highlight }, item.name);
502
+ }
503
+ const list = items.mapWithKey('id', item => row(item));
504
+
505
+ // Good. Key scopes the signal to the surrounding computed so the same
506
+ // instance is reused across re-runs.
507
+ function row(item) {
508
+ const highlight = signal(false, item.id);
509
+ return t.li({ class: highlight }, item.name);
510
+ }
511
+ const list = items.mapWithKey('id', item => row(item));
512
+ ```
513
+
514
+ Single-file analysis only. A helper defined in `cell.ts` and called from a reactive callback in `grid.ts` is NOT flagged by this rule on `cell.ts` (the call site is invisible). For cross-file coverage use `require-reactive-key`, which flags every unkeyed call site regardless of context.
515
+
516
+ False-positive surface. Helpers reachable from a reactive callback are flagged, even if they are ALSO called from non-reactive sites. The conservative choice is correct: if any call path enters a reactive scope, the key is needed.
517
+
518
+ ---
519
+
520
+ ### `require-reactive-key`
521
+
522
+ Paranoid mode. Flags every unkeyed `signal()`/`computed()`/`.transform()` call site, full stop. Keys are no-ops at module scope (the key argument is ignored when not inside a reactive scope) and required inside reactive scopes, so passing one always is safer than auditing call-site reachability.
523
+
524
+ ```js
525
+ // Flagged. Pass a key.
526
+ const count = signal(0);
527
+ const doubled = computed(() => count.get() * 2);
528
+ const half = count.transform(v => v / 2);
529
+
530
+ // Suppress per call site if the call is genuinely top-level and you do not
531
+ // want the noise.
532
+ // eslint-disable-next-line kensington/require-reactive-key
533
+ const theme = signal('light');
534
+ ```
535
+
536
+ Off in the recommended config (too noisy for production codebases that legitimately scatter top-level signals). On in the `strict` config. Intended for agent-driven workflows, refactor-prone codebases, and projects that want maximum safety against later lifting code into a reactive callback.
537
+
538
+ ---
539
+
471
540
  ### `prefer-boolean-attribute-true`
472
541
 
473
542
  The HTML spec lists ~30 boolean attributes (`disabled`, `checked`, `hidden`, `selected`, etc.). Kensington treats `true` as "present" and `false`/`null`/`undefined` as "absent". An empty string is a confusing way to spell the same thing.
@@ -0,0 +1,611 @@
1
+ #!/usr/bin/env node
2
+ // kensington-check-reactive
3
+ //
4
+ // EXPERIMENTAL. NOT YET RELEASED.
5
+ // This binary ships in the published package but is intentionally not
6
+ // documented in README.md or CHANGELOG.md. The CLI flags, output format,
7
+ // suppression-comment syntax, presence in the package, and even its name
8
+ // may change or be removed in any future release without notice. Do not
9
+ // build tooling on top of it yet. The first release that documents this
10
+ // tool in README.md is the release that commits to its contract.
11
+ //
12
+ // Cross-file static analyzer for the kensington helper-function trap. Parses
13
+ // every .ts/.tsx/.js/.jsx file under the given roots, builds a project-wide
14
+ // call graph (across imports), and reports every unkeyed signal()/computed()/
15
+ // .transform() call site inside a function reachable from a reactive callback
16
+ // anywhere in the project.
17
+ //
18
+ // Complements the single-file ESLint rule (`no-helper-function-trap`). The
19
+ // ESLint rule catches the case where the helper and the reactive callback live
20
+ // in the same file. This script catches the case where they live in different
21
+ // files connected by imports.
22
+ //
23
+ // Usage (subject to change):
24
+ // kensington-check-reactive [paths...]
25
+ // kensington-check-reactive [paths...] --json
26
+ // kensington-check-reactive [paths...] --quiet # exit-code only, no output
27
+ // kensington-check-reactive --help
28
+ //
29
+ // Exits 0 on no findings, 1 on findings, 2 on script error.
30
+
31
+ /* global process */
32
+ import { readFileSync, statSync, readdirSync } from 'node:fs';
33
+ import { resolve, dirname, join, extname, isAbsolute, sep } from 'node:path';
34
+ import { pathToFileURL } from 'node:url';
35
+ import { parse } from '@typescript-eslint/typescript-estree';
36
+
37
+ const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
38
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'cjs', '.next', '.wrangler', 'public', 'coverage']);
39
+
40
+ // Module-level config flags. Set by main() before analysis runs.
41
+ let flagJson = false;
42
+ let flagQuiet = false;
43
+
44
+ // === File discovery ========================================================
45
+
46
+ function listSourceFiles(root) {
47
+ const out = [];
48
+ function walk(dir) {
49
+ let entries;
50
+ try {
51
+ entries = readdirSync(dir, { withFileTypes: true });
52
+ } catch {
53
+ return;
54
+ }
55
+ for (const e of entries) {
56
+ if (SKIP_DIRS.has(e.name)) {
57
+ continue;
58
+ }
59
+ const full = join(dir, e.name);
60
+ if (e.isDirectory()) {
61
+ walk(full);
62
+ } else if (e.isFile() && SOURCE_EXTS.has(extname(e.name))) {
63
+ out.push(full);
64
+ }
65
+ }
66
+ }
67
+ try {
68
+ const st = statSync(root);
69
+ if (st.isFile()) {
70
+ if (SOURCE_EXTS.has(extname(root))) {
71
+ out.push(resolve(root));
72
+ }
73
+ } else {
74
+ walk(resolve(root));
75
+ }
76
+ } catch {
77
+ // ignore missing roots
78
+ }
79
+ return out;
80
+ }
81
+
82
+ // === Import resolution =====================================================
83
+
84
+ function resolveImportPath(fromFile, spec) {
85
+ // Only resolve relative imports. Bare specifiers (`kensington`, `react`) are
86
+ // out of scope.
87
+ if (!spec.startsWith('.') && !isAbsolute(spec)) {
88
+ return null;
89
+ }
90
+ const baseDir = isAbsolute(spec) ? '/' : dirname(fromFile);
91
+ const baseGuess = isAbsolute(spec) ? spec : join(baseDir, spec);
92
+ // Try exact match, then extensions, then /index.{ext}.
93
+ const candidates = [
94
+ baseGuess,
95
+ ...['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].map(ext => baseGuess + ext),
96
+ ...['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].map(ext => join(baseGuess, 'index' + ext)),
97
+ ];
98
+ // Spec may include .js extension for a .ts file (common with moduleResolution: node16).
99
+ for (const cand of candidates) {
100
+ try {
101
+ const st = statSync(cand);
102
+ if (st.isFile()) {
103
+ return resolve(cand);
104
+ }
105
+ } catch {
106
+ // continue
107
+ }
108
+ }
109
+ // Try swapping .js -> .ts.
110
+ if (spec.endsWith('.js')) {
111
+ const swap = baseGuess.replace(/\.js$/, '.ts');
112
+ try {
113
+ if (statSync(swap).isFile()) {
114
+ return resolve(swap);
115
+ }
116
+ } catch {
117
+ // continue
118
+ }
119
+ const swapTsx = baseGuess.replace(/\.js$/, '.tsx');
120
+ try {
121
+ if (statSync(swapTsx).isFile()) {
122
+ return resolve(swapTsx);
123
+ }
124
+ } catch {
125
+ // continue
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ // === Per-file analysis =====================================================
132
+
133
+ // Per-file record:
134
+ // imports: Map<localName, { sourceFile: string|null, exportedName: string }>
135
+ // exports: Map<exportedName, localName> (exportedName 'default' for default exports)
136
+ // funcs: Map<localName, { unkeyedCalls: [{loc, primitive}], callees: Set<localName> }>
137
+ // reactiveLocalEntries: Set<localName> (function names passed as reactive callbacks within this file)
138
+
139
+ function analyzeFile(file) {
140
+ let src;
141
+ try {
142
+ src = readFileSync(file, 'utf8');
143
+ } catch {
144
+ return null;
145
+ }
146
+ let ast;
147
+ try {
148
+ ast = parse(src, { loc: true, range: false, jsx: file.endsWith('.tsx') || file.endsWith('.jsx'), comment: true });
149
+ } catch (err) {
150
+ if (!flagQuiet) {
151
+ process.stderr.write(`parse error: ${file}: ${err.message}\n`);
152
+ }
153
+ return null;
154
+ }
155
+
156
+ // Lines tagged with `// kensington-check-reactive-ignore` (or the
157
+ // shorter `// check-reactive-ignore`) at the end of the line or the line
158
+ // above are suppressed. Used for legitimate lazy-registry patterns and
159
+ // similar cases where the rule's static analysis can't tell the call site
160
+ // is safe in practice (e.g. registry pre-seeded at mount).
161
+ const suppressedLines = new Set();
162
+ const srcLines = src.split('\n');
163
+ for (let i = 0; i < srcLines.length; i++) {
164
+ const line = srcLines[i];
165
+ if (/\/\/\s*(kensington-)?check-reactive-ignore\b/.test(line)) {
166
+ // 1-based; suppress the line the comment is on AND the next code line.
167
+ suppressedLines.add(i + 1);
168
+ suppressedLines.add(i + 2);
169
+ }
170
+ }
171
+
172
+ const imports = new Map();
173
+ const exports = new Map();
174
+ const funcs = new Map();
175
+ const reactiveLocalEntries = new Set();
176
+
177
+ // Kensington-imported reactive primitive names (local-alias resolution).
178
+ const signalNames = new Set();
179
+ const computedNames = new Set();
180
+ const effectNames = new Set();
181
+
182
+ // Track which function we are currently inside (named/binding) and how
183
+ // deeply nested in a reactive callback we are.
184
+ const fnStack = []; // entries: { name | null, rec }
185
+ let reactiveDepth = 0;
186
+
187
+ function currentFn() {
188
+ return fnStack.length ? fnStack[fnStack.length - 1] : null;
189
+ }
190
+
191
+ function ensureFunc(name) {
192
+ if (!funcs.has(name)) {
193
+ funcs.set(name, { unkeyedCalls: [], callees: new Set() });
194
+ }
195
+ return funcs.get(name);
196
+ }
197
+
198
+ function fnBindingName(node, parent) {
199
+ if (node.type === 'FunctionDeclaration' && node.id) {
200
+ return node.id.name;
201
+ }
202
+ if (parent && parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier' && parent.init === node) {
203
+ return parent.id.name;
204
+ }
205
+ if (parent && parent.type === 'AssignmentExpression' && parent.left.type === 'Identifier' && parent.right === node) {
206
+ return parent.left.name;
207
+ }
208
+ if (parent && parent.type === 'Property' && !parent.computed && parent.key.type === 'Identifier' && parent.value === node) {
209
+ // Object property method form. Less useful for graph but we record.
210
+ return parent.key.name;
211
+ }
212
+ if (parent && parent.type === 'ExportDefaultDeclaration') {
213
+ // Default export of an anonymous function. Use a synthetic name.
214
+ return '__default__';
215
+ }
216
+ return null;
217
+ }
218
+
219
+ function isReactiveCallback(node, parent) {
220
+ if (!parent || parent.type !== 'CallExpression') {
221
+ return false;
222
+ }
223
+ const callee = parent.callee;
224
+ if (callee.type === 'Identifier') {
225
+ if ((computedNames.has(callee.name) || effectNames.has(callee.name)) && parent.arguments[0] === node) {
226
+ return true;
227
+ }
228
+ }
229
+ if (callee.type === 'MemberExpression' && !callee.computed && callee.property.type === 'Identifier') {
230
+ if (callee.property.name === 'transform' && parent.arguments[0] === node) {
231
+ return true;
232
+ }
233
+ if (callee.property.name === 'mapWithKey' && parent.arguments[1] === node) {
234
+ return true;
235
+ }
236
+ }
237
+ return false;
238
+ }
239
+
240
+ // Generic AST walker.
241
+ function walk(node, parent) {
242
+ if (!node || typeof node !== 'object') {
243
+ return;
244
+ }
245
+ if (Array.isArray(node)) {
246
+ for (const child of node) { walk(child, parent); }
247
+ return;
248
+ }
249
+ if (typeof node.type !== 'string') {
250
+ return;
251
+ }
252
+
253
+ let pushedFrame = false;
254
+ let pushedDepth = false;
255
+
256
+ // Enter
257
+ if (node.type === 'ImportDeclaration') {
258
+ const sourceFile = resolveImportPath(file, node.source.value);
259
+ for (const spec of node.specifiers) {
260
+ if (spec.type === 'ImportSpecifier') {
261
+ const imported = spec.imported.name;
262
+ const local = spec.local.name;
263
+ if (node.importKind === 'type') {
264
+ continue;
265
+ }
266
+ if (spec.importKind === 'type') {
267
+ continue;
268
+ }
269
+ imports.set(local, { sourceFile, exportedName: imported });
270
+ if (node.source.value === 'kensington') {
271
+ if (imported === 'signal') { signalNames.add(local); }
272
+ else if (imported === 'computed') { computedNames.add(local); }
273
+ else if (imported === 'effect') { effectNames.add(local); }
274
+ }
275
+ } else if (spec.type === 'ImportDefaultSpecifier') {
276
+ imports.set(spec.local.name, { sourceFile, exportedName: 'default' });
277
+ } else if (spec.type === 'ImportNamespaceSpecifier') {
278
+ imports.set(spec.local.name, { sourceFile, exportedName: '*' });
279
+ }
280
+ }
281
+ } else if (node.type === 'ExportNamedDeclaration') {
282
+ if (node.declaration) {
283
+ if (node.declaration.type === 'FunctionDeclaration' && node.declaration.id) {
284
+ exports.set(node.declaration.id.name, node.declaration.id.name);
285
+ } else if (node.declaration.type === 'VariableDeclaration') {
286
+ for (const d of node.declaration.declarations) {
287
+ if (d.id.type === 'Identifier') {
288
+ exports.set(d.id.name, d.id.name);
289
+ }
290
+ }
291
+ }
292
+ }
293
+ if (node.specifiers) {
294
+ for (const spec of node.specifiers) {
295
+ if (spec.type !== 'ExportSpecifier') { continue; }
296
+ const exported = spec.exported.name;
297
+ const local = spec.local.name;
298
+ if (node.source) {
299
+ // Re-export from another file. exported -> { sourceFile, exportedName }
300
+ const sourceFile = resolveImportPath(file, node.source.value);
301
+ // Treat as both import (so resolution can chain) and export.
302
+ imports.set(local, { sourceFile, exportedName: local });
303
+ }
304
+ exports.set(exported, local);
305
+ }
306
+ }
307
+ } else if (node.type === 'ExportDefaultDeclaration') {
308
+ const inner = node.declaration;
309
+ if (inner.type === 'Identifier') {
310
+ exports.set('default', inner.name);
311
+ } else if (inner.type === 'FunctionDeclaration' && inner.id) {
312
+ exports.set('default', inner.id.name);
313
+ } else if (inner.type === 'FunctionDeclaration' || inner.type === 'ArrowFunctionExpression' || inner.type === 'FunctionExpression') {
314
+ exports.set('default', '__default__');
315
+ }
316
+ } else if (node.type === 'ExportAllDeclaration') {
317
+ if (node.source) {
318
+ const sourceFile = resolveImportPath(file, node.source.value);
319
+ if (sourceFile) {
320
+ exports.set('*', { reExportAll: sourceFile });
321
+ }
322
+ }
323
+ }
324
+
325
+ if (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') {
326
+ const name = fnBindingName(node, parent);
327
+ const rec = name ? ensureFunc(name) : { unkeyedCalls: [], callees: new Set(), anonymous: true };
328
+ fnStack.push({ name, rec });
329
+ pushedFrame = true;
330
+ if (isReactiveCallback(node, parent)) {
331
+ reactiveDepth++;
332
+ pushedDepth = true;
333
+ }
334
+ }
335
+
336
+ if (node.type === 'CallExpression') {
337
+ handleCall(node);
338
+ }
339
+
340
+ // Recurse into children.
341
+ for (const key of Object.keys(node)) {
342
+ if (key === 'parent' || key === 'loc' || key === 'range' || key === 'type') {
343
+ continue;
344
+ }
345
+ walk(node[key], node);
346
+ }
347
+
348
+ // Exit
349
+ if (pushedDepth) { reactiveDepth--; }
350
+ if (pushedFrame) { fnStack.pop(); }
351
+ }
352
+
353
+ function handleCall(node) {
354
+ const callee = node.callee;
355
+ const hasKey = node.arguments.length >= 2;
356
+
357
+ // Reactive-callback bare-identifier detection (callback IS an identifier,
358
+ // not a function expression).
359
+ function detectBareIdent(arg, _reason) {
360
+ if (arg && arg.type === 'Identifier') {
361
+ reactiveLocalEntries.add(arg.name);
362
+ }
363
+ }
364
+ if (callee.type === 'Identifier') {
365
+ if (computedNames.has(callee.name)) { detectBareIdent(node.arguments[0]); }
366
+ else if (effectNames.has(callee.name)) { detectBareIdent(node.arguments[0]); }
367
+ } else if (callee.type === 'MemberExpression' && !callee.computed && callee.property.type === 'Identifier') {
368
+ if (callee.property.name === 'transform') { detectBareIdent(node.arguments[0]); }
369
+ else if (callee.property.name === 'mapWithKey') { detectBareIdent(node.arguments[1]); }
370
+ }
371
+
372
+ // While we're lexically inside any reactive callback, every named-identifier
373
+ // call also makes that function a reactive entry point.
374
+ if (reactiveDepth > 0 && callee.type === 'Identifier') {
375
+ reactiveLocalEntries.add(callee.name);
376
+ }
377
+
378
+ // Record unkeyed reactive-primitive calls in the current named function.
379
+ const fn = currentFn();
380
+ if (!fn || !fn.name || fn.rec.anonymous) {
381
+ return;
382
+ }
383
+ const loc = node.loc ? { line: node.loc.start.line, column: node.loc.start.column + 1 } : { line: 0, column: 0 };
384
+ if (suppressedLines.has(loc.line)) {
385
+ return;
386
+ }
387
+ if (callee.type === 'Identifier' && signalNames.has(callee.name) && !hasKey) {
388
+ fn.rec.unkeyedCalls.push({ loc, primitive: 'signal' });
389
+ } else if (callee.type === 'Identifier' && computedNames.has(callee.name) && !hasKey) {
390
+ fn.rec.unkeyedCalls.push({ loc, primitive: 'computed' });
391
+ } else if (
392
+ callee.type === 'MemberExpression'
393
+ && !callee.computed
394
+ && callee.property.type === 'Identifier'
395
+ && callee.property.name === 'transform'
396
+ && !hasKey
397
+ ) {
398
+ fn.rec.unkeyedCalls.push({ loc, primitive: '.transform' });
399
+ } else if (callee.type === 'Identifier') {
400
+ fn.rec.callees.add(callee.name);
401
+ }
402
+ }
403
+
404
+ walk(ast, null);
405
+
406
+ return { file, imports, exports, funcs, reactiveLocalEntries };
407
+ }
408
+
409
+ // === Cross-file resolution + propagation ===================================
410
+
411
+ function buildProjectIndex(roots) {
412
+ const files = new Set();
413
+ for (const r of roots) {
414
+ for (const f of listSourceFiles(r)) { files.add(f); }
415
+ }
416
+ const index = new Map(); // file -> per-file record
417
+ for (const f of files) {
418
+ const rec = analyzeFile(f);
419
+ if (rec) {
420
+ index.set(f, rec);
421
+ }
422
+ }
423
+ return index;
424
+ }
425
+
426
+ // Resolve a local name in a file to a (file, fnName) pair where fnName is
427
+ // defined. Follows re-exports. Returns null if it doesn't resolve to a local
428
+ // function in any scanned file. The exportedName='*' (namespace import) case
429
+ // is not followed.
430
+ function resolveLocal(index, file, localName, visited = new Set()) {
431
+ const visitKey = `${file}::${localName}`;
432
+ if (visited.has(visitKey)) { return null; }
433
+ visited.add(visitKey);
434
+
435
+ const rec = index.get(file);
436
+ if (!rec) { return null; }
437
+ // Local function defined here?
438
+ if (rec.funcs.has(localName)) {
439
+ return { file, fnName: localName };
440
+ }
441
+ // Imported?
442
+ const imp = rec.imports.get(localName);
443
+ if (imp && imp.sourceFile && index.has(imp.sourceFile) && imp.exportedName !== '*') {
444
+ const targetRec = index.get(imp.sourceFile);
445
+ const exportedLocal = targetRec.exports.get(imp.exportedName);
446
+ if (typeof exportedLocal === 'string') {
447
+ return resolveLocal(index, imp.sourceFile, exportedLocal, visited);
448
+ }
449
+ // Re-export-all: search every file in the * chain.
450
+ if (exportedLocal && typeof exportedLocal === 'object' && exportedLocal.reExportAll) {
451
+ const r = resolveLocal(index, exportedLocal.reExportAll, imp.exportedName, visited);
452
+ if (r) { return r; }
453
+ }
454
+ // Last-resort. Maybe the target file has the function by the imported name
455
+ // (common pattern: re-export named directly).
456
+ if (targetRec.funcs.has(imp.exportedName)) {
457
+ return { file: imp.sourceFile, fnName: imp.exportedName };
458
+ }
459
+ }
460
+ return null;
461
+ }
462
+
463
+ function propagate(index) {
464
+ // Seed reactive-reachable set.
465
+ const reachable = new Map(); // key 'file::fnName' -> reason string
466
+ const queue = [];
467
+ for (const [file, rec] of index) {
468
+ for (const local of rec.reactiveLocalEntries) {
469
+ const r = resolveLocal(index, file, local);
470
+ if (r) {
471
+ const key = `${r.file}::${r.fnName}`;
472
+ if (!reachable.has(key)) {
473
+ reachable.set(key, `entered via ${relPath(file)} reactive callback`);
474
+ queue.push(r);
475
+ }
476
+ }
477
+ }
478
+ }
479
+ // BFS through callees.
480
+ while (queue.length) {
481
+ const { file, fnName } = queue.shift();
482
+ const rec = index.get(file);
483
+ if (!rec) { continue; }
484
+ const fn = rec.funcs.get(fnName);
485
+ if (!fn) { continue; }
486
+ for (const callee of fn.callees) {
487
+ const r = resolveLocal(index, file, callee);
488
+ if (!r) { continue; }
489
+ const key = `${r.file}::${r.fnName}`;
490
+ if (!reachable.has(key)) {
491
+ reachable.set(key, `called from ${fnName} in ${relPath(file)}`);
492
+ queue.push(r);
493
+ }
494
+ }
495
+ }
496
+ return reachable;
497
+ }
498
+
499
+ function relPath(p) {
500
+ const cwd = process.cwd() + sep;
501
+ if (p.startsWith(cwd)) { return p.slice(cwd.length); }
502
+ return p;
503
+ }
504
+
505
+ function report(index, reachable) {
506
+ const findings = [];
507
+ for (const [file, rec] of index) {
508
+ for (const [fnName, fn] of rec.funcs) {
509
+ const key = `${file}::${fnName}`;
510
+ if (!reachable.has(key)) { continue; }
511
+ const reason = reachable.get(key);
512
+ for (const hit of fn.unkeyedCalls) {
513
+ findings.push({
514
+ file: relPath(file),
515
+ line: hit.loc.line,
516
+ column: hit.loc.column,
517
+ primitive: hit.primitive,
518
+ fnName,
519
+ reason,
520
+ });
521
+ }
522
+ }
523
+ }
524
+ findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column);
525
+ return findings;
526
+ }
527
+
528
+ // === Public API ============================================================
529
+
530
+ // Programmatic entry. Returns { findings, fileCount }. Pure (no process exit,
531
+ // no stdout). Suitable for tests and tooling integration.
532
+ export function analyzeProject(roots, opts = {}) {
533
+ const prevQuiet = flagQuiet;
534
+ flagQuiet = opts.quiet ?? true;
535
+ try {
536
+ const index = buildProjectIndex(roots);
537
+ const reachable = propagate(index);
538
+ const findings = report(index, reachable);
539
+ return { findings, fileCount: index.size };
540
+ } finally {
541
+ flagQuiet = prevQuiet;
542
+ }
543
+ }
544
+
545
+ // === Main ==================================================================
546
+
547
+ function printHelp() {
548
+ process.stdout.write(
549
+ 'kensington-check-reactive (EXPERIMENTAL, NOT YET RELEASED)\n'
550
+ + '\n'
551
+ + 'Cross-file static analyzer for unkeyed signal()/computed()/.transform()\n'
552
+ + 'calls inside helper functions reachable from a reactive callback. This\n'
553
+ + 'binary is shipped for early testing only. The CLI surface, output format,\n'
554
+ + 'and even its presence in the package may change without notice. Do not\n'
555
+ + 'build tooling on top of it until it appears in README.md.\n'
556
+ + '\n'
557
+ + 'Usage:\n'
558
+ + ' kensington-check-reactive [paths...] scan and print findings\n'
559
+ + ' kensington-check-reactive [paths...] --json structured JSON output\n'
560
+ + ' kensington-check-reactive [paths...] --quiet exit code only, no output\n'
561
+ + ' kensington-check-reactive --help this message\n'
562
+ + '\n'
563
+ + 'Exits 0 on no findings, 1 on findings, 2 on script error.\n',
564
+ );
565
+ }
566
+
567
+ function main() {
568
+ const args = process.argv.slice(2);
569
+ if (args.includes('--help') || args.includes('-h')) {
570
+ printHelp();
571
+ process.exit(0);
572
+ }
573
+ flagJson = args.includes('--json');
574
+ flagQuiet = args.includes('--quiet');
575
+ const roots = args.filter(a => !a.startsWith('--'));
576
+ if (roots.length === 0) {
577
+ roots.push('.');
578
+ }
579
+
580
+ const index = buildProjectIndex(roots);
581
+ if (index.size === 0) {
582
+ if (!flagQuiet) {
583
+ process.stderr.write(`no source files found under: ${roots.join(', ')}\n`);
584
+ }
585
+ process.exit(2);
586
+ }
587
+ const reachable = propagate(index);
588
+ const findings = report(index, reachable);
589
+
590
+ if (flagJson) {
591
+ process.stdout.write(JSON.stringify({ findings }, null, 2) + '\n');
592
+ } else if (!flagQuiet) {
593
+ if (findings.length === 0) {
594
+ process.stdout.write(`kensington-check-reactive: 0 findings across ${index.size} files\n`);
595
+ } else {
596
+ for (const f of findings) {
597
+ process.stdout.write(
598
+ `${f.file}:${f.line}:${f.column}: warning: ${f.primitive}() unkeyed in \`${f.fnName}\` (${f.reason})\n`,
599
+ );
600
+ }
601
+ process.stdout.write(`\n${findings.length} finding${findings.length === 1 ? '' : 's'} across ${index.size} files\n`);
602
+ }
603
+ }
604
+
605
+ process.exit(findings.length > 0 ? 1 : 0);
606
+ }
607
+
608
+ // Run main() only when invoked as a CLI, not when imported.
609
+ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
610
+ main();
611
+ }
package/index.js CHANGED
@@ -22,6 +22,8 @@ import preferArrayForMultilineContent from './rules/prefer-array-for-multiline-c
22
22
  import attrsOnCallLine from './rules/attrs-on-call-line.js';
23
23
  import attrsCanonicalShape from './rules/attrs-canonical-shape.js';
24
24
  import consistentContentLayout from './rules/consistent-content-layout.js';
25
+ import noHelperFunctionTrap from './rules/no-helper-function-trap.js';
26
+ import requireReactiveKey from './rules/require-reactive-key.js';
25
27
 
26
28
  const plugin = {
27
29
  meta: { name: 'eslint-plugin-kensington' },
@@ -50,6 +52,8 @@ const plugin = {
50
52
  'attrs-on-call-line': attrsOnCallLine,
51
53
  'attrs-canonical-shape': attrsCanonicalShape,
52
54
  'consistent-content-layout': consistentContentLayout,
55
+ 'no-helper-function-trap': noHelperFunctionTrap,
56
+ 'require-reactive-key': requireReactiveKey,
53
57
  },
54
58
  configs: {},
55
59
  };
@@ -73,6 +77,23 @@ plugin.configs.recommended = {
73
77
  'kensington/no-async-effect': 'error',
74
78
  'kensington/no-async-computed': 'error',
75
79
  'kensington/no-out-of-scope-reactive-reference': 'warn',
80
+ 'kensington/no-helper-function-trap': 'warn',
81
+ },
82
+ };
83
+
84
+ plugin.configs.strict = {
85
+ plugins: { kensington: plugin },
86
+ rules: {
87
+ ...plugin.configs.recommended.rules,
88
+ // Promote every reactive-correctness warning to error. Strict mode trades
89
+ // tolerance of false positives for zero silent misses.
90
+ 'kensington/no-signal-async-write': 'error',
91
+ 'kensington/no-ignored-effect-return': 'error',
92
+ 'kensington/prefer-value-in-async': 'error',
93
+ 'kensington/no-new-computed-in-computed': 'error',
94
+ 'kensington/no-out-of-scope-reactive-reference': 'error',
95
+ 'kensington/no-helper-function-trap': 'error',
96
+ 'kensington/require-reactive-key': 'error',
76
97
  },
77
98
  };
78
99
 
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "kensington-eslint-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "ESLint rules for kensington signal correctness",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
+ "bin": {
8
+ "kensington-check-reactive": "./bin/check-reactive.js"
9
+ },
7
10
  "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 tests/no-out-of-scope-reactive-reference.test.js"
11
+ "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 tests/no-helper-function-trap.test.js tests/require-reactive-key.test.js tests/check-reactive.test.js"
9
12
  },
10
13
  "peerDependencies": {
11
14
  "eslint": ">=9"
@@ -30,6 +33,10 @@
30
33
  "license": "ISC",
31
34
  "files": [
32
35
  "index.js",
33
- "rules"
34
- ]
36
+ "rules",
37
+ "bin"
38
+ ],
39
+ "dependencies": {
40
+ "@typescript-eslint/typescript-estree": "^8.62.0"
41
+ }
35
42
  }
@@ -0,0 +1,278 @@
1
+ // Reports unkeyed `signal()`, `computed()`, or `.transform()` calls inside named
2
+ // functions that are transitively reachable from a reactive callback in the
3
+ // same file (a mapWithKey mapFn, a `computed(fn)`, `transform(fn)`, or
4
+ // `effect(fn)`). The existing `no-new-signal-in-computed` and
5
+ // `no-new-computed-in-computed` rules catch the LEXICAL case (the call is
6
+ // directly inside a `computed(() => ...)` body in the source). This rule
7
+ // catches the CALL-STACK case (the call is in a helper, the helper is called
8
+ // from inside a computed/transform/mapFn callback, so at runtime the call runs
9
+ // in a reactive scope).
10
+ //
11
+ // Single-file analysis only. We track only named top-level functions; nested
12
+ // anonymous helpers are out of scope (the lexical rule already covers them).
13
+ //
14
+ // Algorithm:
15
+ // Single AST walk that maintains TWO stacks:
16
+ // - fnStack: named-function frames (innermost last). Used to attribute
17
+ // unkeyed reactive primitives to their containing function and to record
18
+ // which other named functions each function calls.
19
+ // - reactiveDepth: an integer counter incremented when entering a reactive
20
+ // callback (the function argument to `computed(fn)`, `effect(fn)`,
21
+ // `signal.transform(fn)`, or `signal.mapWithKey(key, fn)`). When > 0,
22
+ // every named CallExpression encountered marks its callee as a reactive
23
+ // entry point. This captures helpers reached via arbitrary intermediate
24
+ // wrappers (`.map(a => cell(a))`, nested arrows, loops, etc.).
25
+ // Then on Program:exit, BFS from reactive entry points through the call
26
+ // graph; every reached function is in a reactive scope. Report every unkeyed
27
+ // reactive-primitive call site inside any reached function.
28
+
29
+ export default {
30
+ meta: {
31
+ type: 'suggestion',
32
+ docs: {
33
+ description:
34
+ 'require a stable key for signal/computed/.transform calls inside helper '
35
+ + 'functions called from a reactive callback (mapWithKey mapFn, '
36
+ + 'computed/transform/effect body) in the same file',
37
+ },
38
+ messages: {
39
+ helperFunctionTrap:
40
+ '{{primitive}}() call without a key inside `{{fnName}}`, which is called from '
41
+ + 'a reactive callback in this file ({{reason}}). The call runs inside the '
42
+ + 'surrounding reactive scope at runtime even though the call site looks '
43
+ + 'top-level here. Pass a stable key as the second argument to scope the '
44
+ + 'instance to the surrounding reactive scope.',
45
+ },
46
+ schema: [],
47
+ },
48
+
49
+ create(context) {
50
+ const signalNames = new Set();
51
+ const computedNames = new Set();
52
+ const effectNames = new Set();
53
+
54
+ // funcName -> { unkeyedCalls: [{node, primitive}], callees: Set<funcName> }
55
+ const funcs = new Map();
56
+ // funcName -> reason it's a reactive entry point.
57
+ const reactiveEntryPoints = new Map();
58
+ // Stack of named-function frames; innermost last.
59
+ const fnStack = [];
60
+ // How many reactive callbacks deep we are at the lexical level.
61
+ let reactiveDepth = 0;
62
+
63
+ function currentFn() {
64
+ return fnStack.length ? fnStack[fnStack.length - 1] : null;
65
+ }
66
+
67
+ function ensureFunc(name) {
68
+ if (!funcs.has(name)) {
69
+ funcs.set(name, { unkeyedCalls: [], callees: new Set() });
70
+ }
71
+ return funcs.get(name);
72
+ }
73
+
74
+ function fnBindingName(node) {
75
+ if (node.type === 'FunctionDeclaration' && node.id) {
76
+ return node.id.name;
77
+ }
78
+ if (
79
+ node.parent
80
+ && node.parent.type === 'VariableDeclarator'
81
+ && node.parent.id.type === 'Identifier'
82
+ && node.parent.init === node
83
+ ) {
84
+ return node.parent.id.name;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ // True when the given (Function|Arrow)Expression is being passed as a
90
+ // reactive callback argument to a kensington reactive wrapper or method.
91
+ function isReactiveCallback(node) {
92
+ const { parent } = node;
93
+ if (!parent || parent.type !== 'CallExpression') {
94
+ return false;
95
+ }
96
+ const callee = parent.callee;
97
+ if (callee.type === 'Identifier') {
98
+ if (
99
+ (computedNames.has(callee.name) || effectNames.has(callee.name))
100
+ && parent.arguments[0] === node
101
+ ) {
102
+ return true;
103
+ }
104
+ }
105
+ if (
106
+ callee.type === 'MemberExpression'
107
+ && !callee.computed
108
+ && callee.property.type === 'Identifier'
109
+ ) {
110
+ // signal.transform(fn) — first arg is the callback.
111
+ if (callee.property.name === 'transform' && parent.arguments[0] === node) {
112
+ return true;
113
+ }
114
+ // signal.mapWithKey(key, fn) — second arg is the callback.
115
+ if (callee.property.name === 'mapWithKey' && parent.arguments[1] === node) {
116
+ return true;
117
+ }
118
+ }
119
+ return false;
120
+ }
121
+
122
+ function reasonFor(node) {
123
+ const { parent } = node;
124
+ const callee = parent.callee;
125
+ if (callee.type === 'Identifier') {
126
+ if (computedNames.has(callee.name)) { return 'passed to computed()'; }
127
+ if (effectNames.has(callee.name)) { return 'passed to effect()'; }
128
+ }
129
+ if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') {
130
+ if (callee.property.name === 'transform') { return 'passed to .transform()'; }
131
+ if (callee.property.name === 'mapWithKey') { return 'passed to mapWithKey() as mapFn'; }
132
+ }
133
+ return 'inside a reactive callback';
134
+ }
135
+
136
+ return {
137
+ ImportDeclaration(node) {
138
+ if (node.source.value !== 'kensington') {
139
+ return;
140
+ }
141
+ for (const spec of node.specifiers) {
142
+ if (spec.type !== 'ImportSpecifier') {
143
+ continue;
144
+ }
145
+ if (spec.imported.name === 'signal') { signalNames.add(spec.local.name); }
146
+ if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
147
+ if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
148
+ }
149
+ },
150
+
151
+ ':matches(FunctionDeclaration, FunctionExpression, ArrowFunctionExpression)'(node) {
152
+ const name = fnBindingName(node);
153
+ if (name) {
154
+ fnStack.push(ensureFunc(name));
155
+ } else {
156
+ fnStack.push({ unkeyedCalls: [], callees: new Set(), anonymous: true });
157
+ }
158
+ if (isReactiveCallback(node)) {
159
+ reactiveDepth++;
160
+ }
161
+ },
162
+
163
+ ':matches(FunctionDeclaration, FunctionExpression, ArrowFunctionExpression):exit'(node) {
164
+ if (isReactiveCallback(node)) {
165
+ reactiveDepth--;
166
+ }
167
+ fnStack.pop();
168
+ },
169
+
170
+ CallExpression(node) {
171
+ const callee = node.callee;
172
+ const hasKey = node.arguments.length >= 2;
173
+
174
+ // 1. If we are lexically inside ANY reactive callback and the callee is a
175
+ // named identifier, mark that function as a reactive entry point.
176
+ if (reactiveDepth > 0 && callee.type === 'Identifier') {
177
+ if (!reactiveEntryPoints.has(callee.name)) {
178
+ reactiveEntryPoints.set(
179
+ callee.name,
180
+ `transitively reached from a reactive callback in this file`,
181
+ );
182
+ }
183
+ }
184
+
185
+ // 1b. Detect bare-identifier callbacks passed to reactive wrappers:
186
+ // computed(fn), effect(fn), signal.transform(fn), signal.mapWithKey(key, fn).
187
+ // The callback isn't lexically a Function/Arrow, so it never enters our
188
+ // reactiveDepth tracking. Mark the identifier as an entry point directly.
189
+ function checkBareIdent(arg, reason) {
190
+ if (arg && arg.type === 'Identifier' && !reactiveEntryPoints.has(arg.name)) {
191
+ reactiveEntryPoints.set(arg.name, reason);
192
+ }
193
+ }
194
+ if (callee.type === 'Identifier') {
195
+ if (computedNames.has(callee.name)) {
196
+ checkBareIdent(node.arguments[0], 'passed to computed()');
197
+ } else if (effectNames.has(callee.name)) {
198
+ checkBareIdent(node.arguments[0], 'passed to effect()');
199
+ }
200
+ } else if (
201
+ callee.type === 'MemberExpression'
202
+ && !callee.computed
203
+ && callee.property.type === 'Identifier'
204
+ ) {
205
+ if (callee.property.name === 'transform') {
206
+ checkBareIdent(node.arguments[0], 'passed to .transform()');
207
+ } else if (callee.property.name === 'mapWithKey') {
208
+ checkBareIdent(node.arguments[1], 'passed to mapWithKey() as mapFn');
209
+ }
210
+ }
211
+
212
+ // 2. Record unkeyed reactive-primitive calls inside the current named function.
213
+ const fn = currentFn();
214
+ if (fn && !fn.anonymous) {
215
+ if (callee.type === 'Identifier' && signalNames.has(callee.name) && !hasKey) {
216
+ fn.unkeyedCalls.push({ node, primitive: 'signal' });
217
+ } else if (callee.type === 'Identifier' && computedNames.has(callee.name) && !hasKey) {
218
+ fn.unkeyedCalls.push({ node, primitive: 'computed' });
219
+ } else if (
220
+ callee.type === 'MemberExpression'
221
+ && !callee.computed
222
+ && callee.property.type === 'Identifier'
223
+ && callee.property.name === 'transform'
224
+ && !hasKey
225
+ ) {
226
+ fn.unkeyedCalls.push({ node, primitive: '.transform' });
227
+ } else if (callee.type === 'Identifier') {
228
+ fn.callees.add(callee.name);
229
+ }
230
+ }
231
+ },
232
+
233
+ 'Program:exit'() {
234
+ // BFS over the call graph starting from reactive entry points.
235
+ const reachable = new Map();
236
+ const queue = [];
237
+ for (const [name, reason] of reactiveEntryPoints) {
238
+ if (funcs.has(name)) {
239
+ reachable.set(name, reason);
240
+ queue.push(name);
241
+ }
242
+ }
243
+ while (queue.length) {
244
+ const name = queue.shift();
245
+ const rec = funcs.get(name);
246
+ if (!rec) {
247
+ continue;
248
+ }
249
+ for (const callee of rec.callees) {
250
+ if (!reachable.has(callee) && funcs.has(callee)) {
251
+ reachable.set(callee, `called transitively from ${name}`);
252
+ queue.push(callee);
253
+ }
254
+ }
255
+ }
256
+
257
+ for (const [name, rec] of funcs) {
258
+ if (!reachable.has(name)) {
259
+ continue;
260
+ }
261
+ const reason = reachable.get(name);
262
+ for (const hit of rec.unkeyedCalls) {
263
+ context.report({
264
+ node: hit.node,
265
+ messageId: 'helperFunctionTrap',
266
+ data: { primitive: hit.primitive, fnName: name, reason },
267
+ });
268
+ }
269
+ }
270
+
271
+ // The reasonFor helper isn't used by the simplified entry-point recording
272
+ // (reason text is generic now). Keeping the function for future detail
273
+ // upgrades without changing the public message shape.
274
+ void reasonFor;
275
+ },
276
+ };
277
+ },
278
+ };
@@ -0,0 +1,96 @@
1
+ // Paranoid mode. Flags EVERY `signal()`, `computed()`, or `.transform()` call
2
+ // site that does not pass a key. Keys are cheap and harmless: at runtime the
3
+ // key is IGNORED when the call is not inside a reactive scope. The only cost is
4
+ // a few extra characters per call. The benefit is that the code is robust to
5
+ // future refactors that lift a top-level helper inside a `computed()` callback
6
+ // or a `mapWithKey()` mapFn. Without the key, that refactor silently introduces
7
+ // the helper-function trap; per-row local state then resets on every outer
8
+ // re-run and the operator only discovers it during manual testing.
9
+ //
10
+ // Suppress per call site with `// eslint-disable-next-line kensington/require-reactive-key`
11
+ // when you're certain the call site will never be moved into a reactive scope.
12
+ //
13
+ // Off in the `recommended` config. On in `strict` and intended for use by
14
+ // agents and projects that want maximum safety.
15
+
16
+ export default {
17
+ meta: {
18
+ type: 'suggestion',
19
+ docs: {
20
+ description:
21
+ 'require a stable key on every signal/computed/.transform call. Keys are no-ops '
22
+ + 'at the module scope and required inside reactive callbacks; passing one always '
23
+ + 'is safer than auditing call-site reachability.',
24
+ },
25
+ messages: {
26
+ missingKey:
27
+ '{{primitive}}() called without a key. Pass a stable key as the second argument '
28
+ + '(e.g. {{primitive}}({{exampleFirstArg}}, \'{{exampleKey}}\')). Keys are ignored '
29
+ + 'outside a reactive scope so this is safe everywhere; required inside one. '
30
+ + 'Suppress per call site with `eslint-disable-next-line kensington/require-reactive-key` '
31
+ + 'when the call site is known never to move into a reactive scope.',
32
+ },
33
+ schema: [],
34
+ },
35
+
36
+ create(context) {
37
+ const signalNames = new Set();
38
+ const computedNames = new Set();
39
+
40
+ return {
41
+ ImportDeclaration(node) {
42
+ if (node.source.value !== 'kensington') {
43
+ return;
44
+ }
45
+ for (const spec of node.specifiers) {
46
+ if (spec.type !== 'ImportSpecifier') {
47
+ continue;
48
+ }
49
+ if (spec.imported.name === 'signal') { signalNames.add(spec.local.name); }
50
+ if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
51
+ }
52
+ },
53
+
54
+ CallExpression(node) {
55
+ const callee = node.callee;
56
+ const hasKey = node.arguments.length >= 2;
57
+ if (hasKey) {
58
+ return;
59
+ }
60
+
61
+ if (callee.type === 'Identifier' && signalNames.has(callee.name)) {
62
+ // signal(initial) — second arg should be the key.
63
+ context.report({
64
+ node,
65
+ messageId: 'missingKey',
66
+ data: { primitive: callee.name, exampleFirstArg: 'initial', exampleKey: 'unique-id' },
67
+ });
68
+ return;
69
+ }
70
+
71
+ if (callee.type === 'Identifier' && computedNames.has(callee.name)) {
72
+ context.report({
73
+ node,
74
+ messageId: 'missingKey',
75
+ data: { primitive: callee.name, exampleFirstArg: 'fn', exampleKey: 'unique-id' },
76
+ });
77
+ return;
78
+ }
79
+
80
+ if (
81
+ callee.type === 'MemberExpression'
82
+ && !callee.computed
83
+ && callee.property.type === 'Identifier'
84
+ && callee.property.name === 'transform'
85
+ ) {
86
+ // .transform(fn) — second arg should be the key.
87
+ context.report({
88
+ node,
89
+ messageId: 'missingKey',
90
+ data: { primitive: '.transform', exampleFirstArg: 'fn', exampleKey: 'unique-id' },
91
+ });
92
+ }
93
+ },
94
+ };
95
+ },
96
+ };