@pyreon/lint 0.13.1 → 0.15.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.
@@ -1,5 +1,6 @@
1
1
  import type { Rule, VisitorCallbacks } from '../../types'
2
2
  import { getSpan, isDestructuring } from '../../utils/ast'
3
+ import { isPathExempt } from '../../utils/exempt-paths'
3
4
 
4
5
  function containsJSXReturn(node: any): boolean {
5
6
  if (!node) return false
@@ -31,6 +32,40 @@ function getDestructuredNames(pattern: any): string[] {
31
32
  return names
32
33
  }
33
34
 
35
+ /**
36
+ * Names of HOC / factory call expressions whose first-argument render
37
+ * function takes Pyreon component props. Destructuring inside these IS
38
+ * a real reactivity bug — same as destructuring at the component
39
+ * signature directly. Do NOT add this exemption for these.
40
+ *
41
+ * The `callArgFns` exemption is intentionally narrow: it only fires for
42
+ * generic call arguments where the parent call is NOT one of these
43
+ * known component-shaped factories.
44
+ */
45
+ const COMPONENT_FACTORY_NAMES = new Set([
46
+ 'createComponent',
47
+ 'defineComponent',
48
+ 'lazy',
49
+ 'memo',
50
+ 'observer',
51
+ 'forwardRef',
52
+ 'rocketstyle',
53
+ 'styled',
54
+ 'attrs',
55
+ 'kinetic',
56
+ ])
57
+
58
+ function isComponentFactoryCall(call: any): boolean {
59
+ if (!call || call.type !== 'CallExpression') return false
60
+ const callee = call.callee
61
+ if (!callee) return false
62
+ if (callee.type === 'Identifier' && COMPONENT_FACTORY_NAMES.has(callee.name)) return true
63
+ // `styled.div\`...\`` template tag falls back to a CallExpression on
64
+ // styled members in some compilers — be conservative and don't try to
65
+ // detect template literal forms here.
66
+ return false
67
+ }
68
+
34
69
  export const noPropsDestructure: Rule = {
35
70
  meta: {
36
71
  id: 'pyreon/no-props-destructure',
@@ -39,13 +74,18 @@ export const noPropsDestructure: Rule = {
39
74
  'Disallow destructuring props in component functions — breaks reactive prop tracking. Use props.x or splitProps().',
40
75
  severity: 'error',
41
76
  fixable: false,
77
+ schema: { exemptPaths: 'string[]' },
42
78
  },
43
79
  create(context) {
80
+ if (isPathExempt(context)) return {}
81
+
44
82
  let functionDepth = 0
45
83
  // oxc visitor doesn't pass `parent` to callbacks — previous
46
84
  // `parent?.type === 'CallExpression'` check was silently inert. Pre-mark
47
85
  // function nodes that appear as CallExpression arguments on the way in.
48
- const callArgFns = new WeakSet<any>()
86
+ // Track BOTH the function and its parent call so we can later refuse
87
+ // the exemption when the parent is a known component factory.
88
+ const callArgFns = new WeakMap<any, any>()
49
89
 
50
90
  const callbacks: VisitorCallbacks = {
51
91
  CallExpression(node: any) {
@@ -55,7 +95,7 @@ export const noPropsDestructure: Rule = {
55
95
  arg?.type === 'FunctionExpression' ||
56
96
  arg?.type === 'FunctionDeclaration'
57
97
  ) {
58
- callArgFns.add(arg)
98
+ callArgFns.set(arg, node)
59
99
  }
60
100
  }
61
101
  },
@@ -85,19 +125,29 @@ export const noPropsDestructure: Rule = {
85
125
  },
86
126
  }
87
127
 
88
- function checkFunction(node: any, context: any, depth: number, callArgFns: WeakSet<any>) {
128
+ function checkFunction(node: any, context: any, depth: number, callArgFns: WeakMap<any, any>) {
89
129
  const params = node.params
90
130
  if (!params || params.length === 0) return
91
131
 
92
132
  const firstParam = params[0]
93
133
  if (!isDestructuring(firstParam)) return
94
134
 
95
- // Skip HOC inner functions (depth > 1)
135
+ // Skip nested functions (depth > 1). This protects render-prop
136
+ // callbacks whose first param is NOT a Pyreon component prop bag —
137
+ // e.g. `<For>{(item) => <li>{item}</li>}</For>` passes raw array
138
+ // items, so destructuring is a non-issue there. The tradeoff is that
139
+ // genuinely-nested component declarations slip past this rule;
140
+ // they're rare enough in practice that the false-negative is
141
+ // acceptable.
96
142
  if (depth > 1) return
97
143
 
98
- // Skip functions passed as arguments to HOC factories
99
- // e.g. createLink(({ href, ...rest }) => <a {...rest} />)
100
- if (callArgFns.has(node)) return
144
+ // Skip functions passed as call arguments (HOC / render-prop
145
+ // pattern), UNLESS the parent call is a known component factory.
146
+ // Component factories receive Pyreon props via the inner function,
147
+ // so destructuring there breaks reactivity exactly like it does at
148
+ // the component signature.
149
+ const parentCall = callArgFns.get(node)
150
+ if (parentCall && !isComponentFactoryCall(parentCall)) return
101
151
 
102
152
  const body = node.body
103
153
  if (!body) return
@@ -0,0 +1,278 @@
1
+ import type { Rule, VisitorCallbacks } from '../../types'
2
+ import { getSpan, isCallTo } from '../../utils/ast'
3
+ import { isPathExempt } from '../../utils/exempt-paths'
4
+
5
+ /**
6
+ * Imperative APIs whose presence inside an `effect(() => { ... })`
7
+ * callback signals that the effect is doing setup work that belongs
8
+ * in `onMount` — not reactive signal tracking. Calls to these inside
9
+ * an effect at component body level cause the work to run
10
+ * synchronously during component setup, which is the bug shape #268
11
+ * surfaced (per-instance effect allocation under load).
12
+ *
13
+ * The list is intentionally narrow: each entry is a pattern that
14
+ * cannot be a pure reactive read. `fetch(...)` triggers IO,
15
+ * `setTimeout(fn)` schedules a deferred callback, `addEventListener`
16
+ * mutates a global. None of these track signals; using `effect()` to
17
+ * run them per-instance is the bug.
18
+ *
19
+ * Do NOT add: signal reads (`.value`, `()`), `console.log`, `Math.X`,
20
+ * `JSON.X` — those are the legitimate reactive-tracking uses of
21
+ * `effect()`.
22
+ */
23
+ const IMPERATIVE_GLOBAL_CALLS = new Set([
24
+ 'fetch',
25
+ 'setTimeout',
26
+ 'setInterval',
27
+ 'requestAnimationFrame',
28
+ 'requestIdleCallback',
29
+ 'queueMicrotask',
30
+ ])
31
+
32
+ const IMPERATIVE_MEMBER_METHODS = new Set([
33
+ 'addEventListener',
34
+ 'removeEventListener',
35
+ 'querySelector',
36
+ 'querySelectorAll',
37
+ 'getElementById',
38
+ 'getElementsByClassName',
39
+ 'getElementsByTagName',
40
+ 'getBoundingClientRect',
41
+ 'getComputedStyle',
42
+ 'focus',
43
+ 'blur',
44
+ 'scrollIntoView',
45
+ 'scrollTo',
46
+ 'scrollBy',
47
+ 'requestFullscreen',
48
+ 'play',
49
+ 'pause',
50
+ ])
51
+
52
+ const IMPERATIVE_BROWSER_OBJECTS = new Set([
53
+ 'document',
54
+ 'window',
55
+ 'navigator',
56
+ 'localStorage',
57
+ 'sessionStorage',
58
+ ])
59
+
60
+ /**
61
+ * Constructor names whose presence inside an `effect()` body signals
62
+ * imperative API setup (observers, workers, network sockets) that
63
+ * should run from `onMount` — not synchronously per-instance at
64
+ * component setup time. Observer registration and socket allocation
65
+ * are unambiguously imperative and never tracked as reactive reads.
66
+ */
67
+ const IMPERATIVE_CONSTRUCTORS = new Set([
68
+ 'IntersectionObserver',
69
+ 'ResizeObserver',
70
+ 'MutationObserver',
71
+ 'PerformanceObserver',
72
+ 'Worker',
73
+ 'SharedWorker',
74
+ 'WebSocket',
75
+ 'EventSource',
76
+ 'BroadcastChannel',
77
+ ])
78
+
79
+ /**
80
+ * Returns true when `node` is an immediately-invoked function
81
+ * expression — i.e. a `CallExpression` whose callee is a function
82
+ * literal: `(() => { ... })()` or `(function () { ... })()`. The body
83
+ * runs synchronously at the call site, so for our purposes it should
84
+ * be walked even though it's structurally a "nested function".
85
+ *
86
+ * Parenthesized callees (`(arrow)()`) come through as
87
+ * `ParenthesizedExpression` wrapping the function — unwrap one level.
88
+ */
89
+ function isIIFE(node: any): boolean {
90
+ if (!node || node.type !== 'CallExpression') return false
91
+ let callee = node.callee
92
+ if (callee?.type === 'ParenthesizedExpression') callee = callee.expression
93
+ return (
94
+ callee?.type === 'ArrowFunctionExpression' || callee?.type === 'FunctionExpression'
95
+ )
96
+ }
97
+
98
+ /**
99
+ * Walk the effect callback body and look for imperative patterns.
100
+ * Returns the first matching node + a short label describing what was
101
+ * found, or null when the body is pure reactive tracking.
102
+ *
103
+ * Stops at nested function boundaries — code inside a nested function
104
+ * (e.g. an event handler the effect attaches) is deferred-execution
105
+ * and doesn't run synchronously at effect setup. The exception is
106
+ * IIFE callees: those run at the call site, so we descend into them.
107
+ */
108
+ function findImperativePattern(node: any, insideIIFE = false): { node: any; label: string } | null {
109
+ if (!node || typeof node !== 'object') return null
110
+
111
+ // Stop descent into nested functions — their bodies run later — UNLESS
112
+ // we descended via an IIFE call (the inline-invoked function body
113
+ // does run synchronously at the call site).
114
+ if (
115
+ !insideIIFE &&
116
+ (node.type === 'FunctionExpression' ||
117
+ node.type === 'FunctionDeclaration' ||
118
+ node.type === 'ArrowFunctionExpression')
119
+ ) {
120
+ return null
121
+ }
122
+
123
+ // `await` keyword — signals async work in the effect body.
124
+ if (node.type === 'AwaitExpression') {
125
+ return { node, label: '`await` (async work)' }
126
+ }
127
+
128
+ // `new IntersectionObserver(...)` / `new Worker(...)` / etc.
129
+ if (node.type === 'NewExpression') {
130
+ const callee = node.callee
131
+ if (callee?.type === 'Identifier' && IMPERATIVE_CONSTRUCTORS.has(callee.name)) {
132
+ return { node, label: `\`new ${callee.name}(...)\`` }
133
+ }
134
+ }
135
+
136
+ // `fetch(...)` / `setTimeout(...)` / etc.
137
+ if (node.type === 'CallExpression') {
138
+ const callee = node.callee
139
+ if (callee?.type === 'Identifier' && IMPERATIVE_GLOBAL_CALLS.has(callee.name)) {
140
+ return { node, label: `\`${callee.name}(...)\`` }
141
+ }
142
+ // Member calls like `el.addEventListener(...)`, `document.querySelector(...)`,
143
+ // `localStorage.setItem(...)`, `.then(...)` (Promise chain).
144
+ if (callee?.type === 'MemberExpression' && callee.property?.type === 'Identifier') {
145
+ const method = callee.property.name
146
+ if (IMPERATIVE_MEMBER_METHODS.has(method)) {
147
+ return { node, label: `\`.${method}(...)\`` }
148
+ }
149
+ // `.then(...)` / `.catch(...)` — Promise consumption.
150
+ if (method === 'then' || method === 'catch' || method === 'finally') {
151
+ return { node, label: `\`.${method}(...)\` (Promise chain)` }
152
+ }
153
+ // localStorage.setItem / sessionStorage.getItem / etc.
154
+ const obj = callee.object
155
+ if (obj?.type === 'Identifier' && IMPERATIVE_BROWSER_OBJECTS.has(obj.name)) {
156
+ return { node, label: `\`${obj.name}.${method}(...)\`` }
157
+ }
158
+ }
159
+
160
+ // IIFE — descend into the function body even though it's a nested
161
+ // function, because it runs synchronously here.
162
+ if (isIIFE(node)) {
163
+ let calleeFn = callee
164
+ if (calleeFn?.type === 'ParenthesizedExpression') calleeFn = calleeFn.expression
165
+ const body = calleeFn?.body
166
+ if (body) {
167
+ const found = findImperativePattern(body, true)
168
+ if (found) return found
169
+ }
170
+ }
171
+ }
172
+
173
+ // `document.X` / `window.X` member READS that aren't part of a call —
174
+ // e.g. `const el = document.body`, `window.location.href = '/x'`.
175
+ if (
176
+ node.type === 'MemberExpression' &&
177
+ node.object?.type === 'Identifier' &&
178
+ IMPERATIVE_BROWSER_OBJECTS.has(node.object.name) &&
179
+ // Skip when the member is `localStorage`/`sessionStorage` ON window —
180
+ // those go through the call form below.
181
+ node.property?.type === 'Identifier'
182
+ ) {
183
+ return { node, label: `\`${node.object.name}.${node.property.name}\`` }
184
+ }
185
+
186
+ // Recurse. After we've descended INTO an IIFE body, child nodes
187
+ // shouldn't keep treating themselves as "inside an IIFE" forever —
188
+ // we want the next nested function (a real handler) to bail. So
189
+ // pass `false` to recursive calls: only the immediate IIFE-body
190
+ // first-level walk gets `true`, then it resets.
191
+ for (const key in node) {
192
+ if (key === 'parent' || key === 'loc' || key === 'range' || key === 'type') continue
193
+ const value = node[key]
194
+ if (Array.isArray(value)) {
195
+ for (const child of value) {
196
+ const found = findImperativePattern(child, false)
197
+ if (found) return found
198
+ }
199
+ } else if (value && typeof value === 'object') {
200
+ const found = findImperativePattern(value, false)
201
+ if (found) return found
202
+ }
203
+ }
204
+ return null
205
+ }
206
+
207
+ /**
208
+ * Safe wrapper names — `effect()` calls inside these don't fire
209
+ * synchronously at component setup, so imperative work in their
210
+ * callbacks is fine.
211
+ *
212
+ * `onMount` / `onUnmount` / `onCleanup` — explicit lifecycle hooks.
213
+ * `renderEffect` — runs after mount, similar lifecycle.
214
+ *
215
+ * `effect` is intentionally NOT in this set — the rule's whole purpose
216
+ * is to walk an effect's body. A nested effect inside another effect
217
+ * is a separate problem (`no-nested-effect`), not this rule's concern.
218
+ */
219
+ const SAFE_WRAPPER_NAMES = new Set(['onMount', 'onUnmount', 'onCleanup', 'renderEffect'])
220
+
221
+ export const noImperativeEffectOnCreate: Rule = {
222
+ meta: {
223
+ id: 'pyreon/no-imperative-effect-on-create',
224
+ category: 'lifecycle',
225
+ description:
226
+ 'Flag `effect()` calls at component body level whose callback does imperative work (DOM access, async/IO, addEventListener, setTimeout) — that work belongs in `onMount`, not in a per-instance reactive effect.',
227
+ severity: 'warn',
228
+ fixable: false,
229
+ schema: { exemptPaths: 'string[]' },
230
+ },
231
+ create(context) {
232
+ if (isPathExempt(context)) return {}
233
+
234
+ let safeWrapperDepth = 0
235
+
236
+ const callbacks: VisitorCallbacks = {
237
+ CallExpression(node: any) {
238
+ const callee = node.callee
239
+ if (callee?.type === 'Identifier') {
240
+ if (SAFE_WRAPPER_NAMES.has(callee.name)) {
241
+ safeWrapperDepth++
242
+ }
243
+ }
244
+
245
+ if (safeWrapperDepth > 0) return // already inside a safe wrapper
246
+ if (!isCallTo(node, 'effect')) return
247
+
248
+ const args = node.arguments
249
+ if (!args || args.length === 0) return
250
+ const fn = args[0]
251
+ if (!fn) return
252
+
253
+ let body: any = null
254
+ if (fn.type === 'ArrowFunctionExpression' || fn.type === 'FunctionExpression') {
255
+ body = fn.body
256
+ }
257
+ if (!body) return
258
+
259
+ // Walk the body for imperative patterns.
260
+ const found = findImperativePattern(body)
261
+ if (!found) return
262
+
263
+ context.report({
264
+ message:
265
+ `\`effect()\` at component body level contains ${found.label} — imperative work belongs in \`onMount\`. Pyreon's \`effect()\` runs synchronously per instance during component setup; per-instance imperative work (DOM access, IO, scheduling) accumulates O(N) at mount under load (cf. PR #268). Wrap the imperative call in \`onMount(() => { ... })\` and keep \`effect()\` for pure signal-tracking subscriptions.`,
266
+ span: getSpan(node),
267
+ })
268
+ },
269
+ 'CallExpression:exit'(node: any) {
270
+ const callee = node.callee
271
+ if (callee?.type === 'Identifier' && SAFE_WRAPPER_NAMES.has(callee.name)) {
272
+ safeWrapperDepth--
273
+ }
274
+ },
275
+ }
276
+ return callbacks
277
+ },
278
+ }
@@ -0,0 +1,84 @@
1
+ import type { Rule, VisitorCallbacks } from '../../types'
2
+ import { getSpan, isCallTo } from '../../utils/ast'
3
+
4
+ /**
5
+ * Disallow async functions passed to `effect()` / `renderEffect()` /
6
+ * `computed()` (audit bug #1).
7
+ *
8
+ * The reactivity tracking context is the SYNCHRONOUS frame around the
9
+ * callback's top half. Anything after the first `await` runs detached,
10
+ * so signal reads on the back side aren't tracked and the
11
+ * effect/computed won't re-run when those signals change. Common
12
+ * foot-gun:
13
+ *
14
+ * effect(async () => {
15
+ * const id = userId() // tracked ✓
16
+ * const data = await fetch(...) // boundary
17
+ * const name = profile() // NOT tracked ✗ — runs once, never again
18
+ * setName(name)
19
+ * })
20
+ *
21
+ * `computed(async () => …)` is even worse: the computed's value type
22
+ * becomes `Computed<Promise<T>>`, which silently breaks every consumer
23
+ * that expects `Computed<T>`. There's no scenario where async makes
24
+ * sense for a computed.
25
+ *
26
+ * The runtime emits a matching dev-mode console.warn for each call
27
+ * shape (see `packages/core/reactivity/src/effect.ts` and
28
+ * `computed.ts`); this lint rule surfaces the warning earlier in the
29
+ * editor / CI loop, before the code even runs.
30
+ *
31
+ * Mitigation patterns:
32
+ * - Read all tracked signals BEFORE any await, then `await` last.
33
+ * - Use `watch(source, async (val) => …)` — the source is tracked
34
+ * synchronously; the async callback runs on changes without
35
+ * needing tracking continuity.
36
+ * - Split into two effects: one synchronous (track + dispatch), one
37
+ * async via the dispatch.
38
+ * - For async derived state, use `createResource` or a
39
+ * `signal<Promise<T>>` + `effect` pattern, NOT `computed`.
40
+ */
41
+
42
+ const REACTIVE_PRIMITIVES = ['effect', 'renderEffect', 'computed'] as const
43
+
44
+ export const noAsyncEffect: Rule = {
45
+ meta: {
46
+ id: 'pyreon/no-async-effect',
47
+ category: 'reactivity',
48
+ description:
49
+ 'Disallow async functions in `effect()` / `renderEffect()` / `computed()` — signal reads after the first await are not tracked.',
50
+ severity: 'error',
51
+ fixable: false,
52
+ },
53
+ create(context) {
54
+ const callbacks: VisitorCallbacks = {
55
+ CallExpression(node: any) {
56
+ // Only flag direct calls. Renamed imports (`import { effect as
57
+ // fx }`) skip; the rule errs toward false negatives over false
58
+ // positives.
59
+ const calleeName = REACTIVE_PRIMITIVES.find((n) => isCallTo(node, n))
60
+ if (!calleeName) return
61
+ const arg = node.arguments?.[0]
62
+ if (!arg) return
63
+ // ArrowFunctionExpression and FunctionExpression both carry
64
+ // `async: true` when authored as `async () => …` or
65
+ // `async function () { … }`. Other arg shapes (named function
66
+ // refs, identifiers, calls) are ambiguous statically — skip.
67
+ if (
68
+ (arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression') &&
69
+ arg.async === true
70
+ ) {
71
+ const remediation =
72
+ calleeName === 'computed'
73
+ ? 'Use `createResource` for async-derived state, or compute synchronously over a signal that holds the awaited value.'
74
+ : 'Read all tracked signals before any await, or use `watch(source, asyncCb)` for async-in-callback patterns.'
75
+ context.report({
76
+ message: `${calleeName}() callback is async — signal reads after the first \`await\` are NOT tracked. ${remediation}`,
77
+ span: getSpan(arg),
78
+ })
79
+ }
80
+ },
81
+ }
82
+ return callbacks
83
+ },
84
+ }
@@ -0,0 +1,60 @@
1
+ import type { Rule, VisitorCallbacks } from '../../types'
2
+ import { getSpan, isCallTo } from '../../utils/ast'
3
+
4
+ /**
5
+ * Mirrors the D1 MCP detector (`signal-write-as-call`) at lint time so
6
+ * editors flag `sig(value)` write attempts as the user types them.
7
+ *
8
+ * Bindings are collected in a single top-down pass: oxc visits
9
+ * VariableDeclaration top-down before nested function bodies, and `const`
10
+ * is in the TDZ before declaration — so a use site never precedes its
11
+ * binding's visitor. Scope-blind on purpose: shadowing a signal name
12
+ * with a non-signal in a nested scope is itself unusual, and the
13
+ * diagnostic points at the exact call so a human can dismiss the rare
14
+ * false positive.
15
+ *
16
+ * Only `const` declarations qualify — `let`/`var` may be reassigned to a
17
+ * non-signal value, so a use-site call wouldn't be a reliable
18
+ * signal-write.
19
+ */
20
+ export const noSignalCallWrite: Rule = {
21
+ meta: {
22
+ id: 'pyreon/no-signal-call-write',
23
+ category: 'reactivity',
24
+ description:
25
+ 'Disallow `sig(value)` write attempts on signal/computed bindings — `signal()` is the read-only callable. Use `sig.set(value)` or `sig.update(fn)`.',
26
+ severity: 'error',
27
+ fixable: false,
28
+ },
29
+ create(context) {
30
+ const bindings = new Set<string>()
31
+
32
+ const callbacks: VisitorCallbacks = {
33
+ VariableDeclaration(node: any) {
34
+ if (node.kind !== 'const') return
35
+ for (const decl of node.declarations ?? []) {
36
+ if (decl?.type !== 'VariableDeclarator') continue
37
+ if (decl.id?.type !== 'Identifier') continue
38
+ const init = decl.init
39
+ if (!init) continue
40
+ if (!isCallTo(init, 'signal') && !isCallTo(init, 'computed')) continue
41
+ bindings.add(decl.id.name)
42
+ }
43
+ },
44
+ CallExpression(node: any) {
45
+ const callee = node.callee
46
+ if (!callee || callee.type !== 'Identifier') return
47
+ if (!bindings.has(callee.name)) return
48
+ // Zero-arg call is a READ — the documented Pyreon API.
49
+ if (!node.arguments || node.arguments.length === 0) return
50
+
51
+ context.report({
52
+ message:
53
+ `\`${callee.name}(value)\` does NOT write the signal — \`signal()\` is the read-only callable surface and ignores its arguments. Use \`${callee.name}.set(value)\` or \`${callee.name}.update((prev) => …)\`.`,
54
+ span: getSpan(node),
55
+ })
56
+ },
57
+ }
58
+ return callbacks
59
+ },
60
+ }