@pyreon/compiler 0.4.0 → 0.5.1
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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +829 -1
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +638 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index2.d.ts +92 -1
- package/lib/types/index2.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +20 -0
- package/src/project-scanner.ts +215 -0
- package/src/react-intercept.ts +1152 -0
- package/src/tests/react-intercept.test.ts +702 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import {
|
|
2
|
+
detectReactPatterns,
|
|
3
|
+
diagnoseError,
|
|
4
|
+
hasReactPatterns,
|
|
5
|
+
migrateReactCode,
|
|
6
|
+
} from "../react-intercept"
|
|
7
|
+
|
|
8
|
+
// ─── hasReactPatterns ────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
describe("hasReactPatterns", () => {
|
|
11
|
+
test("returns true for React import", () => {
|
|
12
|
+
expect(hasReactPatterns(`import { useState } from "react"`)).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test("returns true for react-dom import", () => {
|
|
16
|
+
expect(hasReactPatterns(`import { createRoot } from "react-dom/client"`)).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("returns true for react-router import", () => {
|
|
20
|
+
expect(hasReactPatterns(`import { Link } from "react-router-dom"`)).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("returns true for useState call", () => {
|
|
24
|
+
expect(hasReactPatterns("const [a, b] = useState(0)")).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("returns true for useState with type parameter", () => {
|
|
28
|
+
expect(hasReactPatterns("const [a, b] = useState<number>(0)")).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("returns true for useEffect call", () => {
|
|
32
|
+
expect(hasReactPatterns("useEffect(() => {}, [])")).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test("returns true for useMemo call", () => {
|
|
36
|
+
expect(hasReactPatterns("useMemo(() => x * 2, [x])")).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("returns true for useCallback call", () => {
|
|
40
|
+
expect(hasReactPatterns("useCallback(() => doThing(), [])")).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("returns true for useRef call", () => {
|
|
44
|
+
expect(hasReactPatterns("useRef(null)")).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("returns true for useRef with type parameter", () => {
|
|
48
|
+
expect(hasReactPatterns("useRef<HTMLDivElement>(null)")).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("returns true for useReducer call", () => {
|
|
52
|
+
expect(hasReactPatterns("useReducer(reducer, init)")).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("returns true for useReducer with type parameter", () => {
|
|
56
|
+
expect(hasReactPatterns("useReducer<State>(reducer, init)")).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("returns true for React.memo", () => {
|
|
60
|
+
expect(hasReactPatterns("React.memo(MyComponent)")).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("returns true for forwardRef call", () => {
|
|
64
|
+
expect(hasReactPatterns("forwardRef((props, ref) => {})")).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("returns true for forwardRef with type parameter", () => {
|
|
68
|
+
expect(hasReactPatterns("forwardRef<HTMLInputElement>((props, ref) => {})")).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("returns true for className attribute", () => {
|
|
72
|
+
expect(hasReactPatterns('className="foo"')).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("returns true for className with space", () => {
|
|
76
|
+
expect(hasReactPatterns("className ")).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("returns true for htmlFor attribute", () => {
|
|
80
|
+
expect(hasReactPatterns('htmlFor="name"')).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("returns true for .value assignment", () => {
|
|
84
|
+
expect(hasReactPatterns("count.value = 5")).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test("returns false for pure Pyreon code", () => {
|
|
88
|
+
expect(
|
|
89
|
+
hasReactPatterns(`
|
|
90
|
+
import { signal, effect, computed } from "@pyreon/reactivity"
|
|
91
|
+
const count = signal(0)
|
|
92
|
+
effect(() => console.log(count()))
|
|
93
|
+
`),
|
|
94
|
+
).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("returns false for empty code", () => {
|
|
98
|
+
expect(hasReactPatterns("")).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("returns false for plain JavaScript", () => {
|
|
102
|
+
expect(hasReactPatterns("const x = 42\nfunction foo() { return x }")).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ─── detectReactPatterns ─────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
describe("detectReactPatterns", () => {
|
|
109
|
+
test("detects React import", () => {
|
|
110
|
+
const diags = detectReactPatterns(`import { useState } from "react"`)
|
|
111
|
+
const importDiag = diags.find((d) => d.code === "react-import")
|
|
112
|
+
expect(importDiag).toBeDefined()
|
|
113
|
+
expect(importDiag!.suggested).toContain("@pyreon/core")
|
|
114
|
+
expect(importDiag!.fixable).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("detects react-dom import", () => {
|
|
118
|
+
const diags = detectReactPatterns(`import { createRoot } from "react-dom/client"`)
|
|
119
|
+
const importDiag = diags.find((d) => d.code === "react-dom-import")
|
|
120
|
+
expect(importDiag).toBeDefined()
|
|
121
|
+
expect(importDiag!.suggested).toContain("@pyreon/runtime-dom")
|
|
122
|
+
expect(importDiag!.fixable).toBe(true)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("detects react-router import", () => {
|
|
126
|
+
const diags = detectReactPatterns(`import { Link } from "react-router-dom"`)
|
|
127
|
+
const importDiag = diags.find((d) => d.code === "react-router-import")
|
|
128
|
+
expect(importDiag).toBeDefined()
|
|
129
|
+
expect(importDiag!.suggested).toContain("@pyreon/router")
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("detects useState with destructuring", () => {
|
|
133
|
+
const diags = detectReactPatterns("const [count, setCount] = useState(0)")
|
|
134
|
+
const d = diags.find((d) => d.code === "use-state")
|
|
135
|
+
expect(d).toBeDefined()
|
|
136
|
+
expect(d!.message).toContain("signal")
|
|
137
|
+
expect(d!.suggested).toContain("count = signal(0)")
|
|
138
|
+
expect(d!.fixable).toBe(true)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test("detects useState without array destructuring", () => {
|
|
142
|
+
const diags = detectReactPatterns("const state = useState(0)")
|
|
143
|
+
const d = diags.find((d) => d.code === "use-state")
|
|
144
|
+
expect(d).toBeDefined()
|
|
145
|
+
expect(d!.suggested).toContain("signal")
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test("detects useEffect with empty deps (mount pattern)", () => {
|
|
149
|
+
const code = `useEffect(() => { console.log("mounted") }, [])`
|
|
150
|
+
const diags = detectReactPatterns(code)
|
|
151
|
+
const d = diags.find((d) => d.code === "use-effect-mount")
|
|
152
|
+
expect(d).toBeDefined()
|
|
153
|
+
expect(d!.message).toContain("onMount")
|
|
154
|
+
expect(d!.suggested).toContain("return undefined")
|
|
155
|
+
expect(d!.fixable).toBe(true)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("detects useEffect with empty deps and cleanup", () => {
|
|
159
|
+
const code = `useEffect(() => { const id = setInterval(tick, 1000); return () => clearInterval(id) }, [])`
|
|
160
|
+
const diags = detectReactPatterns(code)
|
|
161
|
+
const d = diags.find((d) => d.code === "use-effect-mount")
|
|
162
|
+
expect(d).toBeDefined()
|
|
163
|
+
expect(d!.suggested).toContain("cleanup")
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test("detects useEffect with dependency array", () => {
|
|
167
|
+
const code = "useEffect(() => { document.title = count }, [count])"
|
|
168
|
+
const diags = detectReactPatterns(code)
|
|
169
|
+
const d = diags.find((d) => d.code === "use-effect-deps")
|
|
170
|
+
expect(d).toBeDefined()
|
|
171
|
+
expect(d!.message).toContain("auto-tracks")
|
|
172
|
+
expect(d!.fixable).toBe(true)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test("detects useEffect with no dependency array", () => {
|
|
176
|
+
const code = "useEffect(() => { console.log('render') })"
|
|
177
|
+
const diags = detectReactPatterns(code)
|
|
178
|
+
const d = diags.find((d) => d.code === "use-effect-no-deps")
|
|
179
|
+
expect(d).toBeDefined()
|
|
180
|
+
expect(d!.message).toContain("auto-tracks")
|
|
181
|
+
expect(d!.fixable).toBe(true)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test("detects useLayoutEffect", () => {
|
|
185
|
+
const code = "useLayoutEffect(() => { measure() }, [])"
|
|
186
|
+
const diags = detectReactPatterns(code)
|
|
187
|
+
const d = diags.find((d) => d.code === "use-effect-mount")
|
|
188
|
+
expect(d).toBeDefined()
|
|
189
|
+
expect(d!.message).toContain("useLayoutEffect")
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test("detects useMemo", () => {
|
|
193
|
+
const code = "const doubled = useMemo(() => count * 2, [count])"
|
|
194
|
+
const diags = detectReactPatterns(code)
|
|
195
|
+
const d = diags.find((d) => d.code === "use-memo")
|
|
196
|
+
expect(d).toBeDefined()
|
|
197
|
+
expect(d!.message).toContain("computed")
|
|
198
|
+
expect(d!.suggested).toContain("computed")
|
|
199
|
+
expect(d!.fixable).toBe(true)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test("detects useCallback", () => {
|
|
203
|
+
const code = "const handleClick = useCallback(() => doThing(), [doThing])"
|
|
204
|
+
const diags = detectReactPatterns(code)
|
|
205
|
+
const d = diags.find((d) => d.code === "use-callback")
|
|
206
|
+
expect(d).toBeDefined()
|
|
207
|
+
expect(d!.message).toContain("not needed")
|
|
208
|
+
expect(d!.suggested).toContain("() => doThing()")
|
|
209
|
+
expect(d!.fixable).toBe(true)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test("detects useRef with null (DOM ref)", () => {
|
|
213
|
+
const code = "const inputRef = useRef(null)"
|
|
214
|
+
const diags = detectReactPatterns(code)
|
|
215
|
+
const d = diags.find((d) => d.code === "use-ref-dom")
|
|
216
|
+
expect(d).toBeDefined()
|
|
217
|
+
expect(d!.message).toContain("createRef")
|
|
218
|
+
expect(d!.suggested).toContain("createRef()")
|
|
219
|
+
expect(d!.fixable).toBe(true)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test("detects useRef with value (mutable box)", () => {
|
|
223
|
+
const code = "const prevCount = useRef(0)"
|
|
224
|
+
const diags = detectReactPatterns(code)
|
|
225
|
+
const d = diags.find((d) => d.code === "use-ref-box")
|
|
226
|
+
expect(d).toBeDefined()
|
|
227
|
+
expect(d!.message).toContain("signal")
|
|
228
|
+
expect(d!.suggested).toContain("signal(0)")
|
|
229
|
+
expect(d!.fixable).toBe(true)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test("detects useReducer", () => {
|
|
233
|
+
const code = "const [state, dispatch] = useReducer(reducer, initialState)"
|
|
234
|
+
const diags = detectReactPatterns(code)
|
|
235
|
+
const d = diags.find((d) => d.code === "use-reducer")
|
|
236
|
+
expect(d).toBeDefined()
|
|
237
|
+
expect(d!.message).toContain("signal")
|
|
238
|
+
expect(d!.fixable).toBe(false)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test("detects memo() wrapper", () => {
|
|
242
|
+
const code = "const MyComp = memo(function MyComp() { return <div /> })"
|
|
243
|
+
const diags = detectReactPatterns(code)
|
|
244
|
+
const d = diags.find((d) => d.code === "memo-wrapper")
|
|
245
|
+
expect(d).toBeDefined()
|
|
246
|
+
expect(d!.message).toContain("not needed")
|
|
247
|
+
expect(d!.fixable).toBe(true)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test("detects React.memo() wrapper", () => {
|
|
251
|
+
const code = "const MyComp = React.memo(function MyComp() { return <div /> })"
|
|
252
|
+
const diags = detectReactPatterns(code)
|
|
253
|
+
const d = diags.find((d) => d.code === "memo-wrapper")
|
|
254
|
+
expect(d).toBeDefined()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test("detects forwardRef()", () => {
|
|
258
|
+
const code = "const Input = forwardRef((props, ref) => <input ref={ref} />)"
|
|
259
|
+
const diags = detectReactPatterns(code)
|
|
260
|
+
const d = diags.find((d) => d.code === "forward-ref")
|
|
261
|
+
expect(d).toBeDefined()
|
|
262
|
+
expect(d!.message).toContain("not needed")
|
|
263
|
+
expect(d!.fixable).toBe(true)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test("detects React.forwardRef()", () => {
|
|
267
|
+
const code = "const Input = React.forwardRef((props, ref) => <input ref={ref} />)"
|
|
268
|
+
const diags = detectReactPatterns(code)
|
|
269
|
+
const d = diags.find((d) => d.code === "forward-ref")
|
|
270
|
+
expect(d).toBeDefined()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test("detects className attribute", () => {
|
|
274
|
+
const code = 'const el = <div className="container">Hello</div>'
|
|
275
|
+
const diags = detectReactPatterns(code)
|
|
276
|
+
const d = diags.find((d) => d.code === "class-name-prop")
|
|
277
|
+
expect(d).toBeDefined()
|
|
278
|
+
expect(d!.message).toContain("class")
|
|
279
|
+
expect(d!.suggested).toContain("class")
|
|
280
|
+
expect(d!.fixable).toBe(true)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test("detects htmlFor attribute", () => {
|
|
284
|
+
const code = 'const el = <label htmlFor="name">Name</label>'
|
|
285
|
+
const diags = detectReactPatterns(code)
|
|
286
|
+
const d = diags.find((d) => d.code === "html-for-prop")
|
|
287
|
+
expect(d).toBeDefined()
|
|
288
|
+
expect(d!.message).toContain("for")
|
|
289
|
+
expect(d!.suggested).toContain("for")
|
|
290
|
+
expect(d!.fixable).toBe(true)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test("detects onChange on input", () => {
|
|
294
|
+
const code = "const el = <input onChange={handleChange} />"
|
|
295
|
+
const diags = detectReactPatterns(code)
|
|
296
|
+
const d = diags.find((d) => d.code === "on-change-input")
|
|
297
|
+
expect(d).toBeDefined()
|
|
298
|
+
expect(d!.message).toContain("onInput")
|
|
299
|
+
expect(d!.suggested).toContain("onInput")
|
|
300
|
+
expect(d!.fixable).toBe(true)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test("detects onChange on textarea", () => {
|
|
304
|
+
const code = "const el = <textarea onChange={handleChange} />"
|
|
305
|
+
const diags = detectReactPatterns(code)
|
|
306
|
+
const d = diags.find((d) => d.code === "on-change-input")
|
|
307
|
+
expect(d).toBeDefined()
|
|
308
|
+
expect(d!.message).toContain("textarea")
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test("detects onChange on select", () => {
|
|
312
|
+
const code = "const el = <select onChange={handleChange} />"
|
|
313
|
+
const diags = detectReactPatterns(code)
|
|
314
|
+
const d = diags.find((d) => d.code === "on-change-input")
|
|
315
|
+
expect(d).toBeDefined()
|
|
316
|
+
expect(d!.message).toContain("select")
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test("does NOT detect onChange on non-input element", () => {
|
|
320
|
+
const code = "const el = <div onChange={handleChange} />"
|
|
321
|
+
const diags = detectReactPatterns(code)
|
|
322
|
+
const d = diags.find((d) => d.code === "on-change-input")
|
|
323
|
+
expect(d).toBeUndefined()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test("detects dangerouslySetInnerHTML", () => {
|
|
327
|
+
const code = 'const el = <div dangerouslySetInnerHTML={{ __html: "<b>hi</b>" }} />'
|
|
328
|
+
const diags = detectReactPatterns(code)
|
|
329
|
+
const d = diags.find((d) => d.code === "dangerously-set-inner-html")
|
|
330
|
+
expect(d).toBeDefined()
|
|
331
|
+
expect(d!.message).toContain("innerHTML")
|
|
332
|
+
expect(d!.suggested).toContain("innerHTML")
|
|
333
|
+
expect(d!.fixable).toBe(true)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test("detects .value assignment on signal-like variable", () => {
|
|
337
|
+
const code = "count.value = 5"
|
|
338
|
+
const diags = detectReactPatterns(code)
|
|
339
|
+
const d = diags.find((d) => d.code === "dot-value-signal")
|
|
340
|
+
expect(d).toBeDefined()
|
|
341
|
+
expect(d!.message).toContain("Vue ref")
|
|
342
|
+
expect(d!.suggested).toContain("count.set(5)")
|
|
343
|
+
expect(d!.fixable).toBe(false)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
test("detects .map() in JSX expression", () => {
|
|
347
|
+
const code = "const el = <ul>{items.map(item => <li>{item}</li>)}</ul>"
|
|
348
|
+
const diags = detectReactPatterns(code)
|
|
349
|
+
const d = diags.find((d) => d.code === "array-map-jsx")
|
|
350
|
+
expect(d).toBeDefined()
|
|
351
|
+
expect(d!.message).toContain("<For>")
|
|
352
|
+
expect(d!.fixable).toBe(false)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test("returns empty array for pure Pyreon code", () => {
|
|
356
|
+
const code = `
|
|
357
|
+
import { signal, effect } from "@pyreon/reactivity"
|
|
358
|
+
const count = signal(0)
|
|
359
|
+
effect(() => console.log(count()))
|
|
360
|
+
const el = <div class="foo">{count()}</div>
|
|
361
|
+
`
|
|
362
|
+
const diags = detectReactPatterns(code)
|
|
363
|
+
expect(diags).toEqual([])
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test("includes correct line and column information", () => {
|
|
367
|
+
const code = "const [count, setCount] = useState(0)"
|
|
368
|
+
const diags = detectReactPatterns(code)
|
|
369
|
+
const d = diags.find((d) => d.code === "use-state")
|
|
370
|
+
expect(d).toBeDefined()
|
|
371
|
+
expect(d!.line).toBe(1)
|
|
372
|
+
expect(d!.column).toBeGreaterThanOrEqual(0)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test("detects multiple patterns in one file", () => {
|
|
376
|
+
const code = `
|
|
377
|
+
import { useState, useEffect, useMemo } from "react"
|
|
378
|
+
const [count, setCount] = useState(0)
|
|
379
|
+
useEffect(() => {}, [])
|
|
380
|
+
const doubled = useMemo(() => count * 2, [count])
|
|
381
|
+
`
|
|
382
|
+
const diags = detectReactPatterns(code)
|
|
383
|
+
expect(diags.find((d) => d.code === "react-import")).toBeDefined()
|
|
384
|
+
expect(diags.find((d) => d.code === "use-state")).toBeDefined()
|
|
385
|
+
expect(diags.find((d) => d.code === "use-effect-mount")).toBeDefined()
|
|
386
|
+
expect(diags.find((d) => d.code === "use-memo")).toBeDefined()
|
|
387
|
+
expect(diags.length).toBeGreaterThanOrEqual(4)
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
// ─── migrateReactCode ────────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
describe("migrateReactCode", () => {
|
|
394
|
+
test("rewrites useState to signal", () => {
|
|
395
|
+
const code = `const [count, setCount] = useState(0)`
|
|
396
|
+
const result = migrateReactCode(code)
|
|
397
|
+
expect(result.code).toContain("count = signal(0)")
|
|
398
|
+
expect(result.code).not.toContain("useState")
|
|
399
|
+
expect(result.changes.length).toBeGreaterThan(0)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test("rewrites useEffect with deps to effect", () => {
|
|
403
|
+
const code = `useEffect(() => { console.log(count) }, [count])`
|
|
404
|
+
const result = migrateReactCode(code)
|
|
405
|
+
expect(result.code).toContain("effect(")
|
|
406
|
+
expect(result.code).not.toContain("useEffect")
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
test("rewrites useEffect with empty deps to onMount", () => {
|
|
410
|
+
const code = `useEffect(() => { setup() }, [])`
|
|
411
|
+
const result = migrateReactCode(code)
|
|
412
|
+
expect(result.code).toContain("onMount(")
|
|
413
|
+
expect(result.code).not.toContain("useEffect")
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test("rewrites useEffect with no deps to effect", () => {
|
|
417
|
+
const code = `useEffect(() => { console.log("render") })`
|
|
418
|
+
const result = migrateReactCode(code)
|
|
419
|
+
expect(result.code).toContain("effect(")
|
|
420
|
+
expect(result.code).not.toContain("useEffect")
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test("rewrites useMemo to computed", () => {
|
|
424
|
+
const code = `const doubled = useMemo(() => count * 2, [count])`
|
|
425
|
+
const result = migrateReactCode(code)
|
|
426
|
+
expect(result.code).toContain("computed(")
|
|
427
|
+
expect(result.code).not.toContain("useMemo")
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
test("removes useCallback wrapper", () => {
|
|
431
|
+
const code = `const handleClick = useCallback(() => doThing(), [doThing])`
|
|
432
|
+
const result = migrateReactCode(code)
|
|
433
|
+
expect(result.code).toContain("() => doThing()")
|
|
434
|
+
expect(result.code).not.toContain("useCallback")
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
test("rewrites useRef(null) to createRef()", () => {
|
|
438
|
+
const code = `const inputRef = useRef(null)`
|
|
439
|
+
const result = migrateReactCode(code)
|
|
440
|
+
expect(result.code).toContain("createRef()")
|
|
441
|
+
expect(result.code).not.toContain("useRef")
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
test("rewrites useRef(value) to signal(value)", () => {
|
|
445
|
+
const code = `const prevCount = useRef(0)`
|
|
446
|
+
const result = migrateReactCode(code)
|
|
447
|
+
expect(result.code).toContain("signal(0)")
|
|
448
|
+
expect(result.code).not.toContain("useRef")
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
test("rewrites useRef(undefined) to createRef()", () => {
|
|
452
|
+
const code = `const ref = useRef(undefined)`
|
|
453
|
+
const result = migrateReactCode(code)
|
|
454
|
+
expect(result.code).toContain("createRef()")
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
test("removes memo() wrapper", () => {
|
|
458
|
+
const code = `const MyComp = memo(function MyComp() { return <div /> })`
|
|
459
|
+
const result = migrateReactCode(code)
|
|
460
|
+
expect(result.code).not.toContain("memo(")
|
|
461
|
+
expect(result.code).toContain("function MyComp()")
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
test("removes React.memo() wrapper", () => {
|
|
465
|
+
const code = `const MyComp = React.memo(function MyComp() { return <div /> })`
|
|
466
|
+
const result = migrateReactCode(code)
|
|
467
|
+
expect(result.code).not.toContain("React.memo")
|
|
468
|
+
expect(result.code).toContain("function MyComp()")
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
test("removes forwardRef() wrapper", () => {
|
|
472
|
+
const code = `const Input = forwardRef((props, ref) => <input ref={ref} />)`
|
|
473
|
+
const result = migrateReactCode(code)
|
|
474
|
+
expect(result.code).not.toContain("forwardRef")
|
|
475
|
+
expect(result.code).toContain("(props, ref) =>")
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
test("removes React.forwardRef() wrapper", () => {
|
|
479
|
+
const code = `const Input = React.forwardRef((props, ref) => <input ref={ref} />)`
|
|
480
|
+
const result = migrateReactCode(code)
|
|
481
|
+
expect(result.code).not.toContain("forwardRef")
|
|
482
|
+
expect(result.code).toContain("(props, ref) =>")
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
test("rewrites className to class", () => {
|
|
486
|
+
const code = `const el = <div className="container">Hello</div>`
|
|
487
|
+
const result = migrateReactCode(code)
|
|
488
|
+
expect(result.code).toContain('class="container"')
|
|
489
|
+
expect(result.code).not.toContain("className")
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
test("rewrites onChange to onInput on input", () => {
|
|
493
|
+
const code = `const el = <input onChange={handleChange} />`
|
|
494
|
+
const result = migrateReactCode(code)
|
|
495
|
+
expect(result.code).toContain("onInput")
|
|
496
|
+
expect(result.code).not.toContain("onChange")
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
test("rewrites onChange to onInput on textarea", () => {
|
|
500
|
+
const code = `const el = <textarea onChange={handleChange} />`
|
|
501
|
+
const result = migrateReactCode(code)
|
|
502
|
+
expect(result.code).toContain("onInput")
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
test("rewrites onChange to onInput on select", () => {
|
|
506
|
+
const code = `const el = <select onChange={handleChange} />`
|
|
507
|
+
const result = migrateReactCode(code)
|
|
508
|
+
expect(result.code).toContain("onInput")
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
test("rewrites React imports to Pyreon imports", () => {
|
|
512
|
+
const code = `import { useState, useEffect, useMemo } from "react"
|
|
513
|
+
const [count, setCount] = useState(0)
|
|
514
|
+
useEffect(() => { console.log(count) }, [count])
|
|
515
|
+
const doubled = useMemo(() => count * 2, [count])`
|
|
516
|
+
const result = migrateReactCode(code)
|
|
517
|
+
expect(result.code).not.toContain(`from "react"`)
|
|
518
|
+
expect(result.code).toContain("@pyreon/reactivity")
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
test("rewrites dangerouslySetInnerHTML to innerHTML", () => {
|
|
522
|
+
const code = `const el = <div dangerouslySetInnerHTML={{ __html: htmlString }} />`
|
|
523
|
+
const result = migrateReactCode(code)
|
|
524
|
+
expect(result.code).toContain("innerHTML={htmlString}")
|
|
525
|
+
expect(result.code).not.toContain("dangerouslySetInnerHTML")
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
test("returns change descriptions", () => {
|
|
529
|
+
const code = `const [count, setCount] = useState(0)`
|
|
530
|
+
const result = migrateReactCode(code)
|
|
531
|
+
expect(result.changes.length).toBeGreaterThan(0)
|
|
532
|
+
expect(result.changes[0]!.description).toContain("useState")
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test("handles code with no React patterns (no changes)", () => {
|
|
536
|
+
const code = `const count = signal(0)\neffect(() => console.log(count()))`
|
|
537
|
+
const result = migrateReactCode(code)
|
|
538
|
+
expect(result.code).toBe(code)
|
|
539
|
+
expect(result.changes).toEqual([])
|
|
540
|
+
expect(result.diagnostics).toEqual([])
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
test("includes diagnostics in migration result", () => {
|
|
544
|
+
const code = `import { useState } from "react"\nconst [count, setCount] = useState(0)`
|
|
545
|
+
const result = migrateReactCode(code)
|
|
546
|
+
expect(result.diagnostics.length).toBeGreaterThan(0)
|
|
547
|
+
expect(result.diagnostics.find((d) => d.code === "use-state")).toBeDefined()
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
test("adds correct Pyreon imports", () => {
|
|
551
|
+
const code = `import { useState, useMemo } from "react"
|
|
552
|
+
const [count, setCount] = useState(0)
|
|
553
|
+
const doubled = useMemo(() => count * 2, [count])`
|
|
554
|
+
const result = migrateReactCode(code)
|
|
555
|
+
expect(result.code).toContain(`import { computed, signal } from "@pyreon/reactivity"`)
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
test("rewrites react-dom/client import specifiers", () => {
|
|
559
|
+
const code = `import { createRoot } from "react-dom/client"
|
|
560
|
+
createRoot(document.getElementById("root"))`
|
|
561
|
+
const result = migrateReactCode(code)
|
|
562
|
+
expect(result.code).not.toContain("react-dom")
|
|
563
|
+
expect(result.code).toContain("@pyreon/runtime-dom")
|
|
564
|
+
expect(result.code).toContain("mount")
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
test("migrates full React component", () => {
|
|
568
|
+
const code = `import { useState, useEffect, useMemo, useCallback, useRef, memo } from "react"
|
|
569
|
+
|
|
570
|
+
const Counter = memo(function Counter() {
|
|
571
|
+
const [count, setCount] = useState(0)
|
|
572
|
+
const inputRef = useRef(null)
|
|
573
|
+
const doubled = useMemo(() => count * 2, [count])
|
|
574
|
+
const handleClick = useCallback(() => setCount(c => c + 1), [])
|
|
575
|
+
|
|
576
|
+
useEffect(() => {
|
|
577
|
+
document.title = \`Count: \${count}\`
|
|
578
|
+
}, [count])
|
|
579
|
+
|
|
580
|
+
return <div className="counter">{count}</div>
|
|
581
|
+
})`
|
|
582
|
+
const result = migrateReactCode(code)
|
|
583
|
+
expect(result.code).not.toContain("useState")
|
|
584
|
+
expect(result.code).not.toContain("useMemo")
|
|
585
|
+
expect(result.code).not.toContain("useCallback")
|
|
586
|
+
expect(result.code).not.toContain("useRef")
|
|
587
|
+
expect(result.code).not.toContain("className")
|
|
588
|
+
expect(result.code).toContain("signal")
|
|
589
|
+
expect(result.code).toContain("computed")
|
|
590
|
+
expect(result.code).toContain("effect")
|
|
591
|
+
expect(result.code).toContain("createRef")
|
|
592
|
+
expect(result.code).toContain("class=")
|
|
593
|
+
expect(result.changes.length).toBeGreaterThan(0)
|
|
594
|
+
})
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
// ─── diagnoseError ───────────────────────────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
describe("diagnoseError", () => {
|
|
600
|
+
test("diagnoses 'X is not a function' as signal access issue", () => {
|
|
601
|
+
const result = diagnoseError("count is not a function")
|
|
602
|
+
expect(result).not.toBeNull()
|
|
603
|
+
expect(result!.cause).toContain("count")
|
|
604
|
+
expect(result!.fix).toContain("signal")
|
|
605
|
+
expect(result!.fixCode).toContain("count()")
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
test("diagnoses Cannot read properties of undefined (reading 'set')", () => {
|
|
609
|
+
const result = diagnoseError("Cannot read properties of undefined (reading 'set')")
|
|
610
|
+
expect(result).not.toBeNull()
|
|
611
|
+
expect(result!.cause).toContain(".set()")
|
|
612
|
+
expect(result!.fix).toContain("signal")
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
test("diagnoses Cannot read properties of undefined (reading 'update')", () => {
|
|
616
|
+
const result = diagnoseError("Cannot read properties of undefined (reading 'update')")
|
|
617
|
+
expect(result).not.toBeNull()
|
|
618
|
+
expect(result!.cause).toContain(".update()")
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
test("diagnoses Cannot read properties of undefined (reading 'peek')", () => {
|
|
622
|
+
const result = diagnoseError("Cannot read properties of undefined (reading 'peek')")
|
|
623
|
+
expect(result).not.toBeNull()
|
|
624
|
+
expect(result!.cause).toContain(".peek()")
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
test("diagnoses Cannot read properties of undefined (reading 'subscribe')", () => {
|
|
628
|
+
const result = diagnoseError("Cannot read properties of undefined (reading 'subscribe')")
|
|
629
|
+
expect(result).not.toBeNull()
|
|
630
|
+
expect(result!.cause).toContain(".subscribe()")
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
test("diagnoses missing @pyreon package", () => {
|
|
634
|
+
const result = diagnoseError("Cannot find module '@pyreon/reactivity'")
|
|
635
|
+
expect(result).not.toBeNull()
|
|
636
|
+
expect(result!.cause).toContain("@pyreon/reactivity")
|
|
637
|
+
expect(result!.fix).toContain("bun add")
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
test("diagnoses missing react module in Pyreon project", () => {
|
|
641
|
+
const result = diagnoseError("Cannot find module 'react'")
|
|
642
|
+
expect(result).not.toBeNull()
|
|
643
|
+
expect(result!.cause).toContain("react")
|
|
644
|
+
expect(result!.fix).toContain("Pyreon")
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
test("diagnoses .value property on Signal type", () => {
|
|
648
|
+
const result = diagnoseError("Property 'value' does not exist on type 'Signal<number>'")
|
|
649
|
+
expect(result).not.toBeNull()
|
|
650
|
+
expect(result!.cause).toContain(".value")
|
|
651
|
+
expect(result!.fix).toContain("callable")
|
|
652
|
+
expect(result!.fixCode).toContain("mySignal()")
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
test("diagnoses non-value property on Signal type", () => {
|
|
656
|
+
const result = diagnoseError("Property 'current' does not exist on type 'Signal<number>'")
|
|
657
|
+
expect(result).not.toBeNull()
|
|
658
|
+
expect(result!.cause).toContain(".current")
|
|
659
|
+
expect(result!.fix).toContain(".set()")
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
test("diagnoses type not assignable to VNode", () => {
|
|
663
|
+
const result = diagnoseError("Type 'number' is not assignable to type 'VNode'")
|
|
664
|
+
expect(result).not.toBeNull()
|
|
665
|
+
expect(result!.cause).toContain("number")
|
|
666
|
+
expect(result!.fix).toContain("JSX")
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
test("diagnoses onMount callback return type error", () => {
|
|
670
|
+
const result = diagnoseError("onMount callback must return")
|
|
671
|
+
expect(result).not.toBeNull()
|
|
672
|
+
expect(result!.cause).toContain("CleanupFn")
|
|
673
|
+
expect(result!.fixCode).toContain("return undefined")
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
test("diagnoses missing by prop on For", () => {
|
|
677
|
+
const result = diagnoseError("Expected 'by' prop on <For>")
|
|
678
|
+
expect(result).not.toBeNull()
|
|
679
|
+
expect(result!.cause).toContain("by")
|
|
680
|
+
expect(result!.fixCode).toContain("by=")
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
test("diagnoses hook called outside component", () => {
|
|
684
|
+
const result = diagnoseError("useHook called outside component boundary")
|
|
685
|
+
expect(result).not.toBeNull()
|
|
686
|
+
expect(result!.cause).toContain("outside")
|
|
687
|
+
expect(result!.fix).toContain("component")
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
test("diagnoses hydration mismatch", () => {
|
|
691
|
+
const result = diagnoseError("Hydration mismatch")
|
|
692
|
+
expect(result).not.toBeNull()
|
|
693
|
+
expect(result!.cause).toContain("Server-rendered")
|
|
694
|
+
expect(result!.related).toContain("window")
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
test("returns null for unknown errors", () => {
|
|
698
|
+
expect(diagnoseError("Something completely unrelated happened")).toBeNull()
|
|
699
|
+
expect(diagnoseError("")).toBeNull()
|
|
700
|
+
expect(diagnoseError("TypeError: Cannot freeze")).toBeNull()
|
|
701
|
+
})
|
|
702
|
+
})
|