eslint-plugin-what 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "eslint-plugin-what",
3
+ "version": "0.5.3",
4
+ "description": "ESLint rules for What Framework — catch signal bugs, enforce patterns",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "keywords": [
14
+ "eslint",
15
+ "eslintplugin",
16
+ "what",
17
+ "framework",
18
+ "signals",
19
+ "reactive"
20
+ ],
21
+ "peerDependencies": {
22
+ "eslint": ">=9.0.0"
23
+ },
24
+ "author": "",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/zvndev/what-fw"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/zvndev/what-fw/issues"
32
+ },
33
+ "homepage": "https://whatframework.dev"
34
+ }
package/src/index.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * eslint-plugin-what
3
+ *
4
+ * ESLint rules for What Framework — catch signal bugs, enforce patterns.
5
+ * Designed for ESLint 9+ flat config.
6
+ *
7
+ * Usage:
8
+ * import what from 'eslint-plugin-what';
9
+ * export default [what.configs.recommended];
10
+ */
11
+
12
+ import noSignalInEffectDeps from './rules/no-signal-in-effect-deps.js';
13
+ import reactiveJsxChildren from './rules/reactive-jsx-children.js';
14
+ import noSignalWriteInRender from './rules/no-signal-write-in-render.js';
15
+ import noCamelcaseEvents from './rules/no-camelcase-events.js';
16
+ import preferSet from './rules/prefer-set.js';
17
+
18
+ const plugin = {
19
+ meta: {
20
+ name: 'eslint-plugin-what',
21
+ version: '0.5.2',
22
+ },
23
+
24
+ rules: {
25
+ 'no-signal-in-effect-deps': noSignalInEffectDeps,
26
+ 'reactive-jsx-children': reactiveJsxChildren,
27
+ 'no-signal-write-in-render': noSignalWriteInRender,
28
+ 'no-camelcase-events': noCamelcaseEvents,
29
+ 'prefer-set': preferSet,
30
+ },
31
+
32
+ configs: {},
33
+ };
34
+
35
+ // Flat config presets (ESLint 9+)
36
+
37
+ plugin.configs.recommended = {
38
+ plugins: { what: plugin },
39
+ rules: {
40
+ 'what/no-signal-in-effect-deps': 'warn',
41
+ 'what/reactive-jsx-children': 'warn',
42
+ 'what/no-signal-write-in-render': 'warn',
43
+ 'what/no-camelcase-events': 'warn',
44
+ 'what/prefer-set': 'off',
45
+ },
46
+ };
47
+
48
+ // Stricter config — all rules as errors + prefer-set
49
+ plugin.configs.strict = {
50
+ plugins: { what: plugin },
51
+ rules: {
52
+ 'what/no-signal-in-effect-deps': 'error',
53
+ 'what/reactive-jsx-children': 'error',
54
+ 'what/no-signal-write-in-render': 'error',
55
+ 'what/no-camelcase-events': 'error',
56
+ 'what/prefer-set': 'warn',
57
+ },
58
+ };
59
+
60
+ // Config for projects using the What compiler (disables rules the compiler handles)
61
+ plugin.configs.compiler = {
62
+ plugins: { what: plugin },
63
+ rules: {
64
+ 'what/no-signal-in-effect-deps': 'warn',
65
+ 'what/reactive-jsx-children': 'off', // compiler handles reactive wrapping
66
+ 'what/no-signal-write-in-render': 'warn',
67
+ 'what/no-camelcase-events': 'off', // compiler normalizes events
68
+ 'what/prefer-set': 'off',
69
+ },
70
+ };
71
+
72
+ export default plugin;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Rule: what/no-camelcase-events
3
+ *
4
+ * Warn on camelCase event handlers (onClick, onChange, etc.) in JSX when not
5
+ * using the What compiler. Without the compiler, esbuild passes props through
6
+ * directly to the DOM, and DOM event handlers must be lowercase (onclick, onchange).
7
+ *
8
+ * The compiler normalizes these automatically, so this rule only applies to
9
+ * projects using esbuild's built-in JSX transform instead of what-compiler.
10
+ *
11
+ * Bad (without compiler): <button onClick={fn} />
12
+ * Good (without compiler): <button onclick={fn} />
13
+ * Good (with compiler): <button onClick={fn} /> — compiler handles it
14
+ */
15
+
16
+ // Common DOM events that have camelCase variants
17
+ const CAMEL_EVENTS = new Map([
18
+ ['onClick', 'onclick'],
19
+ ['onChange', 'onchange'],
20
+ ['onInput', 'oninput'],
21
+ ['onSubmit', 'onsubmit'],
22
+ ['onFocus', 'onfocus'],
23
+ ['onBlur', 'onblur'],
24
+ ['onKeyDown', 'onkeydown'],
25
+ ['onKeyUp', 'onkeyup'],
26
+ ['onKeyPress', 'onkeypress'],
27
+ ['onMouseDown', 'onmousedown'],
28
+ ['onMouseUp', 'onmouseup'],
29
+ ['onMouseMove', 'onmousemove'],
30
+ ['onMouseEnter', 'onmouseenter'],
31
+ ['onMouseLeave', 'onmouseleave'],
32
+ ['onMouseOver', 'onmouseover'],
33
+ ['onMouseOut', 'onmouseout'],
34
+ ['onTouchStart', 'ontouchstart'],
35
+ ['onTouchEnd', 'ontouchend'],
36
+ ['onTouchMove', 'ontouchmove'],
37
+ ['onScroll', 'onscroll'],
38
+ ['onWheel', 'onwheel'],
39
+ ['onDragStart', 'ondragstart'],
40
+ ['onDragEnd', 'ondragend'],
41
+ ['onDragOver', 'ondragover'],
42
+ ['onDrop', 'ondrop'],
43
+ ['onContextMenu', 'oncontextmenu'],
44
+ ['onDoubleClick', 'ondblclick'],
45
+ ['onPointerDown', 'onpointerdown'],
46
+ ['onPointerUp', 'onpointerup'],
47
+ ['onPointerMove', 'onpointermove'],
48
+ ['onAnimationEnd', 'onanimationend'],
49
+ ['onTransitionEnd', 'ontransitionend'],
50
+ ['onLoad', 'onload'],
51
+ ['onError', 'onerror'],
52
+ ]);
53
+
54
+ export default {
55
+ meta: {
56
+ type: 'problem',
57
+ docs: {
58
+ description: 'Disallow camelCase event handlers in JSX without the compiler',
59
+ recommended: true,
60
+ },
61
+ fixable: 'code',
62
+ messages: {
63
+ camelCaseEvent:
64
+ '"{{name}}" won\'t work without the What compiler. Use "{{fix}}" instead.',
65
+ },
66
+ schema: [
67
+ {
68
+ type: 'object',
69
+ properties: {
70
+ hasCompiler: {
71
+ type: 'boolean',
72
+ description: 'Set to true if the project uses what-compiler (skips this rule)',
73
+ },
74
+ },
75
+ additionalProperties: false,
76
+ },
77
+ ],
78
+ },
79
+
80
+ create(context) {
81
+ const options = context.options[0] || {};
82
+ if (options.hasCompiler === true) return {};
83
+
84
+ return {
85
+ JSXAttribute(node) {
86
+ if (node.name.type !== 'JSXIdentifier') return;
87
+
88
+ const name = node.name.name;
89
+ const fix = CAMEL_EVENTS.get(name);
90
+
91
+ if (fix) {
92
+ context.report({
93
+ node: node.name,
94
+ messageId: 'camelCaseEvent',
95
+ data: { name, fix },
96
+ fix(fixer) {
97
+ return fixer.replaceText(node.name, fix);
98
+ },
99
+ });
100
+ }
101
+ },
102
+ };
103
+ },
104
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Rule: what/no-signal-in-effect-deps
3
+ *
4
+ * Warn when passing signal getter functions to useEffect dependency arrays.
5
+ * Signal getters create new function references, so deps always appear "changed",
6
+ * causing the effect to re-run on every render cycle.
7
+ *
8
+ * Bad: useEffect(() => { ... }, [count]) // count is a signal getter fn
9
+ * Good: useEffect(() => { ... }, [count()]) // use the value instead
10
+ * Good: useEffect(() => { count(); }, []) // or rely on auto-tracking
11
+ */
12
+
13
+ export default {
14
+ meta: {
15
+ type: 'problem',
16
+ docs: {
17
+ description: 'Disallow signal getters in useEffect dependency arrays',
18
+ recommended: true,
19
+ },
20
+ messages: {
21
+ signalInDeps:
22
+ 'Signal getter "{{name}}" in useEffect deps will cause infinite re-runs. ' +
23
+ 'Use {{name}}() for the value, or remove deps to rely on auto-tracking.',
24
+ },
25
+ schema: [],
26
+ },
27
+
28
+ create(context) {
29
+ // Track variables initialized from signal/useSignal/computed calls
30
+ const signalVars = new Set();
31
+
32
+ return {
33
+ VariableDeclarator(node) {
34
+ if (!node.init) return;
35
+
36
+ // Detect: const x = signal(...), useSignal(...), computed(...)
37
+ if (
38
+ node.init.type === 'CallExpression' &&
39
+ node.init.callee.type === 'Identifier' &&
40
+ ['signal', 'useSignal', 'computed', 'useComputed'].includes(node.init.callee.name) &&
41
+ node.id.type === 'Identifier'
42
+ ) {
43
+ signalVars.add(node.id.name);
44
+ }
45
+ },
46
+
47
+ CallExpression(node) {
48
+ // Match useEffect(fn, [deps])
49
+ if (
50
+ node.callee.type !== 'Identifier' ||
51
+ node.callee.name !== 'useEffect'
52
+ ) return;
53
+
54
+ const depsArg = node.arguments[1];
55
+ if (!depsArg || depsArg.type !== 'ArrayExpression') return;
56
+
57
+ for (const element of depsArg.elements) {
58
+ if (!element) continue;
59
+
60
+ // Direct signal reference: useEffect(fn, [count])
61
+ if (
62
+ element.type === 'Identifier' &&
63
+ signalVars.has(element.name)
64
+ ) {
65
+ context.report({
66
+ node: element,
67
+ messageId: 'signalInDeps',
68
+ data: { name: element.name },
69
+ });
70
+ }
71
+ }
72
+ },
73
+ };
74
+ },
75
+ };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Rule: what/no-signal-write-in-render
3
+ *
4
+ * Warn when writing to a signal outside of event handlers, effects, or callbacks.
5
+ * Signal writes during render (the component function body) can cause infinite
6
+ * re-render loops because the write triggers effects that re-run the component.
7
+ *
8
+ * Bad: function App() { count(count() + 1); return ... }
9
+ * Good: function App() { return <button onclick={() => count(c => c + 1)} /> }
10
+ * Good: useEffect(() => { count(0); })
11
+ */
12
+
13
+ export default {
14
+ meta: {
15
+ type: 'problem',
16
+ docs: {
17
+ description: 'Disallow signal writes in the render phase (component function body)',
18
+ recommended: true,
19
+ },
20
+ messages: {
21
+ signalWriteInRender:
22
+ 'Signal write to "{{name}}" during render may cause infinite loops. ' +
23
+ 'Move signal writes into event handlers, effects, or callbacks.',
24
+ },
25
+ schema: [],
26
+ },
27
+
28
+ create(context) {
29
+ const signalVars = new Set();
30
+
31
+ // Track whether we're inside a "safe" write context
32
+ function isInsideSafeContext(node) {
33
+ let current = node.parent;
34
+ while (current) {
35
+ // Inside event handler (arrow fn or function expression assigned to on* prop or as callback arg)
36
+ if (
37
+ current.type === 'ArrowFunctionExpression' ||
38
+ current.type === 'FunctionExpression'
39
+ ) {
40
+ // Check if it's an event handler prop: onclick={() => ...}
41
+ if (
42
+ current.parent?.type === 'JSXExpressionContainer' &&
43
+ current.parent?.parent?.type === 'JSXAttribute'
44
+ ) {
45
+ return true;
46
+ }
47
+ // Check if it's an event handler prop (object property): { onclick: () => ... }
48
+ if (
49
+ current.parent?.type === 'Property' &&
50
+ current.parent?.key?.type === 'Identifier' &&
51
+ /^on[a-z]/.test(current.parent.key.name)
52
+ ) {
53
+ return true;
54
+ }
55
+ // Callback passed to useEffect, effect, setTimeout, etc.
56
+ if (current.parent?.type === 'CallExpression') {
57
+ const callee = current.parent.callee;
58
+ if (
59
+ callee.type === 'Identifier' &&
60
+ ['useEffect', 'effect', 'setTimeout', 'setInterval', 'requestAnimationFrame',
61
+ 'queueMicrotask', 'batch', 'onMount', 'onCleanup', 'addEventListener'].includes(callee.name)
62
+ ) {
63
+ return true;
64
+ }
65
+ }
66
+ // Generic arrow/function inside another arrow/function — assume nested callback is safe
67
+ if (
68
+ current.parent?.type === 'ArrowFunctionExpression' ||
69
+ current.parent?.type === 'FunctionExpression'
70
+ ) {
71
+ return true;
72
+ }
73
+ }
74
+ current = current.parent;
75
+ }
76
+ return false;
77
+ }
78
+
79
+ return {
80
+ VariableDeclarator(node) {
81
+ if (!node.init) return;
82
+ if (
83
+ node.init.type === 'CallExpression' &&
84
+ node.init.callee.type === 'Identifier' &&
85
+ ['signal', 'useSignal', 'computed', 'useComputed'].includes(node.init.callee.name) &&
86
+ node.id.type === 'Identifier'
87
+ ) {
88
+ signalVars.add(node.id.name);
89
+ }
90
+ },
91
+
92
+ CallExpression(node) {
93
+ // Check for signal writes: count(value), count.set(value)
94
+ let signalName = null;
95
+
96
+ // Direct call: count(value) — with at least one argument (0-arg is a read)
97
+ if (
98
+ node.callee.type === 'Identifier' &&
99
+ signalVars.has(node.callee.name) &&
100
+ node.arguments.length > 0
101
+ ) {
102
+ signalName = node.callee.name;
103
+ }
104
+
105
+ // Method call: count.set(value)
106
+ if (
107
+ node.callee.type === 'MemberExpression' &&
108
+ node.callee.object.type === 'Identifier' &&
109
+ signalVars.has(node.callee.object.name) &&
110
+ node.callee.property.type === 'Identifier' &&
111
+ node.callee.property.name === 'set'
112
+ ) {
113
+ signalName = node.callee.object.name;
114
+ }
115
+
116
+ if (signalName && !isInsideSafeContext(node)) {
117
+ context.report({
118
+ node,
119
+ messageId: 'signalWriteInRender',
120
+ data: { name: signalName },
121
+ });
122
+ }
123
+ },
124
+ };
125
+ },
126
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Rule: what/prefer-set
3
+ *
4
+ * Suggest using sig.set(value) instead of sig(value) for signal writes.
5
+ * The unified getter/setter pattern sig(value) is valid but ambiguous —
6
+ * sig.set(value) makes the write intent explicit and easier to grep/review.
7
+ *
8
+ * Bad: count(5) // is this a read or write?
9
+ * Bad: count(c => c + 1) // updater pattern
10
+ * Good: count.set(5)
11
+ * Good: count.set(c => c + 1)
12
+ *
13
+ * This rule is off by default (style preference, not a bug).
14
+ */
15
+
16
+ export default {
17
+ meta: {
18
+ type: 'suggestion',
19
+ docs: {
20
+ description: 'Prefer sig.set(value) over sig(value) for signal writes',
21
+ recommended: false,
22
+ },
23
+ fixable: 'code',
24
+ messages: {
25
+ preferSet:
26
+ 'Prefer "{{name}}.set({{arg}})" over "{{name}}({{arg}})" for explicit signal writes.',
27
+ },
28
+ schema: [],
29
+ },
30
+
31
+ create(context) {
32
+ const signalVars = new Set();
33
+
34
+ return {
35
+ VariableDeclarator(node) {
36
+ if (!node.init) return;
37
+ if (
38
+ node.init.type === 'CallExpression' &&
39
+ node.init.callee.type === 'Identifier' &&
40
+ ['signal', 'useSignal', 'computed', 'useComputed'].includes(node.init.callee.name) &&
41
+ node.id.type === 'Identifier'
42
+ ) {
43
+ signalVars.add(node.id.name);
44
+ }
45
+ },
46
+
47
+ CallExpression(node) {
48
+ // Only match: signalVar(value) with exactly 1 argument
49
+ if (
50
+ node.callee.type !== 'Identifier' ||
51
+ !signalVars.has(node.callee.name) ||
52
+ node.arguments.length !== 1
53
+ ) return;
54
+
55
+ // Already using .set() — skip
56
+ if (node.callee.type === 'MemberExpression') return;
57
+
58
+ const name = node.callee.name;
59
+ const sourceCode = context.sourceCode || context.getSourceCode();
60
+ const argText = sourceCode.getText(node.arguments[0]);
61
+
62
+ context.report({
63
+ node,
64
+ messageId: 'preferSet',
65
+ data: { name, arg: argText },
66
+ fix(fixer) {
67
+ return fixer.replaceText(node, `${name}.set(${argText})`);
68
+ },
69
+ });
70
+ },
71
+ };
72
+ },
73
+ };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Rule: what/reactive-jsx-children
3
+ *
4
+ * Warn when using bare signal calls as JSX children without the compiler.
5
+ * Without the What compiler, esbuild/TS handles JSX → h() calls, and a bare
6
+ * signal read like {count()} won't be reactive — it captures the value once.
7
+ *
8
+ * The rule checks if the project uses what-compiler (via config option or
9
+ * vite.config presence). If no compiler is detected, it warns on bare signal
10
+ * reads in JSX expression containers.
11
+ *
12
+ * Bad (without compiler): <p>{count()}</p>
13
+ * Good (without compiler): <p>{() => count()}</p>
14
+ */
15
+
16
+ export default {
17
+ meta: {
18
+ type: 'problem',
19
+ docs: {
20
+ description: 'Require reactive function wrappers for signal reads in JSX when not using the compiler',
21
+ recommended: true,
22
+ },
23
+ messages: {
24
+ bareSignalRead:
25
+ 'Signal read "{{name}}()" in JSX won\'t be reactive without the What compiler. ' +
26
+ 'Wrap in a function: {() => {{name}}()}',
27
+ },
28
+ schema: [
29
+ {
30
+ type: 'object',
31
+ properties: {
32
+ hasCompiler: {
33
+ type: 'boolean',
34
+ description: 'Set to true if the project uses what-compiler (auto-detected if not set)',
35
+ },
36
+ },
37
+ additionalProperties: false,
38
+ },
39
+ ],
40
+ },
41
+
42
+ create(context) {
43
+ const options = context.options[0] || {};
44
+
45
+ // If the user explicitly says they have the compiler, skip all checks
46
+ if (options.hasCompiler === true) return {};
47
+
48
+ // Track signal variables
49
+ const signalVars = new Set();
50
+
51
+ return {
52
+ VariableDeclarator(node) {
53
+ if (!node.init) return;
54
+ if (
55
+ node.init.type === 'CallExpression' &&
56
+ node.init.callee.type === 'Identifier' &&
57
+ ['signal', 'useSignal', 'computed', 'useComputed'].includes(node.init.callee.name) &&
58
+ node.id.type === 'Identifier'
59
+ ) {
60
+ signalVars.add(node.id.name);
61
+ }
62
+ },
63
+
64
+ // JSX expression: {count()}
65
+ JSXExpressionContainer(node) {
66
+ const expr = node.expression;
67
+ if (!expr || expr.type === 'JSXEmptyExpression') return;
68
+
69
+ // Skip if parent is an attribute (e.g., className={count()}) — only check children
70
+ if (node.parent.type !== 'JSXElement' && node.parent.type !== 'JSXFragment') return;
71
+
72
+ // Already wrapped in arrow: {() => count()} — OK
73
+ if (expr.type === 'ArrowFunctionExpression' || expr.type === 'FunctionExpression') return;
74
+
75
+ // Direct signal call: {count()}
76
+ if (
77
+ expr.type === 'CallExpression' &&
78
+ expr.callee.type === 'Identifier' &&
79
+ signalVars.has(expr.callee.name) &&
80
+ expr.arguments.length === 0
81
+ ) {
82
+ context.report({
83
+ node: expr,
84
+ messageId: 'bareSignalRead',
85
+ data: { name: expr.callee.name },
86
+ });
87
+ }
88
+ },
89
+ };
90
+ },
91
+ };