@pyreon/lint 0.14.0 → 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.
- package/README.md +9 -7
- package/lib/analysis/cli.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +540 -60
- package/lib/index.js +540 -60
- package/package.json +3 -2
- package/src/manifest.ts +6 -6
- package/src/rules/architecture/dev-guard-warnings.ts +56 -6
- package/src/rules/architecture/no-process-dev-gate.ts +141 -62
- package/src/rules/index.ts +11 -2
- package/src/rules/jsx/no-props-destructure.ts +57 -7
- package/src/rules/lifecycle/no-imperative-effect-on-create.ts +278 -0
- package/src/rules/reactivity/no-async-effect.ts +84 -0
- package/src/rules/reactivity/no-signal-call-write.ts +60 -0
- package/src/tests/runner.test.ts +543 -8
- package/lib/cli.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
|
@@ -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
|
+
}
|