@pyreon/compiler 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +674 -1
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +494 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index2.d.ts +58 -1
- package/lib/types/index2.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +14 -0
- package/src/react-intercept.ts +1152 -0
- package/src/tests/react-intercept.test.ts +702 -0
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Pattern Interceptor — detects React/Vue patterns in code and provides
|
|
3
|
+
* structured diagnostics with exact fix suggestions for AI-assisted migration.
|
|
4
|
+
*
|
|
5
|
+
* Two modes:
|
|
6
|
+
* - `detectReactPatterns(code)` — returns diagnostics only (non-destructive)
|
|
7
|
+
* - `migrateReactCode(code)` — applies auto-fixes and returns transformed code
|
|
8
|
+
*
|
|
9
|
+
* Designed for three consumers:
|
|
10
|
+
* 1. Compiler pre-pass (warnings during build)
|
|
11
|
+
* 2. CLI `pyreon doctor` (project-wide scanning)
|
|
12
|
+
* 3. MCP server `migrate_react` / `validate` tools (AI agent integration)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import ts from "typescript"
|
|
16
|
+
|
|
17
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
18
|
+
// Types
|
|
19
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
20
|
+
|
|
21
|
+
export type ReactDiagnosticCode =
|
|
22
|
+
| "react-import"
|
|
23
|
+
| "react-dom-import"
|
|
24
|
+
| "react-router-import"
|
|
25
|
+
| "use-state"
|
|
26
|
+
| "use-effect-mount"
|
|
27
|
+
| "use-effect-deps"
|
|
28
|
+
| "use-effect-no-deps"
|
|
29
|
+
| "use-memo"
|
|
30
|
+
| "use-callback"
|
|
31
|
+
| "use-ref-dom"
|
|
32
|
+
| "use-ref-box"
|
|
33
|
+
| "use-reducer"
|
|
34
|
+
| "use-layout-effect"
|
|
35
|
+
| "memo-wrapper"
|
|
36
|
+
| "forward-ref"
|
|
37
|
+
| "class-name-prop"
|
|
38
|
+
| "html-for-prop"
|
|
39
|
+
| "on-change-input"
|
|
40
|
+
| "dangerously-set-inner-html"
|
|
41
|
+
| "dot-value-signal"
|
|
42
|
+
| "array-map-jsx"
|
|
43
|
+
| "key-on-for-child"
|
|
44
|
+
| "create-context-import"
|
|
45
|
+
| "use-context-import"
|
|
46
|
+
|
|
47
|
+
export interface ReactDiagnostic {
|
|
48
|
+
/** Machine-readable code for filtering and programmatic handling */
|
|
49
|
+
code: ReactDiagnosticCode
|
|
50
|
+
/** Human-readable message explaining the issue */
|
|
51
|
+
message: string
|
|
52
|
+
/** 1-based line number */
|
|
53
|
+
line: number
|
|
54
|
+
/** 0-based column */
|
|
55
|
+
column: number
|
|
56
|
+
/** The code as written */
|
|
57
|
+
current: string
|
|
58
|
+
/** The suggested Pyreon equivalent */
|
|
59
|
+
suggested: string
|
|
60
|
+
/** Whether migrateReactCode can auto-fix this */
|
|
61
|
+
fixable: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MigrationChange {
|
|
65
|
+
type: "replace" | "remove" | "add"
|
|
66
|
+
line: number
|
|
67
|
+
description: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface MigrationResult {
|
|
71
|
+
/** Transformed source code */
|
|
72
|
+
code: string
|
|
73
|
+
/** All detected patterns (including unfixable ones) */
|
|
74
|
+
diagnostics: ReactDiagnostic[]
|
|
75
|
+
/** Description of changes applied */
|
|
76
|
+
changes: MigrationChange[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
80
|
+
// React Hook → Pyreon mapping
|
|
81
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
82
|
+
|
|
83
|
+
interface HookMapping {
|
|
84
|
+
pyreonFn: string
|
|
85
|
+
pyreonImport: string
|
|
86
|
+
description: string
|
|
87
|
+
example: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const _REACT_HOOK_MAP: Record<string, HookMapping> = {
|
|
91
|
+
useState: {
|
|
92
|
+
pyreonFn: "signal",
|
|
93
|
+
pyreonImport: "@pyreon/reactivity",
|
|
94
|
+
description: "Signals are callable functions — read: count(), write: count.set(5)",
|
|
95
|
+
example:
|
|
96
|
+
"const count = signal(0)\n// Read: count() Write: count.set(5) Update: count.update(n => n + 1)",
|
|
97
|
+
},
|
|
98
|
+
useEffect: {
|
|
99
|
+
pyreonFn: "effect",
|
|
100
|
+
pyreonImport: "@pyreon/reactivity",
|
|
101
|
+
description: "Effects auto-track signal dependencies — no dependency array needed",
|
|
102
|
+
example: "effect(() => {\n console.log(count()) // auto-subscribes to count\n})",
|
|
103
|
+
},
|
|
104
|
+
useLayoutEffect: {
|
|
105
|
+
pyreonFn: "effect",
|
|
106
|
+
pyreonImport: "@pyreon/reactivity",
|
|
107
|
+
description: "Pyreon effects run synchronously after signal updates",
|
|
108
|
+
example: "effect(() => {\n // runs sync after signal changes\n})",
|
|
109
|
+
},
|
|
110
|
+
useMemo: {
|
|
111
|
+
pyreonFn: "computed",
|
|
112
|
+
pyreonImport: "@pyreon/reactivity",
|
|
113
|
+
description: "Computed values auto-track dependencies and memoize",
|
|
114
|
+
example: "const doubled = computed(() => count() * 2)",
|
|
115
|
+
},
|
|
116
|
+
useCallback: {
|
|
117
|
+
pyreonFn: "(plain function)",
|
|
118
|
+
pyreonImport: "",
|
|
119
|
+
description:
|
|
120
|
+
"Not needed — Pyreon components run once, so closures never go stale. Use a plain function",
|
|
121
|
+
example: "const handleClick = () => doSomething(count())",
|
|
122
|
+
},
|
|
123
|
+
useReducer: {
|
|
124
|
+
pyreonFn: "signal",
|
|
125
|
+
pyreonImport: "@pyreon/reactivity",
|
|
126
|
+
description: "Use signal with update() for reducer-like patterns",
|
|
127
|
+
example:
|
|
128
|
+
"const state = signal(initialState)\nconst dispatch = (action) => state.update(s => reducer(s, action))",
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** React import sources → Pyreon equivalents */
|
|
133
|
+
const IMPORT_REWRITES: Record<string, string | null> = {
|
|
134
|
+
react: "@pyreon/core",
|
|
135
|
+
"react-dom": "@pyreon/runtime-dom",
|
|
136
|
+
"react-dom/client": "@pyreon/runtime-dom",
|
|
137
|
+
"react-dom/server": "@pyreon/runtime-server",
|
|
138
|
+
"react-router": "@pyreon/router",
|
|
139
|
+
"react-router-dom": "@pyreon/router",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** React specifiers that map to specific Pyreon imports */
|
|
143
|
+
const SPECIFIER_REWRITES: Record<string, { name: string; from: string }> = {
|
|
144
|
+
useState: { name: "signal", from: "@pyreon/reactivity" },
|
|
145
|
+
useEffect: { name: "effect", from: "@pyreon/reactivity" },
|
|
146
|
+
useLayoutEffect: { name: "effect", from: "@pyreon/reactivity" },
|
|
147
|
+
useMemo: { name: "computed", from: "@pyreon/reactivity" },
|
|
148
|
+
useReducer: { name: "signal", from: "@pyreon/reactivity" },
|
|
149
|
+
useRef: { name: "signal", from: "@pyreon/reactivity" },
|
|
150
|
+
createContext: { name: "createContext", from: "@pyreon/core" },
|
|
151
|
+
useContext: { name: "useContext", from: "@pyreon/core" },
|
|
152
|
+
Fragment: { name: "Fragment", from: "@pyreon/core" },
|
|
153
|
+
Suspense: { name: "Suspense", from: "@pyreon/core" },
|
|
154
|
+
lazy: { name: "lazy", from: "@pyreon/core" },
|
|
155
|
+
memo: { name: "", from: "" }, // removed, not needed
|
|
156
|
+
forwardRef: { name: "", from: "" }, // removed, not needed
|
|
157
|
+
createRoot: { name: "mount", from: "@pyreon/runtime-dom" },
|
|
158
|
+
hydrateRoot: { name: "hydrateRoot", from: "@pyreon/runtime-dom" },
|
|
159
|
+
// React Router
|
|
160
|
+
useNavigate: { name: "useRouter", from: "@pyreon/router" },
|
|
161
|
+
useParams: { name: "useRoute", from: "@pyreon/router" },
|
|
162
|
+
useLocation: { name: "useRoute", from: "@pyreon/router" },
|
|
163
|
+
Link: { name: "RouterLink", from: "@pyreon/router" },
|
|
164
|
+
NavLink: { name: "RouterLink", from: "@pyreon/router" },
|
|
165
|
+
Outlet: { name: "RouterView", from: "@pyreon/router" },
|
|
166
|
+
useSearchParams: { name: "useSearchParams", from: "@pyreon/router" },
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** JSX attribute rewrites (React → standard HTML) */
|
|
170
|
+
const JSX_ATTR_REWRITES: Record<string, string> = {
|
|
171
|
+
className: "class",
|
|
172
|
+
htmlFor: "for",
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
176
|
+
// Detection (diagnostic-only, no modifications)
|
|
177
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
178
|
+
|
|
179
|
+
interface DetectContext {
|
|
180
|
+
sf: ts.SourceFile
|
|
181
|
+
code: string
|
|
182
|
+
diagnostics: ReactDiagnostic[]
|
|
183
|
+
reactImportedHooks: Set<string>
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function detectGetNodeText(ctx: DetectContext, node: ts.Node): string {
|
|
187
|
+
return ctx.code.slice(node.getStart(ctx.sf), node.getEnd())
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function detectDiag(
|
|
191
|
+
ctx: DetectContext,
|
|
192
|
+
node: ts.Node,
|
|
193
|
+
diagCode: ReactDiagnosticCode,
|
|
194
|
+
message: string,
|
|
195
|
+
current: string,
|
|
196
|
+
suggested: string,
|
|
197
|
+
fixable: boolean,
|
|
198
|
+
): void {
|
|
199
|
+
const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf))
|
|
200
|
+
ctx.diagnostics.push({
|
|
201
|
+
code: diagCode,
|
|
202
|
+
message,
|
|
203
|
+
line: line + 1,
|
|
204
|
+
column: character,
|
|
205
|
+
current: current.trim(),
|
|
206
|
+
suggested: suggested.trim(),
|
|
207
|
+
fixable,
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function detectImportDeclaration(ctx: DetectContext, node: ts.ImportDeclaration): void {
|
|
212
|
+
if (!node.moduleSpecifier) return
|
|
213
|
+
const source = (node.moduleSpecifier as ts.StringLiteral).text
|
|
214
|
+
const pyreonSource = IMPORT_REWRITES[source]
|
|
215
|
+
|
|
216
|
+
if (pyreonSource !== undefined) {
|
|
217
|
+
if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) {
|
|
218
|
+
for (const spec of node.importClause.namedBindings.elements) {
|
|
219
|
+
ctx.reactImportedHooks.add(spec.name.text)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const diagCode = source.startsWith("react-router")
|
|
224
|
+
? "react-router-import"
|
|
225
|
+
: source.startsWith("react-dom")
|
|
226
|
+
? "react-dom-import"
|
|
227
|
+
: "react-import"
|
|
228
|
+
|
|
229
|
+
detectDiag(
|
|
230
|
+
ctx,
|
|
231
|
+
node,
|
|
232
|
+
diagCode,
|
|
233
|
+
`Import from '${source}' is a React package. Use Pyreon equivalent.`,
|
|
234
|
+
detectGetNodeText(ctx, node),
|
|
235
|
+
pyreonSource
|
|
236
|
+
? `import { ... } from "${pyreonSource}"`
|
|
237
|
+
: "Remove this import — not needed in Pyreon",
|
|
238
|
+
true,
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function detectUseState(ctx: DetectContext, node: ts.CallExpression): void {
|
|
244
|
+
const parent = node.parent
|
|
245
|
+
if (
|
|
246
|
+
ts.isVariableDeclaration(parent) &&
|
|
247
|
+
parent.name &&
|
|
248
|
+
ts.isArrayBindingPattern(parent.name) &&
|
|
249
|
+
parent.name.elements.length >= 1
|
|
250
|
+
) {
|
|
251
|
+
const firstEl = parent.name.elements[0]
|
|
252
|
+
const valueName =
|
|
253
|
+
firstEl && ts.isBindingElement(firstEl) ? (firstEl.name as ts.Identifier).text : "value"
|
|
254
|
+
const initArg = node.arguments[0] ? detectGetNodeText(ctx, node.arguments[0]) : "undefined"
|
|
255
|
+
|
|
256
|
+
detectDiag(
|
|
257
|
+
ctx,
|
|
258
|
+
node,
|
|
259
|
+
"use-state",
|
|
260
|
+
`useState is a React API. In Pyreon, use signal(). Read: ${valueName}(), Write: ${valueName}.set(x)`,
|
|
261
|
+
detectGetNodeText(ctx, parent),
|
|
262
|
+
`${valueName} = signal(${initArg})`,
|
|
263
|
+
true,
|
|
264
|
+
)
|
|
265
|
+
} else {
|
|
266
|
+
detectDiag(
|
|
267
|
+
ctx,
|
|
268
|
+
node,
|
|
269
|
+
"use-state",
|
|
270
|
+
"useState is a React API. In Pyreon, use signal().",
|
|
271
|
+
detectGetNodeText(ctx, node),
|
|
272
|
+
"signal(initialValue)",
|
|
273
|
+
true,
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function callbackHasCleanup(callbackArg: ts.Expression): boolean {
|
|
279
|
+
if (!ts.isArrowFunction(callbackArg) && !ts.isFunctionExpression(callbackArg)) return false
|
|
280
|
+
const body = callbackArg.body
|
|
281
|
+
if (!ts.isBlock(body)) return false
|
|
282
|
+
for (const stmt of body.statements) {
|
|
283
|
+
if (ts.isReturnStatement(stmt) && stmt.expression) return true
|
|
284
|
+
}
|
|
285
|
+
return false
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function detectUseEffect(ctx: DetectContext, node: ts.CallExpression): void {
|
|
289
|
+
const hookName = (node.expression as ts.Identifier).text
|
|
290
|
+
const depsArg = node.arguments[1]
|
|
291
|
+
const callbackArg = node.arguments[0]
|
|
292
|
+
|
|
293
|
+
if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0) {
|
|
294
|
+
const hasCleanup = callbackArg ? callbackHasCleanup(callbackArg) : false
|
|
295
|
+
|
|
296
|
+
detectDiag(
|
|
297
|
+
ctx,
|
|
298
|
+
node,
|
|
299
|
+
"use-effect-mount",
|
|
300
|
+
`${hookName} with empty deps [] means "run once on mount". Use onMount() in Pyreon.`,
|
|
301
|
+
detectGetNodeText(ctx, node),
|
|
302
|
+
hasCleanup
|
|
303
|
+
? "onMount(() => {\n // setup...\n return () => { /* cleanup */ }\n})"
|
|
304
|
+
: "onMount(() => {\n // setup...\n return undefined\n})",
|
|
305
|
+
true,
|
|
306
|
+
)
|
|
307
|
+
} else if (depsArg && ts.isArrayLiteralExpression(depsArg)) {
|
|
308
|
+
detectDiag(
|
|
309
|
+
ctx,
|
|
310
|
+
node,
|
|
311
|
+
"use-effect-deps",
|
|
312
|
+
`${hookName} with dependency array. In Pyreon, effect() auto-tracks dependencies — no array needed.`,
|
|
313
|
+
detectGetNodeText(ctx, node),
|
|
314
|
+
"effect(() => {\n // reads are auto-tracked\n})",
|
|
315
|
+
true,
|
|
316
|
+
)
|
|
317
|
+
} else if (!depsArg) {
|
|
318
|
+
detectDiag(
|
|
319
|
+
ctx,
|
|
320
|
+
node,
|
|
321
|
+
"use-effect-no-deps",
|
|
322
|
+
`${hookName} with no dependency array. In Pyreon, use effect() — it auto-tracks signal reads.`,
|
|
323
|
+
detectGetNodeText(ctx, node),
|
|
324
|
+
"effect(() => {\n // runs when accessed signals change\n})",
|
|
325
|
+
true,
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function detectUseMemo(ctx: DetectContext, node: ts.CallExpression): void {
|
|
331
|
+
const computeFn = node.arguments[0]
|
|
332
|
+
const computeText = computeFn ? detectGetNodeText(ctx, computeFn) : "() => value"
|
|
333
|
+
|
|
334
|
+
detectDiag(
|
|
335
|
+
ctx,
|
|
336
|
+
node,
|
|
337
|
+
"use-memo",
|
|
338
|
+
"useMemo is a React API. In Pyreon, use computed() — dependencies auto-tracked.",
|
|
339
|
+
detectGetNodeText(ctx, node),
|
|
340
|
+
`computed(${computeText})`,
|
|
341
|
+
true,
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function detectUseCallback(ctx: DetectContext, node: ts.CallExpression): void {
|
|
346
|
+
const callbackFn = node.arguments[0]
|
|
347
|
+
const callbackText = callbackFn ? detectGetNodeText(ctx, callbackFn) : "() => {}"
|
|
348
|
+
|
|
349
|
+
detectDiag(
|
|
350
|
+
ctx,
|
|
351
|
+
node,
|
|
352
|
+
"use-callback",
|
|
353
|
+
"useCallback is not needed in Pyreon. Components run once, so closures never go stale. Use a plain function.",
|
|
354
|
+
detectGetNodeText(ctx, node),
|
|
355
|
+
callbackText,
|
|
356
|
+
true,
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function detectUseRef(ctx: DetectContext, node: ts.CallExpression): void {
|
|
361
|
+
const arg = node.arguments[0]
|
|
362
|
+
const isNullInit =
|
|
363
|
+
arg &&
|
|
364
|
+
(arg.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(arg) && arg.text === "undefined"))
|
|
365
|
+
|
|
366
|
+
if (isNullInit) {
|
|
367
|
+
detectDiag(
|
|
368
|
+
ctx,
|
|
369
|
+
node,
|
|
370
|
+
"use-ref-dom",
|
|
371
|
+
"useRef(null) for DOM refs. In Pyreon, use createRef() from @pyreon/core.",
|
|
372
|
+
detectGetNodeText(ctx, node),
|
|
373
|
+
"createRef()",
|
|
374
|
+
true,
|
|
375
|
+
)
|
|
376
|
+
} else {
|
|
377
|
+
const initText = arg ? detectGetNodeText(ctx, arg) : "undefined"
|
|
378
|
+
detectDiag(
|
|
379
|
+
ctx,
|
|
380
|
+
node,
|
|
381
|
+
"use-ref-box",
|
|
382
|
+
"useRef for mutable values. In Pyreon, use signal() — it works the same way but is reactive.",
|
|
383
|
+
detectGetNodeText(ctx, node),
|
|
384
|
+
`signal(${initText})`,
|
|
385
|
+
true,
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function detectUseReducer(ctx: DetectContext, node: ts.CallExpression): void {
|
|
391
|
+
detectDiag(
|
|
392
|
+
ctx,
|
|
393
|
+
node,
|
|
394
|
+
"use-reducer",
|
|
395
|
+
"useReducer is a React API. In Pyreon, use signal() with update() for reducer patterns.",
|
|
396
|
+
detectGetNodeText(ctx, node),
|
|
397
|
+
"const state = signal(initialState)\nconst dispatch = (action) => state.update(s => reducer(s, action))",
|
|
398
|
+
false,
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function isCallToReactDot(callee: ts.Expression, methodName: string): boolean {
|
|
403
|
+
return (
|
|
404
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
405
|
+
ts.isIdentifier(callee.expression) &&
|
|
406
|
+
callee.expression.text === "React" &&
|
|
407
|
+
callee.name.text === methodName
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function detectMemoWrapper(ctx: DetectContext, node: ts.CallExpression): void {
|
|
412
|
+
const callee = node.expression
|
|
413
|
+
const isMemo =
|
|
414
|
+
(ts.isIdentifier(callee) && callee.text === "memo") || isCallToReactDot(callee, "memo")
|
|
415
|
+
|
|
416
|
+
if (isMemo) {
|
|
417
|
+
const inner = node.arguments[0]
|
|
418
|
+
const innerText = inner ? detectGetNodeText(ctx, inner) : "Component"
|
|
419
|
+
|
|
420
|
+
detectDiag(
|
|
421
|
+
ctx,
|
|
422
|
+
node,
|
|
423
|
+
"memo-wrapper",
|
|
424
|
+
"memo() is not needed in Pyreon. Components run once — only signals trigger updates, not re-renders.",
|
|
425
|
+
detectGetNodeText(ctx, node),
|
|
426
|
+
innerText,
|
|
427
|
+
true,
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function detectForwardRef(ctx: DetectContext, node: ts.CallExpression): void {
|
|
433
|
+
const callee = node.expression
|
|
434
|
+
const isForwardRef =
|
|
435
|
+
(ts.isIdentifier(callee) && callee.text === "forwardRef") ||
|
|
436
|
+
isCallToReactDot(callee, "forwardRef")
|
|
437
|
+
|
|
438
|
+
if (isForwardRef) {
|
|
439
|
+
detectDiag(
|
|
440
|
+
ctx,
|
|
441
|
+
node,
|
|
442
|
+
"forward-ref",
|
|
443
|
+
"forwardRef is not needed in Pyreon. Pass ref as a regular prop.",
|
|
444
|
+
detectGetNodeText(ctx, node),
|
|
445
|
+
"// Just pass ref as a prop:\nconst MyInput = (props) => <input ref={props.ref} />",
|
|
446
|
+
true,
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function detectJsxAttributes(ctx: DetectContext, node: ts.JsxAttribute): void {
|
|
452
|
+
const attrName = (node.name as ts.Identifier).text
|
|
453
|
+
|
|
454
|
+
if (attrName in JSX_ATTR_REWRITES) {
|
|
455
|
+
const htmlAttr = JSX_ATTR_REWRITES[attrName] as string
|
|
456
|
+
detectDiag(
|
|
457
|
+
ctx,
|
|
458
|
+
node,
|
|
459
|
+
attrName === "className" ? "class-name-prop" : "html-for-prop",
|
|
460
|
+
`'${attrName}' is a React JSX attribute. Use '${htmlAttr}' in Pyreon (standard HTML).`,
|
|
461
|
+
detectGetNodeText(ctx, node),
|
|
462
|
+
detectGetNodeText(ctx, node).replace(attrName, htmlAttr),
|
|
463
|
+
true,
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (attrName === "onChange") {
|
|
468
|
+
const jsxElement = findParentJsxElement(node)
|
|
469
|
+
if (jsxElement) {
|
|
470
|
+
const tagName = getJsxTagName(jsxElement)
|
|
471
|
+
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
|
|
472
|
+
detectDiag(
|
|
473
|
+
ctx,
|
|
474
|
+
node,
|
|
475
|
+
"on-change-input",
|
|
476
|
+
`onChange on <${tagName}> fires on blur in Pyreon (native DOM behavior). For keypress-by-keypress updates, use onInput.`,
|
|
477
|
+
detectGetNodeText(ctx, node),
|
|
478
|
+
detectGetNodeText(ctx, node).replace("onChange", "onInput"),
|
|
479
|
+
true,
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (attrName === "dangerouslySetInnerHTML") {
|
|
486
|
+
detectDiag(
|
|
487
|
+
ctx,
|
|
488
|
+
node,
|
|
489
|
+
"dangerously-set-inner-html",
|
|
490
|
+
"dangerouslySetInnerHTML is React-specific. Use innerHTML prop in Pyreon.",
|
|
491
|
+
detectGetNodeText(ctx, node),
|
|
492
|
+
"innerHTML={htmlString}",
|
|
493
|
+
true,
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function detectDotValueSignal(ctx: DetectContext, node: ts.PropertyAccessExpression): void {
|
|
499
|
+
const varName = (node.expression as ts.Identifier).text
|
|
500
|
+
const parent = node.parent
|
|
501
|
+
if (ts.isBinaryExpression(parent) && parent.left === node) {
|
|
502
|
+
detectDiag(
|
|
503
|
+
ctx,
|
|
504
|
+
node,
|
|
505
|
+
"dot-value-signal",
|
|
506
|
+
`'${varName}.value' looks like a Vue ref pattern. Pyreon signals are callable functions. Use ${varName}.set(x) to write.`,
|
|
507
|
+
detectGetNodeText(ctx, parent),
|
|
508
|
+
`${varName}.set(${detectGetNodeText(ctx, parent.right)})`,
|
|
509
|
+
false,
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function detectArrayMapJsx(ctx: DetectContext, node: ts.CallExpression): void {
|
|
515
|
+
const parent = node.parent
|
|
516
|
+
if (ts.isJsxExpression(parent)) {
|
|
517
|
+
const arrayExpr = detectGetNodeText(
|
|
518
|
+
ctx,
|
|
519
|
+
(node.expression as ts.PropertyAccessExpression).expression,
|
|
520
|
+
)
|
|
521
|
+
const mapCallback = node.arguments[0]
|
|
522
|
+
const mapCallbackText = mapCallback
|
|
523
|
+
? detectGetNodeText(ctx, mapCallback)
|
|
524
|
+
: "item => <li>{item}</li>"
|
|
525
|
+
|
|
526
|
+
detectDiag(
|
|
527
|
+
ctx,
|
|
528
|
+
node,
|
|
529
|
+
"array-map-jsx",
|
|
530
|
+
"Array.map() in JSX is not reactive in Pyreon. Use <For> for efficient keyed list rendering.",
|
|
531
|
+
detectGetNodeText(ctx, node),
|
|
532
|
+
`<For each={${arrayExpr}} by={item => item.id}>\n {${mapCallbackText}}\n</For>`,
|
|
533
|
+
false,
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function isCallToHook(node: ts.Node, hookName: string): node is ts.CallExpression {
|
|
539
|
+
return (
|
|
540
|
+
ts.isCallExpression(node) &&
|
|
541
|
+
ts.isIdentifier(node.expression) &&
|
|
542
|
+
node.expression.text === hookName
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function isCallToEffectHook(node: ts.Node): node is ts.CallExpression {
|
|
547
|
+
return (
|
|
548
|
+
ts.isCallExpression(node) &&
|
|
549
|
+
ts.isIdentifier(node.expression) &&
|
|
550
|
+
(node.expression.text === "useEffect" || node.expression.text === "useLayoutEffect")
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function isMapCallExpression(node: ts.Node): node is ts.CallExpression {
|
|
555
|
+
return (
|
|
556
|
+
ts.isCallExpression(node) &&
|
|
557
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
558
|
+
ts.isIdentifier(node.expression.name) &&
|
|
559
|
+
node.expression.name.text === "map"
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function isDotValueAccess(node: ts.Node): node is ts.PropertyAccessExpression {
|
|
564
|
+
return (
|
|
565
|
+
ts.isPropertyAccessExpression(node) &&
|
|
566
|
+
ts.isIdentifier(node.name) &&
|
|
567
|
+
node.name.text === "value" &&
|
|
568
|
+
ts.isIdentifier(node.expression)
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function detectVisitNode(ctx: DetectContext, node: ts.Node): void {
|
|
573
|
+
if (ts.isImportDeclaration(node)) detectImportDeclaration(ctx, node)
|
|
574
|
+
if (isCallToHook(node, "useState")) detectUseState(ctx, node)
|
|
575
|
+
if (isCallToEffectHook(node)) detectUseEffect(ctx, node)
|
|
576
|
+
if (isCallToHook(node, "useMemo")) detectUseMemo(ctx, node)
|
|
577
|
+
if (isCallToHook(node, "useCallback")) detectUseCallback(ctx, node)
|
|
578
|
+
if (isCallToHook(node, "useRef")) detectUseRef(ctx, node)
|
|
579
|
+
if (isCallToHook(node, "useReducer")) detectUseReducer(ctx, node)
|
|
580
|
+
if (ts.isCallExpression(node)) detectMemoWrapper(ctx, node)
|
|
581
|
+
if (ts.isCallExpression(node)) detectForwardRef(ctx, node)
|
|
582
|
+
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) detectJsxAttributes(ctx, node)
|
|
583
|
+
if (isDotValueAccess(node)) detectDotValueSignal(ctx, node)
|
|
584
|
+
if (isMapCallExpression(node)) detectArrayMapJsx(ctx, node)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function detectVisit(ctx: DetectContext, node: ts.Node): void {
|
|
588
|
+
ts.forEachChild(node, (child) => {
|
|
589
|
+
detectVisitNode(ctx, child)
|
|
590
|
+
detectVisit(ctx, child)
|
|
591
|
+
})
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function detectReactPatterns(code: string, filename = "input.tsx"): ReactDiagnostic[] {
|
|
595
|
+
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX)
|
|
596
|
+
const ctx: DetectContext = {
|
|
597
|
+
sf,
|
|
598
|
+
code,
|
|
599
|
+
diagnostics: [],
|
|
600
|
+
reactImportedHooks: new Set<string>(),
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
detectVisit(ctx, sf)
|
|
604
|
+
return ctx.diagnostics
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
608
|
+
// Migration (detection + auto-fix)
|
|
609
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
610
|
+
|
|
611
|
+
type Replacement = { start: number; end: number; text: string }
|
|
612
|
+
|
|
613
|
+
interface MigrateContext {
|
|
614
|
+
sf: ts.SourceFile
|
|
615
|
+
code: string
|
|
616
|
+
replacements: Replacement[]
|
|
617
|
+
changes: MigrationChange[]
|
|
618
|
+
pyreonImports: Map<string, Set<string>>
|
|
619
|
+
importsToRemove: Set<ts.ImportDeclaration>
|
|
620
|
+
specifierRewrites: Map<ts.ImportSpecifier, { name: string; from: string }>
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function migrateAddImport(ctx: MigrateContext, source: string, specifier: string): void {
|
|
624
|
+
if (!source || !specifier) return
|
|
625
|
+
let specs = ctx.pyreonImports.get(source)
|
|
626
|
+
if (!specs) {
|
|
627
|
+
specs = new Set()
|
|
628
|
+
ctx.pyreonImports.set(source, specs)
|
|
629
|
+
}
|
|
630
|
+
specs.add(specifier)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function migrateReplace(ctx: MigrateContext, node: ts.Node, text: string): void {
|
|
634
|
+
ctx.replacements.push({ start: node.getStart(ctx.sf), end: node.getEnd(), text })
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function migrateGetNodeText(ctx: MigrateContext, node: ts.Node): string {
|
|
638
|
+
return ctx.code.slice(node.getStart(ctx.sf), node.getEnd())
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function migrateGetLine(ctx: MigrateContext, node: ts.Node): number {
|
|
642
|
+
return ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf)).line + 1
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function migrateImportDeclaration(ctx: MigrateContext, node: ts.ImportDeclaration): void {
|
|
646
|
+
if (!node.moduleSpecifier) return
|
|
647
|
+
const source = (node.moduleSpecifier as ts.StringLiteral).text
|
|
648
|
+
if (!(source in IMPORT_REWRITES)) return
|
|
649
|
+
|
|
650
|
+
if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) {
|
|
651
|
+
for (const spec of node.importClause.namedBindings.elements) {
|
|
652
|
+
const name = spec.name.text
|
|
653
|
+
const rewrite = SPECIFIER_REWRITES[name]
|
|
654
|
+
if (rewrite) {
|
|
655
|
+
if (rewrite.name) {
|
|
656
|
+
migrateAddImport(ctx, rewrite.from, rewrite.name)
|
|
657
|
+
}
|
|
658
|
+
ctx.specifierRewrites.set(spec, rewrite)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
ctx.importsToRemove.add(node)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function migrateUseState(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
666
|
+
const parent = node.parent
|
|
667
|
+
if (
|
|
668
|
+
ts.isVariableDeclaration(parent) &&
|
|
669
|
+
parent.name &&
|
|
670
|
+
ts.isArrayBindingPattern(parent.name) &&
|
|
671
|
+
parent.name.elements.length >= 1
|
|
672
|
+
) {
|
|
673
|
+
const firstEl = parent.name.elements[0]
|
|
674
|
+
const valueName =
|
|
675
|
+
firstEl && ts.isBindingElement(firstEl) ? (firstEl.name as ts.Identifier).text : "value"
|
|
676
|
+
const initArg = node.arguments[0] ? migrateGetNodeText(ctx, node.arguments[0]) : "undefined"
|
|
677
|
+
|
|
678
|
+
const declStart = parent.getStart(ctx.sf)
|
|
679
|
+
const declEnd = parent.getEnd()
|
|
680
|
+
ctx.replacements.push({
|
|
681
|
+
start: declStart,
|
|
682
|
+
end: declEnd,
|
|
683
|
+
text: `${valueName} = signal(${initArg})`,
|
|
684
|
+
})
|
|
685
|
+
migrateAddImport(ctx, "@pyreon/reactivity", "signal")
|
|
686
|
+
ctx.changes.push({
|
|
687
|
+
type: "replace",
|
|
688
|
+
line: migrateGetLine(ctx, node),
|
|
689
|
+
description: `useState → signal: ${valueName}`,
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function migrateUseEffect(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
695
|
+
const depsArg = node.arguments[1]
|
|
696
|
+
const callbackArg = node.arguments[0]
|
|
697
|
+
const hookName = (node.expression as ts.Identifier).text
|
|
698
|
+
|
|
699
|
+
if (
|
|
700
|
+
depsArg &&
|
|
701
|
+
ts.isArrayLiteralExpression(depsArg) &&
|
|
702
|
+
depsArg.elements.length === 0 &&
|
|
703
|
+
callbackArg
|
|
704
|
+
) {
|
|
705
|
+
const callbackText = migrateGetNodeText(ctx, callbackArg)
|
|
706
|
+
migrateReplace(ctx, node, `onMount(${callbackText})`)
|
|
707
|
+
migrateAddImport(ctx, "@pyreon/core", "onMount")
|
|
708
|
+
ctx.changes.push({
|
|
709
|
+
type: "replace",
|
|
710
|
+
line: migrateGetLine(ctx, node),
|
|
711
|
+
description: `${hookName}(fn, []) → onMount(fn)`,
|
|
712
|
+
})
|
|
713
|
+
} else if (callbackArg) {
|
|
714
|
+
const callbackText = migrateGetNodeText(ctx, callbackArg)
|
|
715
|
+
migrateReplace(ctx, node, `effect(${callbackText})`)
|
|
716
|
+
migrateAddImport(ctx, "@pyreon/reactivity", "effect")
|
|
717
|
+
ctx.changes.push({
|
|
718
|
+
type: "replace",
|
|
719
|
+
line: migrateGetLine(ctx, node),
|
|
720
|
+
description: `${hookName} → effect (auto-tracks deps)`,
|
|
721
|
+
})
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function migrateUseMemo(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
726
|
+
const computeFn = node.arguments[0]
|
|
727
|
+
if (computeFn) {
|
|
728
|
+
migrateReplace(ctx, node, `computed(${migrateGetNodeText(ctx, computeFn)})`)
|
|
729
|
+
migrateAddImport(ctx, "@pyreon/reactivity", "computed")
|
|
730
|
+
ctx.changes.push({
|
|
731
|
+
type: "replace",
|
|
732
|
+
line: migrateGetLine(ctx, node),
|
|
733
|
+
description: "useMemo → computed (auto-tracks deps)",
|
|
734
|
+
})
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function migrateUseCallback(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
739
|
+
const callbackFn = node.arguments[0]
|
|
740
|
+
if (callbackFn) {
|
|
741
|
+
migrateReplace(ctx, node, migrateGetNodeText(ctx, callbackFn))
|
|
742
|
+
ctx.changes.push({
|
|
743
|
+
type: "replace",
|
|
744
|
+
line: migrateGetLine(ctx, node),
|
|
745
|
+
description: "useCallback → plain function (not needed in Pyreon)",
|
|
746
|
+
})
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function migrateUseRef(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
751
|
+
const arg = node.arguments[0]
|
|
752
|
+
const isNullInit =
|
|
753
|
+
arg &&
|
|
754
|
+
(arg.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(arg) && arg.text === "undefined"))
|
|
755
|
+
|
|
756
|
+
if (isNullInit || !arg) {
|
|
757
|
+
migrateReplace(ctx, node, "createRef()")
|
|
758
|
+
migrateAddImport(ctx, "@pyreon/core", "createRef")
|
|
759
|
+
ctx.changes.push({
|
|
760
|
+
type: "replace",
|
|
761
|
+
line: migrateGetLine(ctx, node),
|
|
762
|
+
description: "useRef(null) → createRef()",
|
|
763
|
+
})
|
|
764
|
+
} else {
|
|
765
|
+
migrateReplace(ctx, node, `signal(${migrateGetNodeText(ctx, arg)})`)
|
|
766
|
+
migrateAddImport(ctx, "@pyreon/reactivity", "signal")
|
|
767
|
+
ctx.changes.push({
|
|
768
|
+
type: "replace",
|
|
769
|
+
line: migrateGetLine(ctx, node),
|
|
770
|
+
description: "useRef(value) → signal(value)",
|
|
771
|
+
})
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function migrateMemoWrapper(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
776
|
+
const callee = node.expression
|
|
777
|
+
const isMemo =
|
|
778
|
+
(ts.isIdentifier(callee) && callee.text === "memo") || isCallToReactDot(callee, "memo")
|
|
779
|
+
|
|
780
|
+
if (isMemo && node.arguments[0]) {
|
|
781
|
+
migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]))
|
|
782
|
+
ctx.changes.push({
|
|
783
|
+
type: "remove",
|
|
784
|
+
line: migrateGetLine(ctx, node),
|
|
785
|
+
description: "Removed memo() wrapper (not needed in Pyreon)",
|
|
786
|
+
})
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function migrateForwardRef(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
791
|
+
const callee = node.expression
|
|
792
|
+
const isForwardRef =
|
|
793
|
+
(ts.isIdentifier(callee) && callee.text === "forwardRef") ||
|
|
794
|
+
isCallToReactDot(callee, "forwardRef")
|
|
795
|
+
|
|
796
|
+
if (isForwardRef && node.arguments[0]) {
|
|
797
|
+
migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]))
|
|
798
|
+
ctx.changes.push({
|
|
799
|
+
type: "remove",
|
|
800
|
+
line: migrateGetLine(ctx, node),
|
|
801
|
+
description: "Removed forwardRef wrapper (pass ref as normal prop in Pyreon)",
|
|
802
|
+
})
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function migrateJsxAttributes(ctx: MigrateContext, node: ts.JsxAttribute): void {
|
|
807
|
+
const attrName = (node.name as ts.Identifier).text
|
|
808
|
+
|
|
809
|
+
if (attrName in JSX_ATTR_REWRITES) {
|
|
810
|
+
const htmlAttr = JSX_ATTR_REWRITES[attrName] as string
|
|
811
|
+
ctx.replacements.push({
|
|
812
|
+
start: node.name.getStart(ctx.sf),
|
|
813
|
+
end: node.name.getEnd(),
|
|
814
|
+
text: htmlAttr,
|
|
815
|
+
})
|
|
816
|
+
ctx.changes.push({
|
|
817
|
+
type: "replace",
|
|
818
|
+
line: migrateGetLine(ctx, node),
|
|
819
|
+
description: `${attrName} → ${htmlAttr}`,
|
|
820
|
+
})
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (attrName === "onChange") {
|
|
824
|
+
const jsxElement = findParentJsxElement(node)
|
|
825
|
+
if (jsxElement) {
|
|
826
|
+
const tagName = getJsxTagName(jsxElement)
|
|
827
|
+
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
|
|
828
|
+
ctx.replacements.push({
|
|
829
|
+
start: node.name.getStart(ctx.sf),
|
|
830
|
+
end: node.name.getEnd(),
|
|
831
|
+
text: "onInput",
|
|
832
|
+
})
|
|
833
|
+
ctx.changes.push({
|
|
834
|
+
type: "replace",
|
|
835
|
+
line: migrateGetLine(ctx, node),
|
|
836
|
+
description: `onChange on <${tagName}> → onInput (native DOM events)`,
|
|
837
|
+
})
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (attrName === "dangerouslySetInnerHTML") {
|
|
843
|
+
migrateDangerouslySetInnerHTML(ctx, node)
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function migrateDangerouslySetInnerHTML(ctx: MigrateContext, node: ts.JsxAttribute): void {
|
|
848
|
+
if (!node.initializer || !ts.isJsxExpression(node.initializer) || !node.initializer.expression) {
|
|
849
|
+
return
|
|
850
|
+
}
|
|
851
|
+
const expr = node.initializer.expression
|
|
852
|
+
if (!ts.isObjectLiteralExpression(expr)) return
|
|
853
|
+
|
|
854
|
+
const htmlProp = expr.properties.find(
|
|
855
|
+
(p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "__html",
|
|
856
|
+
) as ts.PropertyAssignment | undefined
|
|
857
|
+
|
|
858
|
+
if (htmlProp) {
|
|
859
|
+
const valueText = migrateGetNodeText(ctx, htmlProp.initializer)
|
|
860
|
+
migrateReplace(ctx, node, `innerHTML={${valueText}}`)
|
|
861
|
+
ctx.changes.push({
|
|
862
|
+
type: "replace",
|
|
863
|
+
line: migrateGetLine(ctx, node),
|
|
864
|
+
description: "dangerouslySetInnerHTML → innerHTML",
|
|
865
|
+
})
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function applyReplacements(code: string, ctx: MigrateContext): string {
|
|
870
|
+
// Remove React import declarations
|
|
871
|
+
for (const imp of ctx.importsToRemove) {
|
|
872
|
+
ctx.replacements.push({ start: imp.getStart(ctx.sf), end: imp.getEnd(), text: "" })
|
|
873
|
+
ctx.changes.push({
|
|
874
|
+
type: "remove",
|
|
875
|
+
line: ctx.sf.getLineAndCharacterOfPosition(imp.getStart(ctx.sf)).line + 1,
|
|
876
|
+
description: "Removed React import",
|
|
877
|
+
})
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Sort replacements by position (descending) so we can apply from end to start
|
|
881
|
+
ctx.replacements.sort((a, b) => b.start - a.start)
|
|
882
|
+
|
|
883
|
+
// Deduplicate overlapping replacements (keep the outermost / first added)
|
|
884
|
+
const applied = new Set<string>()
|
|
885
|
+
const deduped: Replacement[] = []
|
|
886
|
+
for (const r of ctx.replacements) {
|
|
887
|
+
const key = `${r.start}:${r.end}`
|
|
888
|
+
let overlaps = false
|
|
889
|
+
for (const d of deduped) {
|
|
890
|
+
if (r.start < d.end && r.end > d.start) {
|
|
891
|
+
overlaps = true
|
|
892
|
+
break
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (!overlaps && !applied.has(key)) {
|
|
896
|
+
applied.add(key)
|
|
897
|
+
deduped.push(r)
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
let result = code
|
|
902
|
+
for (const r of deduped) {
|
|
903
|
+
result = result.slice(0, r.start) + r.text + result.slice(r.end)
|
|
904
|
+
}
|
|
905
|
+
return result
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function insertPyreonImports(code: string, pyreonImports: Map<string, Set<string>>): string {
|
|
909
|
+
if (pyreonImports.size === 0) return code
|
|
910
|
+
|
|
911
|
+
const importLines: string[] = []
|
|
912
|
+
const sorted = [...pyreonImports.entries()].sort(([a], [b]) => a.localeCompare(b))
|
|
913
|
+
for (const [source, specs] of sorted) {
|
|
914
|
+
const specList = [...specs].sort().join(", ")
|
|
915
|
+
importLines.push(`import { ${specList} } from "${source}"`)
|
|
916
|
+
}
|
|
917
|
+
const importBlock = importLines.join("\n")
|
|
918
|
+
|
|
919
|
+
const lastImportEnd = findLastImportEnd(code)
|
|
920
|
+
if (lastImportEnd > 0) {
|
|
921
|
+
return `${code.slice(0, lastImportEnd)}\n${importBlock}${code.slice(lastImportEnd)}`
|
|
922
|
+
}
|
|
923
|
+
return `${importBlock}\n\n${code}`
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function migrateVisitNode(ctx: MigrateContext, node: ts.Node): void {
|
|
927
|
+
if (ts.isImportDeclaration(node)) migrateImportDeclaration(ctx, node)
|
|
928
|
+
if (isCallToHook(node, "useState")) migrateUseState(ctx, node)
|
|
929
|
+
if (isCallToEffectHook(node)) migrateUseEffect(ctx, node)
|
|
930
|
+
if (isCallToHook(node, "useMemo")) migrateUseMemo(ctx, node)
|
|
931
|
+
if (isCallToHook(node, "useCallback")) migrateUseCallback(ctx, node)
|
|
932
|
+
if (isCallToHook(node, "useRef")) migrateUseRef(ctx, node)
|
|
933
|
+
if (ts.isCallExpression(node)) migrateMemoWrapper(ctx, node)
|
|
934
|
+
if (ts.isCallExpression(node)) migrateForwardRef(ctx, node)
|
|
935
|
+
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) migrateJsxAttributes(ctx, node)
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function migrateVisit(ctx: MigrateContext, node: ts.Node): void {
|
|
939
|
+
ts.forEachChild(node, (child) => {
|
|
940
|
+
migrateVisitNode(ctx, child)
|
|
941
|
+
migrateVisit(ctx, child)
|
|
942
|
+
})
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export function migrateReactCode(code: string, filename = "input.tsx"): MigrationResult {
|
|
946
|
+
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX)
|
|
947
|
+
const diagnostics = detectReactPatterns(code, filename)
|
|
948
|
+
|
|
949
|
+
const ctx: MigrateContext = {
|
|
950
|
+
sf,
|
|
951
|
+
code,
|
|
952
|
+
replacements: [],
|
|
953
|
+
changes: [],
|
|
954
|
+
pyreonImports: new Map(),
|
|
955
|
+
importsToRemove: new Set(),
|
|
956
|
+
specifierRewrites: new Map(),
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
migrateVisit(ctx, sf)
|
|
960
|
+
|
|
961
|
+
let result = applyReplacements(code, ctx)
|
|
962
|
+
result = insertPyreonImports(result, ctx.pyreonImports)
|
|
963
|
+
|
|
964
|
+
// Clean up empty lines from removed imports
|
|
965
|
+
result = result.replace(/\n{3,}/g, "\n\n")
|
|
966
|
+
|
|
967
|
+
return { code: result, diagnostics, changes: ctx.changes }
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
971
|
+
// Helpers
|
|
972
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
973
|
+
|
|
974
|
+
function findParentJsxElement(
|
|
975
|
+
node: ts.Node,
|
|
976
|
+
): ts.JsxOpeningElement | ts.JsxSelfClosingElement | null {
|
|
977
|
+
let current = node.parent
|
|
978
|
+
while (current) {
|
|
979
|
+
if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current)) {
|
|
980
|
+
return current
|
|
981
|
+
}
|
|
982
|
+
// Don't cross component boundaries
|
|
983
|
+
if (ts.isJsxElement(current)) {
|
|
984
|
+
return current.openingElement
|
|
985
|
+
}
|
|
986
|
+
if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) {
|
|
987
|
+
return null
|
|
988
|
+
}
|
|
989
|
+
current = current.parent
|
|
990
|
+
}
|
|
991
|
+
return null
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function getJsxTagName(node: ts.JsxOpeningElement | ts.JsxSelfClosingElement): string {
|
|
995
|
+
const tagName = node.tagName
|
|
996
|
+
if (ts.isIdentifier(tagName)) {
|
|
997
|
+
return tagName.text
|
|
998
|
+
}
|
|
999
|
+
return ""
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function findLastImportEnd(code: string): number {
|
|
1003
|
+
const importRe = /^import\s.+$/gm
|
|
1004
|
+
let lastEnd = 0
|
|
1005
|
+
let match: RegExpExecArray | null
|
|
1006
|
+
while (true) {
|
|
1007
|
+
match = importRe.exec(code)
|
|
1008
|
+
if (!match) break
|
|
1009
|
+
lastEnd = match.index + match[0].length
|
|
1010
|
+
}
|
|
1011
|
+
return lastEnd
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1015
|
+
// Quick scan (regex-based, for fast pre-filtering)
|
|
1016
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1017
|
+
|
|
1018
|
+
/** Fast regex check — returns true if code likely contains React patterns worth analyzing */
|
|
1019
|
+
export function hasReactPatterns(code: string): boolean {
|
|
1020
|
+
return (
|
|
1021
|
+
/\bfrom\s+['"]react/.test(code) ||
|
|
1022
|
+
/\bfrom\s+['"]react-dom/.test(code) ||
|
|
1023
|
+
/\bfrom\s+['"]react-router/.test(code) ||
|
|
1024
|
+
/\buseState\s*[<(]/.test(code) ||
|
|
1025
|
+
/\buseEffect\s*\(/.test(code) ||
|
|
1026
|
+
/\buseMemo\s*\(/.test(code) ||
|
|
1027
|
+
/\buseCallback\s*\(/.test(code) ||
|
|
1028
|
+
/\buseRef\s*[<(]/.test(code) ||
|
|
1029
|
+
/\buseReducer\s*[<(]/.test(code) ||
|
|
1030
|
+
/\bReact\.memo\b/.test(code) ||
|
|
1031
|
+
/\bforwardRef\s*[<(]/.test(code) ||
|
|
1032
|
+
/\bclassName[=\s]/.test(code) ||
|
|
1033
|
+
/\bhtmlFor[=\s]/.test(code) ||
|
|
1034
|
+
/\.value\s*=/.test(code)
|
|
1035
|
+
)
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1039
|
+
// Error pattern database (for MCP diagnose tool)
|
|
1040
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1041
|
+
|
|
1042
|
+
export interface ErrorDiagnosis {
|
|
1043
|
+
cause: string
|
|
1044
|
+
fix: string
|
|
1045
|
+
fixCode?: string | undefined
|
|
1046
|
+
related?: string | undefined
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
interface ErrorPattern {
|
|
1050
|
+
pattern: RegExp
|
|
1051
|
+
diagnose: (match: RegExpMatchArray) => ErrorDiagnosis
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const ERROR_PATTERNS: ErrorPattern[] = [
|
|
1055
|
+
{
|
|
1056
|
+
pattern: /Cannot read properties of undefined \(reading '(set|update|peek|subscribe)'\)/,
|
|
1057
|
+
diagnose: (m) => ({
|
|
1058
|
+
cause: `Calling .${m[1]}() on undefined. The signal variable is likely out of scope, misspelled, or not yet initialized.`,
|
|
1059
|
+
fix: "Check that the signal is defined and in scope. Signals must be created with signal() before use.",
|
|
1060
|
+
fixCode: `const mySignal = signal(initialValue)\nmySignal.${m[1]}(newValue)`,
|
|
1061
|
+
}),
|
|
1062
|
+
},
|
|
1063
|
+
{
|
|
1064
|
+
pattern: /(\w+) is not a function/,
|
|
1065
|
+
diagnose: (m) => ({
|
|
1066
|
+
cause: `'${m[1]}' is not callable. If this is a signal, you need to call it: ${m[1]}()`,
|
|
1067
|
+
fix: "Pyreon signals are callable functions. Read: signal(), Write: signal.set(value)",
|
|
1068
|
+
fixCode: `// Read value:\nconst value = ${m[1]}()\n// Set value:\n${m[1]}.set(newValue)`,
|
|
1069
|
+
}),
|
|
1070
|
+
},
|
|
1071
|
+
{
|
|
1072
|
+
pattern: /Cannot find module '(@pyreon\/\w[\w-]*)'/,
|
|
1073
|
+
diagnose: (m) => ({
|
|
1074
|
+
cause: `Package ${m[1]} is not installed.`,
|
|
1075
|
+
fix: `Run: bun add ${m[1]}`,
|
|
1076
|
+
fixCode: `bun add ${m[1]}`,
|
|
1077
|
+
}),
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
pattern: /Cannot find module 'react'/,
|
|
1081
|
+
diagnose: () => ({
|
|
1082
|
+
cause: "Importing from 'react' in a Pyreon project.",
|
|
1083
|
+
fix: "Replace React imports with Pyreon equivalents.",
|
|
1084
|
+
fixCode:
|
|
1085
|
+
'// Instead of:\nimport { useState } from "react"\n// Use:\nimport { signal } from "@pyreon/reactivity"',
|
|
1086
|
+
}),
|
|
1087
|
+
},
|
|
1088
|
+
{
|
|
1089
|
+
pattern: /Property '(\w+)' does not exist on type 'Signal<\w+>'/,
|
|
1090
|
+
diagnose: (m) => ({
|
|
1091
|
+
cause: `Accessing .${m[1]} on a signal. Pyreon signals don't have a .${m[1]} property.`,
|
|
1092
|
+
fix:
|
|
1093
|
+
m[1] === "value"
|
|
1094
|
+
? "Pyreon signals are callable functions, not .value getters. Call signal() to read, signal.set() to write."
|
|
1095
|
+
: `Signals have these methods: .set(), .update(), .peek(), .subscribe(). '${m[1]}' is not one of them.`,
|
|
1096
|
+
fixCode:
|
|
1097
|
+
m[1] === "value" ? "// Read: mySignal()\n// Write: mySignal.set(newValue)" : undefined,
|
|
1098
|
+
}),
|
|
1099
|
+
},
|
|
1100
|
+
{
|
|
1101
|
+
pattern: /Type '(\w+)' is not assignable to type 'VNode'/,
|
|
1102
|
+
diagnose: (m) => ({
|
|
1103
|
+
cause: `Component returned ${m[1]} instead of VNode. Components must return JSX, null, or a string.`,
|
|
1104
|
+
fix: "Make sure your component returns a JSX element, null, or a string.",
|
|
1105
|
+
fixCode: "const MyComponent = (props) => {\n return <div>{props.children}</div>\n}",
|
|
1106
|
+
}),
|
|
1107
|
+
},
|
|
1108
|
+
{
|
|
1109
|
+
pattern: /onMount callback must return/,
|
|
1110
|
+
diagnose: () => ({
|
|
1111
|
+
cause: "onMount expects a return of CleanupFn | undefined, not void.",
|
|
1112
|
+
fix: "Return undefined explicitly, or return a cleanup function.",
|
|
1113
|
+
fixCode: "onMount(() => {\n // setup code\n return undefined\n})",
|
|
1114
|
+
}),
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
pattern: /Expected 'by' prop on <For>/,
|
|
1118
|
+
diagnose: () => ({
|
|
1119
|
+
cause: "<For> requires a 'by' prop for efficient keyed reconciliation.",
|
|
1120
|
+
fix: "Add a by prop that returns a unique key for each item.",
|
|
1121
|
+
fixCode:
|
|
1122
|
+
"<For each={items()} by={item => item.id}>\n {item => <li>{item.name}</li>}\n</For>",
|
|
1123
|
+
}),
|
|
1124
|
+
},
|
|
1125
|
+
{
|
|
1126
|
+
pattern: /useHook.*outside.*component/i,
|
|
1127
|
+
diagnose: () => ({
|
|
1128
|
+
cause:
|
|
1129
|
+
"Hook called outside a component function. Pyreon hooks must be called during component setup.",
|
|
1130
|
+
fix: "Move the hook call inside a component function body.",
|
|
1131
|
+
}),
|
|
1132
|
+
},
|
|
1133
|
+
{
|
|
1134
|
+
pattern: /Hydration mismatch/,
|
|
1135
|
+
diagnose: () => ({
|
|
1136
|
+
cause: "Server-rendered HTML doesn't match client-rendered output.",
|
|
1137
|
+
fix: "Ensure SSR and client render the same initial content. Check for browser-only APIs (window, document) in SSR code.",
|
|
1138
|
+
related: "Use typeof window !== 'undefined' checks or onMount() for client-only code.",
|
|
1139
|
+
}),
|
|
1140
|
+
},
|
|
1141
|
+
]
|
|
1142
|
+
|
|
1143
|
+
/** Diagnose an error message and return structured fix information */
|
|
1144
|
+
export function diagnoseError(error: string): ErrorDiagnosis | null {
|
|
1145
|
+
for (const { pattern, diagnose } of ERROR_PATTERNS) {
|
|
1146
|
+
const match = error.match(pattern)
|
|
1147
|
+
if (match) {
|
|
1148
|
+
return diagnose(match)
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return null
|
|
1152
|
+
}
|