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 +95 -26
- package/bin/check-reactive.js +611 -0
- package/index.js +21 -0
- package/package.json +11 -4
- package/rules/no-helper-function-trap.js +278 -0
- package/rules/require-reactive-key.js +96 -0
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
|
-
| [`
|
|
107
|
-
| [`
|
|
108
|
-
| [`prefer-
|
|
109
|
-
| [`prefer-
|
|
110
|
-
| [`prefer-
|
|
111
|
-
| [`
|
|
112
|
-
| [`
|
|
113
|
-
| [`
|
|
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.
|
|
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
|
+
};
|