@pyreon/compiler 0.24.4 → 0.24.6

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.
Files changed (64) hide show
  1. package/package.json +11 -13
  2. package/src/defer-inline.ts +0 -686
  3. package/src/event-names.ts +0 -65
  4. package/src/index.ts +0 -61
  5. package/src/island-audit.ts +0 -675
  6. package/src/jsx.ts +0 -2792
  7. package/src/load-native.ts +0 -156
  8. package/src/lpih.ts +0 -270
  9. package/src/manifest.ts +0 -280
  10. package/src/project-scanner.ts +0 -214
  11. package/src/pyreon-intercept.ts +0 -1029
  12. package/src/react-intercept.ts +0 -1217
  13. package/src/reactivity-lens.ts +0 -190
  14. package/src/ssg-audit.ts +0 -513
  15. package/src/test-audit.ts +0 -435
  16. package/src/tests/backend-parity-r7-r9.test.ts +0 -91
  17. package/src/tests/backend-prop-derived-callback-divergence.test.ts +0 -74
  18. package/src/tests/collapse-bail-census.test.ts +0 -330
  19. package/src/tests/collapse-key-source-hygiene.test.ts +0 -88
  20. package/src/tests/component-child-no-wrap.test.ts +0 -204
  21. package/src/tests/defer-inline.test.ts +0 -387
  22. package/src/tests/depth-stress.test.ts +0 -16
  23. package/src/tests/detector-tag-consistency.test.ts +0 -101
  24. package/src/tests/dynamic-collapse-detector.test.ts +0 -164
  25. package/src/tests/dynamic-collapse-emit.test.ts +0 -192
  26. package/src/tests/dynamic-collapse-scan.test.ts +0 -111
  27. package/src/tests/element-valued-const-child.test.ts +0 -61
  28. package/src/tests/falsy-child-characterization.test.ts +0 -48
  29. package/src/tests/island-audit.test.ts +0 -524
  30. package/src/tests/jsx.test.ts +0 -2908
  31. package/src/tests/load-native.test.ts +0 -53
  32. package/src/tests/lpih.test.ts +0 -404
  33. package/src/tests/malformed-input-resilience.test.ts +0 -50
  34. package/src/tests/manifest-snapshot.test.ts +0 -55
  35. package/src/tests/native-equivalence.test.ts +0 -924
  36. package/src/tests/partial-collapse-detector.test.ts +0 -121
  37. package/src/tests/partial-collapse-emit.test.ts +0 -104
  38. package/src/tests/partial-collapse-robustness.test.ts +0 -53
  39. package/src/tests/project-scanner.test.ts +0 -269
  40. package/src/tests/prop-derived-shadow.test.ts +0 -96
  41. package/src/tests/pure-call-reactive-args.test.ts +0 -50
  42. package/src/tests/pyreon-intercept.test.ts +0 -816
  43. package/src/tests/r13-callback-stmt-equivalence.test.ts +0 -58
  44. package/src/tests/r14-ssr-mode-parity.test.ts +0 -51
  45. package/src/tests/r15-elemconst-propderived.test.ts +0 -47
  46. package/src/tests/r19-defer-inline-robust.test.ts +0 -54
  47. package/src/tests/r20-backend-equivalence-sweep.test.ts +0 -50
  48. package/src/tests/react-intercept.test.ts +0 -1104
  49. package/src/tests/reactivity-lens.test.ts +0 -170
  50. package/src/tests/rocketstyle-collapse.test.ts +0 -208
  51. package/src/tests/runtime/control-flow.test.ts +0 -159
  52. package/src/tests/runtime/dom-properties.test.ts +0 -138
  53. package/src/tests/runtime/events.test.ts +0 -301
  54. package/src/tests/runtime/harness.ts +0 -94
  55. package/src/tests/runtime/pr-352-shapes.test.ts +0 -121
  56. package/src/tests/runtime/reactive-props.test.ts +0 -81
  57. package/src/tests/runtime/signals.test.ts +0 -129
  58. package/src/tests/runtime/whitespace.test.ts +0 -106
  59. package/src/tests/signal-autocall-shadow.test.ts +0 -86
  60. package/src/tests/sourcemap-fidelity.test.ts +0 -77
  61. package/src/tests/ssg-audit.test.ts +0 -402
  62. package/src/tests/static-text-baking.test.ts +0 -64
  63. package/src/tests/test-audit.test.ts +0 -549
  64. package/src/tests/transform-state-isolation.test.ts +0 -49
@@ -1,1217 +0,0 @@
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
- * Identifiers bound to a signal factory (`const x = signal(...)` /
186
- * `computed(...)` / `useSignal(...)` / `createSignal(...)`) anywhere in the
187
- * file. Only `const` declarations are tracked — `let`/`var` may be
188
- * reassigned to a non-signal value, so a `.value` write through them
189
- * wouldn't be a reliable signal-write. The collection is scope-blind for
190
- * the same reason `collectSignalBindings` in `pyreon-intercept.ts` is — the
191
- * rare shadow-a-signal-name case is acceptable noise; the precision win is
192
- * eliminating the `input.value = ''` / `cell.value = x` / `o.value = y`
193
- * false-positive class entirely.
194
- */
195
- signalBindings: Set<string>
196
- }
197
-
198
- /**
199
- * Collects every identifier bound to a signal factory call. Mirrors
200
- * `pyreon-intercept.ts:collectSignalBindings` but also recognises the
201
- * `useSignal` / `createSignal` aliases (Solid / hook-style) so the React
202
- * detector — which runs on cross-framework migration input — doesn't miss a
203
- * genuine `mySignal.value = x` written by someone coming from Solid/Vue.
204
- */
205
- function collectDetectSignalBindings(sf: ts.SourceFile): Set<string> {
206
- const names = new Set<string>()
207
- function isSignalFactoryCall(init: ts.Expression | undefined): boolean {
208
- if (!init || !ts.isCallExpression(init)) return false
209
- const callee = init.expression
210
- if (!ts.isIdentifier(callee)) return false
211
- return (
212
- callee.text === 'signal' ||
213
- callee.text === 'computed' ||
214
- callee.text === 'useSignal' ||
215
- callee.text === 'createSignal'
216
- )
217
- }
218
- function walk(node: ts.Node): void {
219
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
220
- const list = node.parent
221
- if (
222
- ts.isVariableDeclarationList(list) &&
223
- (list.flags & ts.NodeFlags.Const) !== 0 &&
224
- isSignalFactoryCall(node.initializer)
225
- ) {
226
- names.add(node.name.text)
227
- }
228
- }
229
- ts.forEachChild(node, walk)
230
- }
231
- walk(sf)
232
- return names
233
- }
234
-
235
- function detectGetNodeText(ctx: DetectContext, node: ts.Node): string {
236
- return ctx.code.slice(node.getStart(ctx.sf), node.getEnd())
237
- }
238
-
239
- function detectDiag(
240
- ctx: DetectContext,
241
- node: ts.Node,
242
- diagCode: ReactDiagnosticCode,
243
- message: string,
244
- current: string,
245
- suggested: string,
246
- fixable: boolean,
247
- ): void {
248
- const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf))
249
- ctx.diagnostics.push({
250
- code: diagCode,
251
- message,
252
- line: line + 1,
253
- column: character,
254
- current: current.trim(),
255
- suggested: suggested.trim(),
256
- fixable,
257
- })
258
- }
259
-
260
- function detectImportDeclaration(ctx: DetectContext, node: ts.ImportDeclaration): void {
261
- if (!node.moduleSpecifier) return
262
- const source = (node.moduleSpecifier as ts.StringLiteral).text
263
- const pyreonSource = IMPORT_REWRITES[source]
264
-
265
- if (pyreonSource !== undefined) {
266
- if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) {
267
- for (const spec of node.importClause.namedBindings.elements) {
268
- ctx.reactImportedHooks.add(spec.name.text)
269
- }
270
- }
271
-
272
- const diagCode = source.startsWith('react-router')
273
- ? 'react-router-import'
274
- : source.startsWith('react-dom')
275
- ? 'react-dom-import'
276
- : 'react-import'
277
-
278
- detectDiag(
279
- ctx,
280
- node,
281
- diagCode,
282
- `Import from '${source}' is a React package. Use Pyreon equivalent.`,
283
- detectGetNodeText(ctx, node),
284
- pyreonSource
285
- ? `import { ... } from "${pyreonSource}"`
286
- : 'Remove this import — not needed in Pyreon',
287
- true,
288
- )
289
- }
290
- }
291
-
292
- function detectUseState(ctx: DetectContext, node: ts.CallExpression): void {
293
- const parent = node.parent
294
- if (
295
- ts.isVariableDeclaration(parent) &&
296
- parent.name &&
297
- ts.isArrayBindingPattern(parent.name) &&
298
- parent.name.elements.length >= 1
299
- ) {
300
- const firstEl = parent.name.elements[0]
301
- const valueName =
302
- firstEl && ts.isBindingElement(firstEl) ? (firstEl.name as ts.Identifier).text : 'value'
303
- const initArg = node.arguments[0] ? detectGetNodeText(ctx, node.arguments[0]) : 'undefined'
304
-
305
- detectDiag(
306
- ctx,
307
- node,
308
- 'use-state',
309
- `useState is a React API. In Pyreon, use signal(). Read: ${valueName}(), Write: ${valueName}.set(x)`,
310
- detectGetNodeText(ctx, parent),
311
- `${valueName} = signal(${initArg})`,
312
- true,
313
- )
314
- } else {
315
- detectDiag(
316
- ctx,
317
- node,
318
- 'use-state',
319
- 'useState is a React API. In Pyreon, use signal().',
320
- detectGetNodeText(ctx, node),
321
- 'signal(initialValue)',
322
- true,
323
- )
324
- }
325
- }
326
-
327
- function callbackHasCleanup(callbackArg: ts.Expression): boolean {
328
- if (!ts.isArrowFunction(callbackArg) && !ts.isFunctionExpression(callbackArg)) return false
329
- const body = callbackArg.body
330
- if (!ts.isBlock(body)) return false
331
- for (const stmt of body.statements) {
332
- if (ts.isReturnStatement(stmt) && stmt.expression) return true
333
- }
334
- return false
335
- }
336
-
337
- function detectUseEffect(ctx: DetectContext, node: ts.CallExpression): void {
338
- const hookName = (node.expression as ts.Identifier).text
339
- const depsArg = node.arguments[1]
340
- const callbackArg = node.arguments[0]
341
-
342
- if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0) {
343
- const hasCleanup = callbackArg ? callbackHasCleanup(callbackArg) : false
344
-
345
- detectDiag(
346
- ctx,
347
- node,
348
- 'use-effect-mount',
349
- `${hookName} with empty deps [] means "run once on mount". Use onMount() in Pyreon.`,
350
- detectGetNodeText(ctx, node),
351
- hasCleanup
352
- ? 'onMount(() => {\n // setup...\n return () => { /* cleanup */ }\n})'
353
- : 'onMount(() => {\n // setup...\n})',
354
- true,
355
- )
356
- } else if (depsArg && ts.isArrayLiteralExpression(depsArg)) {
357
- detectDiag(
358
- ctx,
359
- node,
360
- 'use-effect-deps',
361
- `${hookName} with dependency array. In Pyreon, effect() auto-tracks dependencies — no array needed.`,
362
- detectGetNodeText(ctx, node),
363
- 'effect(() => {\n // reads are auto-tracked\n})',
364
- true,
365
- )
366
- } else if (!depsArg) {
367
- detectDiag(
368
- ctx,
369
- node,
370
- 'use-effect-no-deps',
371
- `${hookName} with no dependency array. In Pyreon, use effect() — it auto-tracks signal reads.`,
372
- detectGetNodeText(ctx, node),
373
- 'effect(() => {\n // runs when accessed signals change\n})',
374
- true,
375
- )
376
- }
377
- }
378
-
379
- function detectUseMemo(ctx: DetectContext, node: ts.CallExpression): void {
380
- const computeFn = node.arguments[0]
381
- const computeText = computeFn ? detectGetNodeText(ctx, computeFn) : '() => value'
382
-
383
- detectDiag(
384
- ctx,
385
- node,
386
- 'use-memo',
387
- 'useMemo is a React API. In Pyreon, use computed() — dependencies auto-tracked.',
388
- detectGetNodeText(ctx, node),
389
- `computed(${computeText})`,
390
- true,
391
- )
392
- }
393
-
394
- function detectUseCallback(ctx: DetectContext, node: ts.CallExpression): void {
395
- const callbackFn = node.arguments[0]
396
- const callbackText = callbackFn ? detectGetNodeText(ctx, callbackFn) : '() => {}'
397
-
398
- detectDiag(
399
- ctx,
400
- node,
401
- 'use-callback',
402
- 'useCallback is not needed in Pyreon. Components run once, so closures never go stale. Use a plain function.',
403
- detectGetNodeText(ctx, node),
404
- callbackText,
405
- true,
406
- )
407
- }
408
-
409
- function detectUseRef(ctx: DetectContext, node: ts.CallExpression): void {
410
- const arg = node.arguments[0]
411
- const isNullInit =
412
- arg &&
413
- (arg.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(arg) && arg.text === 'undefined'))
414
-
415
- if (isNullInit) {
416
- detectDiag(
417
- ctx,
418
- node,
419
- 'use-ref-dom',
420
- 'useRef(null) for DOM refs. In Pyreon, use createRef() from @pyreon/core.',
421
- detectGetNodeText(ctx, node),
422
- 'createRef()',
423
- true,
424
- )
425
- } else {
426
- const initText = arg ? detectGetNodeText(ctx, arg) : 'undefined'
427
- detectDiag(
428
- ctx,
429
- node,
430
- 'use-ref-box',
431
- 'useRef for mutable values. In Pyreon, use signal() — it works the same way but is reactive.',
432
- detectGetNodeText(ctx, node),
433
- `signal(${initText})`,
434
- true,
435
- )
436
- }
437
- }
438
-
439
- function detectUseReducer(ctx: DetectContext, node: ts.CallExpression): void {
440
- detectDiag(
441
- ctx,
442
- node,
443
- 'use-reducer',
444
- 'useReducer is a React API. In Pyreon, use signal() with update() for reducer patterns.',
445
- detectGetNodeText(ctx, node),
446
- 'const state = signal(initialState)\nconst dispatch = (action) => state.update(s => reducer(s, action))',
447
- false,
448
- )
449
- }
450
-
451
- function isCallToReactDot(callee: ts.Expression, methodName: string): boolean {
452
- return (
453
- ts.isPropertyAccessExpression(callee) &&
454
- ts.isIdentifier(callee.expression) &&
455
- callee.expression.text === 'React' &&
456
- callee.name.text === methodName
457
- )
458
- }
459
-
460
- function detectMemoWrapper(ctx: DetectContext, node: ts.CallExpression): void {
461
- const callee = node.expression
462
- const isMemo =
463
- (ts.isIdentifier(callee) && callee.text === 'memo') || isCallToReactDot(callee, 'memo')
464
-
465
- if (isMemo) {
466
- const inner = node.arguments[0]
467
- const innerText = inner ? detectGetNodeText(ctx, inner) : 'Component'
468
-
469
- detectDiag(
470
- ctx,
471
- node,
472
- 'memo-wrapper',
473
- 'memo() is not needed in Pyreon. Components run once — only signals trigger updates, not re-renders.',
474
- detectGetNodeText(ctx, node),
475
- innerText,
476
- true,
477
- )
478
- }
479
- }
480
-
481
- function detectForwardRef(ctx: DetectContext, node: ts.CallExpression): void {
482
- const callee = node.expression
483
- const isForwardRef =
484
- (ts.isIdentifier(callee) && callee.text === 'forwardRef') ||
485
- isCallToReactDot(callee, 'forwardRef')
486
-
487
- if (isForwardRef) {
488
- detectDiag(
489
- ctx,
490
- node,
491
- 'forward-ref',
492
- 'forwardRef is not needed in Pyreon. Pass ref as a regular prop.',
493
- detectGetNodeText(ctx, node),
494
- '// Just pass ref as a prop:\nconst MyInput = (props) => <input ref={props.ref} />',
495
- true,
496
- )
497
- }
498
- }
499
-
500
- function detectJsxAttributes(ctx: DetectContext, node: ts.JsxAttribute): void {
501
- const attrName = (node.name as ts.Identifier).text
502
-
503
- if (attrName in JSX_ATTR_REWRITES) {
504
- const htmlAttr = JSX_ATTR_REWRITES[attrName] as string
505
- detectDiag(
506
- ctx,
507
- node,
508
- attrName === 'className' ? 'class-name-prop' : 'html-for-prop',
509
- `'${attrName}' is a React JSX attribute. Use '${htmlAttr}' in Pyreon (standard HTML).`,
510
- detectGetNodeText(ctx, node),
511
- detectGetNodeText(ctx, node).replace(attrName, htmlAttr),
512
- true,
513
- )
514
- }
515
-
516
- if (attrName === 'onChange') {
517
- const jsxElement = findParentJsxElement(node)
518
- if (jsxElement) {
519
- const tagName = getJsxTagName(jsxElement)
520
- if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
521
- detectDiag(
522
- ctx,
523
- node,
524
- 'on-change-input',
525
- `onChange on <${tagName}> fires on blur in Pyreon (native DOM behavior). For keypress-by-keypress updates, use onInput.`,
526
- detectGetNodeText(ctx, node),
527
- detectGetNodeText(ctx, node).replace('onChange', 'onInput'),
528
- true,
529
- )
530
- }
531
- }
532
- }
533
-
534
- if (attrName === 'dangerouslySetInnerHTML') {
535
- detectDiag(
536
- ctx,
537
- node,
538
- 'dangerously-set-inner-html',
539
- 'dangerouslySetInnerHTML is React-specific. Use innerHTML prop in Pyreon.',
540
- detectGetNodeText(ctx, node),
541
- 'innerHTML={htmlString}',
542
- true,
543
- )
544
- }
545
- }
546
-
547
- function detectDotValueSignal(ctx: DetectContext, node: ts.PropertyAccessExpression): void {
548
- const varName = (node.expression as ts.Identifier).text
549
- // Precision gate: only flag `X.value = …` when X is actually a tracked
550
- // signal binding. Without this, the detector false-positived on every
551
- // DOM-element / data-object `.value` write — `input.value = ''`,
552
- // `cell.value = x`, `o.value = y`, `ref.current.value = z` (the receiver
553
- // there is the `.current` PropertyAccess, already excluded by
554
- // `isDotValueAccess` requiring an Identifier receiver). Require positive
555
- // evidence the receiver is a `const X = signal(...)` / `computed(...)` /
556
- // `useSignal(...)` / `createSignal(...)` binding before emitting.
557
- if (!ctx.signalBindings.has(varName)) return
558
- const parent = node.parent
559
- if (ts.isBinaryExpression(parent) && parent.left === node) {
560
- detectDiag(
561
- ctx,
562
- node,
563
- 'dot-value-signal',
564
- `'${varName}.value' looks like a Vue ref pattern. Pyreon signals are callable functions. Use ${varName}.set(x) to write.`,
565
- detectGetNodeText(ctx, parent),
566
- `${varName}.set(${detectGetNodeText(ctx, parent.right)})`,
567
- false,
568
- )
569
- }
570
- }
571
-
572
- function detectArrayMapJsx(ctx: DetectContext, node: ts.CallExpression): void {
573
- const parent = node.parent
574
- if (ts.isJsxExpression(parent)) {
575
- const arrayExpr = detectGetNodeText(
576
- ctx,
577
- (node.expression as ts.PropertyAccessExpression).expression,
578
- )
579
- const mapCallback = node.arguments[0]
580
- const mapCallbackText = mapCallback
581
- ? detectGetNodeText(ctx, mapCallback)
582
- : 'item => <li>{item}</li>'
583
-
584
- detectDiag(
585
- ctx,
586
- node,
587
- 'array-map-jsx',
588
- 'Array.map() in JSX is not reactive in Pyreon. Use <For> for efficient keyed list rendering.',
589
- detectGetNodeText(ctx, node),
590
- `<For each={${arrayExpr}} by={item => item.id}>\n {${mapCallbackText}}\n</For>`,
591
- false,
592
- )
593
- }
594
- }
595
-
596
- function isCallToHook(node: ts.Node, hookName: string): node is ts.CallExpression {
597
- return (
598
- ts.isCallExpression(node) &&
599
- ts.isIdentifier(node.expression) &&
600
- node.expression.text === hookName
601
- )
602
- }
603
-
604
- function isCallToEffectHook(node: ts.Node): node is ts.CallExpression {
605
- return (
606
- ts.isCallExpression(node) &&
607
- ts.isIdentifier(node.expression) &&
608
- (node.expression.text === 'useEffect' || node.expression.text === 'useLayoutEffect')
609
- )
610
- }
611
-
612
- function isMapCallExpression(node: ts.Node): node is ts.CallExpression {
613
- return (
614
- ts.isCallExpression(node) &&
615
- ts.isPropertyAccessExpression(node.expression) &&
616
- ts.isIdentifier(node.expression.name) &&
617
- node.expression.name.text === 'map'
618
- )
619
- }
620
-
621
- function isDotValueAccess(node: ts.Node): node is ts.PropertyAccessExpression {
622
- return (
623
- ts.isPropertyAccessExpression(node) &&
624
- ts.isIdentifier(node.name) &&
625
- node.name.text === 'value' &&
626
- ts.isIdentifier(node.expression)
627
- )
628
- }
629
-
630
- function detectVisitNode(ctx: DetectContext, node: ts.Node): void {
631
- if (ts.isImportDeclaration(node)) detectImportDeclaration(ctx, node)
632
- if (isCallToHook(node, 'useState')) detectUseState(ctx, node)
633
- if (isCallToEffectHook(node)) detectUseEffect(ctx, node)
634
- if (isCallToHook(node, 'useMemo')) detectUseMemo(ctx, node)
635
- if (isCallToHook(node, 'useCallback')) detectUseCallback(ctx, node)
636
- if (isCallToHook(node, 'useRef')) detectUseRef(ctx, node)
637
- if (isCallToHook(node, 'useReducer')) detectUseReducer(ctx, node)
638
- if (ts.isCallExpression(node)) detectMemoWrapper(ctx, node)
639
- if (ts.isCallExpression(node)) detectForwardRef(ctx, node)
640
- if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) detectJsxAttributes(ctx, node)
641
- if (isDotValueAccess(node)) detectDotValueSignal(ctx, node)
642
- if (isMapCallExpression(node)) detectArrayMapJsx(ctx, node)
643
- }
644
-
645
- function detectVisit(ctx: DetectContext, node: ts.Node): void {
646
- ts.forEachChild(node, (child) => {
647
- detectVisitNode(ctx, child)
648
- detectVisit(ctx, child)
649
- })
650
- }
651
-
652
- export function detectReactPatterns(code: string, filename = 'input.tsx'): ReactDiagnostic[] {
653
- const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX)
654
- const ctx: DetectContext = {
655
- sf,
656
- code,
657
- diagnostics: [],
658
- reactImportedHooks: new Set<string>(),
659
- signalBindings: collectDetectSignalBindings(sf),
660
- }
661
-
662
- detectVisit(ctx, sf)
663
- return ctx.diagnostics
664
- }
665
-
666
- // ═══════════════════════════════════════════════════════════════════════════════
667
- // Migration (detection + auto-fix)
668
- // ═══════════════════════════════════════════════════════════════════════════════
669
-
670
- type Replacement = { start: number; end: number; text: string }
671
-
672
- interface MigrateContext {
673
- sf: ts.SourceFile
674
- code: string
675
- replacements: Replacement[]
676
- changes: MigrationChange[]
677
- pyreonImports: Map<string, Set<string>>
678
- importsToRemove: Set<ts.ImportDeclaration>
679
- specifierRewrites: Map<ts.ImportSpecifier, { name: string; from: string }>
680
- }
681
-
682
- function migrateAddImport(ctx: MigrateContext, source: string, specifier: string): void {
683
- if (!source || !specifier) return
684
- let specs = ctx.pyreonImports.get(source)
685
- if (!specs) {
686
- specs = new Set()
687
- ctx.pyreonImports.set(source, specs)
688
- }
689
- specs.add(specifier)
690
- }
691
-
692
- function migrateReplace(ctx: MigrateContext, node: ts.Node, text: string): void {
693
- ctx.replacements.push({ start: node.getStart(ctx.sf), end: node.getEnd(), text })
694
- }
695
-
696
- function migrateGetNodeText(ctx: MigrateContext, node: ts.Node): string {
697
- return ctx.code.slice(node.getStart(ctx.sf), node.getEnd())
698
- }
699
-
700
- function migrateGetLine(ctx: MigrateContext, node: ts.Node): number {
701
- return ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf)).line + 1
702
- }
703
-
704
- function migrateImportDeclaration(ctx: MigrateContext, node: ts.ImportDeclaration): void {
705
- if (!node.moduleSpecifier) return
706
- const source = (node.moduleSpecifier as ts.StringLiteral).text
707
- if (!(source in IMPORT_REWRITES)) return
708
-
709
- if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) {
710
- for (const spec of node.importClause.namedBindings.elements) {
711
- const name = spec.name.text
712
- const rewrite = SPECIFIER_REWRITES[name]
713
- if (rewrite) {
714
- if (rewrite.name) {
715
- migrateAddImport(ctx, rewrite.from, rewrite.name)
716
- }
717
- ctx.specifierRewrites.set(spec, rewrite)
718
- }
719
- }
720
- }
721
- ctx.importsToRemove.add(node)
722
- }
723
-
724
- function migrateUseState(ctx: MigrateContext, node: ts.CallExpression): void {
725
- const parent = node.parent
726
- if (
727
- ts.isVariableDeclaration(parent) &&
728
- parent.name &&
729
- ts.isArrayBindingPattern(parent.name) &&
730
- parent.name.elements.length >= 1
731
- ) {
732
- const firstEl = parent.name.elements[0]
733
- const valueName =
734
- firstEl && ts.isBindingElement(firstEl) ? (firstEl.name as ts.Identifier).text : 'value'
735
- const initArg = node.arguments[0] ? migrateGetNodeText(ctx, node.arguments[0]) : 'undefined'
736
-
737
- const declStart = parent.getStart(ctx.sf)
738
- const declEnd = parent.getEnd()
739
- ctx.replacements.push({
740
- start: declStart,
741
- end: declEnd,
742
- text: `${valueName} = signal(${initArg})`,
743
- })
744
- migrateAddImport(ctx, '@pyreon/reactivity', 'signal')
745
- ctx.changes.push({
746
- type: 'replace',
747
- line: migrateGetLine(ctx, node),
748
- description: `useState → signal: ${valueName}`,
749
- })
750
- }
751
- }
752
-
753
- function migrateUseEffect(ctx: MigrateContext, node: ts.CallExpression): void {
754
- const depsArg = node.arguments[1]
755
- const callbackArg = node.arguments[0]
756
- const hookName = (node.expression as ts.Identifier).text
757
-
758
- if (
759
- depsArg &&
760
- ts.isArrayLiteralExpression(depsArg) &&
761
- depsArg.elements.length === 0 &&
762
- callbackArg
763
- ) {
764
- const callbackText = migrateGetNodeText(ctx, callbackArg)
765
- migrateReplace(ctx, node, `onMount(${callbackText})`)
766
- migrateAddImport(ctx, '@pyreon/core', 'onMount')
767
- ctx.changes.push({
768
- type: 'replace',
769
- line: migrateGetLine(ctx, node),
770
- description: `${hookName}(fn, []) → onMount(fn)`,
771
- })
772
- } else if (callbackArg) {
773
- const callbackText = migrateGetNodeText(ctx, callbackArg)
774
- migrateReplace(ctx, node, `effect(${callbackText})`)
775
- migrateAddImport(ctx, '@pyreon/reactivity', 'effect')
776
- ctx.changes.push({
777
- type: 'replace',
778
- line: migrateGetLine(ctx, node),
779
- description: `${hookName} → effect (auto-tracks deps)`,
780
- })
781
- }
782
- }
783
-
784
- function migrateUseMemo(ctx: MigrateContext, node: ts.CallExpression): void {
785
- const computeFn = node.arguments[0]
786
- if (computeFn) {
787
- migrateReplace(ctx, node, `computed(${migrateGetNodeText(ctx, computeFn)})`)
788
- migrateAddImport(ctx, '@pyreon/reactivity', 'computed')
789
- ctx.changes.push({
790
- type: 'replace',
791
- line: migrateGetLine(ctx, node),
792
- description: 'useMemo → computed (auto-tracks deps)',
793
- })
794
- }
795
- }
796
-
797
- function migrateUseCallback(ctx: MigrateContext, node: ts.CallExpression): void {
798
- const callbackFn = node.arguments[0]
799
- if (callbackFn) {
800
- migrateReplace(ctx, node, migrateGetNodeText(ctx, callbackFn))
801
- ctx.changes.push({
802
- type: 'replace',
803
- line: migrateGetLine(ctx, node),
804
- description: 'useCallback → plain function (not needed in Pyreon)',
805
- })
806
- }
807
- }
808
-
809
- function migrateUseRef(ctx: MigrateContext, node: ts.CallExpression): void {
810
- const arg = node.arguments[0]
811
- const isNullInit =
812
- arg &&
813
- (arg.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(arg) && arg.text === 'undefined'))
814
-
815
- if (isNullInit || !arg) {
816
- migrateReplace(ctx, node, 'createRef()')
817
- migrateAddImport(ctx, '@pyreon/core', 'createRef')
818
- ctx.changes.push({
819
- type: 'replace',
820
- line: migrateGetLine(ctx, node),
821
- description: 'useRef(null) → createRef()',
822
- })
823
- } else {
824
- migrateReplace(ctx, node, `signal(${migrateGetNodeText(ctx, arg)})`)
825
- migrateAddImport(ctx, '@pyreon/reactivity', 'signal')
826
- ctx.changes.push({
827
- type: 'replace',
828
- line: migrateGetLine(ctx, node),
829
- description: 'useRef(value) → signal(value)',
830
- })
831
- }
832
- }
833
-
834
- function migrateMemoWrapper(ctx: MigrateContext, node: ts.CallExpression): void {
835
- const callee = node.expression
836
- const isMemo =
837
- (ts.isIdentifier(callee) && callee.text === 'memo') || isCallToReactDot(callee, 'memo')
838
-
839
- if (isMemo && node.arguments[0]) {
840
- migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]))
841
- ctx.changes.push({
842
- type: 'remove',
843
- line: migrateGetLine(ctx, node),
844
- description: 'Removed memo() wrapper (not needed in Pyreon)',
845
- })
846
- }
847
- }
848
-
849
- function migrateForwardRef(ctx: MigrateContext, node: ts.CallExpression): void {
850
- const callee = node.expression
851
- const isForwardRef =
852
- (ts.isIdentifier(callee) && callee.text === 'forwardRef') ||
853
- isCallToReactDot(callee, 'forwardRef')
854
-
855
- if (isForwardRef && node.arguments[0]) {
856
- migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]))
857
- ctx.changes.push({
858
- type: 'remove',
859
- line: migrateGetLine(ctx, node),
860
- description: 'Removed forwardRef wrapper (pass ref as normal prop in Pyreon)',
861
- })
862
- }
863
- }
864
-
865
- function migrateJsxAttributes(ctx: MigrateContext, node: ts.JsxAttribute): void {
866
- const attrName = (node.name as ts.Identifier).text
867
-
868
- if (attrName in JSX_ATTR_REWRITES) {
869
- const htmlAttr = JSX_ATTR_REWRITES[attrName] as string
870
- ctx.replacements.push({
871
- start: node.name.getStart(ctx.sf),
872
- end: node.name.getEnd(),
873
- text: htmlAttr,
874
- })
875
- ctx.changes.push({
876
- type: 'replace',
877
- line: migrateGetLine(ctx, node),
878
- description: `${attrName} → ${htmlAttr}`,
879
- })
880
- }
881
-
882
- if (attrName === 'onChange') {
883
- const jsxElement = findParentJsxElement(node)
884
- if (jsxElement) {
885
- const tagName = getJsxTagName(jsxElement)
886
- if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
887
- ctx.replacements.push({
888
- start: node.name.getStart(ctx.sf),
889
- end: node.name.getEnd(),
890
- text: 'onInput',
891
- })
892
- ctx.changes.push({
893
- type: 'replace',
894
- line: migrateGetLine(ctx, node),
895
- description: `onChange on <${tagName}> → onInput (native DOM events)`,
896
- })
897
- }
898
- }
899
- }
900
-
901
- if (attrName === 'dangerouslySetInnerHTML') {
902
- migrateDangerouslySetInnerHTML(ctx, node)
903
- }
904
- }
905
-
906
- function migrateDangerouslySetInnerHTML(ctx: MigrateContext, node: ts.JsxAttribute): void {
907
- if (!node.initializer || !ts.isJsxExpression(node.initializer) || !node.initializer.expression) {
908
- return
909
- }
910
- const expr = node.initializer.expression
911
- if (!ts.isObjectLiteralExpression(expr)) return
912
-
913
- const htmlProp = expr.properties.find(
914
- (p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === '__html',
915
- ) as ts.PropertyAssignment | undefined
916
-
917
- if (htmlProp) {
918
- const valueText = migrateGetNodeText(ctx, htmlProp.initializer)
919
- migrateReplace(ctx, node, `innerHTML={${valueText}}`)
920
- ctx.changes.push({
921
- type: 'replace',
922
- line: migrateGetLine(ctx, node),
923
- description: 'dangerouslySetInnerHTML → innerHTML',
924
- })
925
- }
926
- }
927
-
928
- function applyReplacements(code: string, ctx: MigrateContext): string {
929
- // Remove React import declarations
930
- for (const imp of ctx.importsToRemove) {
931
- ctx.replacements.push({ start: imp.getStart(ctx.sf), end: imp.getEnd(), text: '' })
932
- ctx.changes.push({
933
- type: 'remove',
934
- line: ctx.sf.getLineAndCharacterOfPosition(imp.getStart(ctx.sf)).line + 1,
935
- description: 'Removed React import',
936
- })
937
- }
938
-
939
- // Sort descending for dedup (outermost replacements win over inner overlapping ones)
940
- ctx.replacements.sort((a, b) => b.start - a.start)
941
-
942
- // Deduplicate overlapping replacements (keep the outermost / first added)
943
- const applied = new Set<string>()
944
- const deduped: Replacement[] = []
945
- for (const r of ctx.replacements) {
946
- const key = `${r.start}:${r.end}`
947
- let overlaps = false
948
- for (const d of deduped) {
949
- if (r.start < d.end && r.end > d.start) {
950
- overlaps = true
951
- break
952
- }
953
- }
954
- if (!overlaps && !applied.has(key)) {
955
- applied.add(key)
956
- deduped.push(r)
957
- }
958
- }
959
-
960
- // Re-sort ascending for string builder — O(n) single join
961
- deduped.sort((a, b) => a.start - b.start)
962
- const parts: string[] = []
963
- let lastPos = 0
964
- for (const r of deduped) {
965
- parts.push(code.slice(lastPos, r.start))
966
- parts.push(r.text)
967
- lastPos = r.end
968
- }
969
- parts.push(code.slice(lastPos))
970
- return parts.join('')
971
- }
972
-
973
- function insertPyreonImports(code: string, pyreonImports: Map<string, Set<string>>): string {
974
- if (pyreonImports.size === 0) return code
975
-
976
- const importLines: string[] = []
977
- const sorted = [...pyreonImports.entries()].sort(([a], [b]) => a.localeCompare(b))
978
- for (const [source, specs] of sorted) {
979
- const specList = [...specs].sort().join(', ')
980
- importLines.push(`import { ${specList} } from "${source}"`)
981
- }
982
- const importBlock = importLines.join('\n')
983
-
984
- const lastImportEnd = findLastImportEnd(code)
985
- if (lastImportEnd > 0) {
986
- return `${code.slice(0, lastImportEnd)}\n${importBlock}${code.slice(lastImportEnd)}`
987
- }
988
- return `${importBlock}\n\n${code}`
989
- }
990
-
991
- function migrateVisitNode(ctx: MigrateContext, node: ts.Node): void {
992
- if (ts.isImportDeclaration(node)) migrateImportDeclaration(ctx, node)
993
- if (isCallToHook(node, 'useState')) migrateUseState(ctx, node)
994
- if (isCallToEffectHook(node)) migrateUseEffect(ctx, node)
995
- if (isCallToHook(node, 'useMemo')) migrateUseMemo(ctx, node)
996
- if (isCallToHook(node, 'useCallback')) migrateUseCallback(ctx, node)
997
- if (isCallToHook(node, 'useRef')) migrateUseRef(ctx, node)
998
- if (ts.isCallExpression(node)) migrateMemoWrapper(ctx, node)
999
- if (ts.isCallExpression(node)) migrateForwardRef(ctx, node)
1000
- if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) migrateJsxAttributes(ctx, node)
1001
- }
1002
-
1003
- function migrateVisit(ctx: MigrateContext, node: ts.Node): void {
1004
- ts.forEachChild(node, (child) => {
1005
- migrateVisitNode(ctx, child)
1006
- migrateVisit(ctx, child)
1007
- })
1008
- }
1009
-
1010
- export function migrateReactCode(code: string, filename = 'input.tsx'): MigrationResult {
1011
- const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX)
1012
- const diagnostics = detectReactPatterns(code, filename)
1013
-
1014
- const ctx: MigrateContext = {
1015
- sf,
1016
- code,
1017
- replacements: [],
1018
- changes: [],
1019
- pyreonImports: new Map(),
1020
- importsToRemove: new Set(),
1021
- specifierRewrites: new Map(),
1022
- }
1023
-
1024
- migrateVisit(ctx, sf)
1025
-
1026
- let result = applyReplacements(code, ctx)
1027
- result = insertPyreonImports(result, ctx.pyreonImports)
1028
-
1029
- // Clean up empty lines from removed imports
1030
- result = result.replace(/\n{3,}/g, '\n\n')
1031
-
1032
- return { code: result, diagnostics, changes: ctx.changes }
1033
- }
1034
-
1035
- // ═══════════════════════════════════════════════════════════════════════════════
1036
- // Helpers
1037
- // ═══════════════════════════════════════════════════════════════════════════════
1038
-
1039
- function findParentJsxElement(
1040
- node: ts.Node,
1041
- ): ts.JsxOpeningElement | ts.JsxSelfClosingElement | null {
1042
- let current = node.parent
1043
- while (current) {
1044
- if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current)) {
1045
- return current
1046
- }
1047
- // Don't cross component boundaries
1048
- if (ts.isJsxElement(current)) {
1049
- return current.openingElement
1050
- }
1051
- if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) {
1052
- return null
1053
- }
1054
- current = current.parent
1055
- }
1056
- return null
1057
- }
1058
-
1059
- function getJsxTagName(node: ts.JsxOpeningElement | ts.JsxSelfClosingElement): string {
1060
- const tagName = node.tagName
1061
- if (ts.isIdentifier(tagName)) {
1062
- return tagName.text
1063
- }
1064
- return ''
1065
- }
1066
-
1067
- function findLastImportEnd(code: string): number {
1068
- const importRe = /^import\s.+$/gm
1069
- let lastEnd = 0
1070
- let match: RegExpExecArray | null
1071
- while (true) {
1072
- match = importRe.exec(code)
1073
- if (!match) break
1074
- lastEnd = match.index + match[0].length
1075
- }
1076
- return lastEnd
1077
- }
1078
-
1079
- // ═══════════════════════════════════════════════════════════════════════════════
1080
- // Quick scan (regex-based, for fast pre-filtering)
1081
- // ═══════════════════════════════════════════════════════════════════════════════
1082
-
1083
- /** Fast regex check — returns true if code likely contains React patterns worth analyzing */
1084
- export function hasReactPatterns(code: string): boolean {
1085
- return (
1086
- /\bfrom\s+['"]react/.test(code) ||
1087
- /\bfrom\s+['"]react-dom/.test(code) ||
1088
- /\bfrom\s+['"]react-router/.test(code) ||
1089
- /\buseState\s*[<(]/.test(code) ||
1090
- /\buseEffect\s*\(/.test(code) ||
1091
- /\buseMemo\s*\(/.test(code) ||
1092
- /\buseCallback\s*\(/.test(code) ||
1093
- /\buseRef\s*[<(]/.test(code) ||
1094
- /\buseReducer\s*[<(]/.test(code) ||
1095
- /\bReact\.memo\b/.test(code) ||
1096
- /\bforwardRef\s*[<(]/.test(code) ||
1097
- /\bclassName[=\s]/.test(code) ||
1098
- /\bhtmlFor[=\s]/.test(code) ||
1099
- /\.value\s*=/.test(code)
1100
- )
1101
- }
1102
-
1103
- // ═══════════════════════════════════════════════════════════════════════════════
1104
- // Error pattern database (for MCP diagnose tool)
1105
- // ═══════════════════════════════════════════════════════════════════════════════
1106
-
1107
- export interface ErrorDiagnosis {
1108
- cause: string
1109
- fix: string
1110
- fixCode?: string | undefined
1111
- related?: string | undefined
1112
- }
1113
-
1114
- interface ErrorPattern {
1115
- pattern: RegExp
1116
- diagnose: (match: RegExpMatchArray) => ErrorDiagnosis
1117
- }
1118
-
1119
- const ERROR_PATTERNS: ErrorPattern[] = [
1120
- {
1121
- pattern: /Cannot read properties of undefined \(reading '(set|update|peek|subscribe)'\)/,
1122
- diagnose: (m) => ({
1123
- cause: `Calling .${m[1]}() on undefined. The signal variable is likely out of scope, misspelled, or not yet initialized.`,
1124
- fix: 'Check that the signal is defined and in scope. Signals must be created with signal() before use.',
1125
- fixCode: `const mySignal = signal(initialValue)\nmySignal.${m[1]}(newValue)`,
1126
- }),
1127
- },
1128
- {
1129
- pattern: /(\w+) is not a function/,
1130
- diagnose: (m) => ({
1131
- cause: `'${m[1]}' is not callable. If this is a signal, you need to call it: ${m[1]}()`,
1132
- fix: 'Pyreon signals are callable functions. Read: signal(), Write: signal.set(value)',
1133
- fixCode: `// Read value:\nconst value = ${m[1]}()\n// Set value:\n${m[1]}.set(newValue)`,
1134
- }),
1135
- },
1136
- {
1137
- pattern: /Cannot find module '(@pyreon\/\w[\w-]*)'/,
1138
- diagnose: (m) => ({
1139
- cause: `Package ${m[1]} is not installed.`,
1140
- fix: `Run: bun add ${m[1]}`,
1141
- fixCode: `bun add ${m[1]}`,
1142
- }),
1143
- },
1144
- {
1145
- pattern: /Cannot find module 'react'/,
1146
- diagnose: () => ({
1147
- cause: "Importing from 'react' in a Pyreon project.",
1148
- fix: 'Replace React imports with Pyreon equivalents.',
1149
- fixCode:
1150
- '// Instead of:\nimport { useState } from "react"\n// Use:\nimport { signal } from "@pyreon/reactivity"',
1151
- }),
1152
- },
1153
- {
1154
- pattern: /Property '(\w+)' does not exist on type 'Signal<\w+>'/,
1155
- diagnose: (m) => ({
1156
- cause: `Accessing .${m[1]} on a signal. Pyreon signals don't have a .${m[1]} property.`,
1157
- fix:
1158
- m[1] === 'value'
1159
- ? 'Pyreon signals are callable functions, not .value getters. Call signal() to read, signal.set() to write.'
1160
- : `Signals have these methods: .set(), .update(), .peek(), .subscribe(). '${m[1]}' is not one of them.`,
1161
- fixCode:
1162
- m[1] === 'value' ? '// Read: mySignal()\n// Write: mySignal.set(newValue)' : undefined,
1163
- }),
1164
- },
1165
- {
1166
- pattern: /Type '(\w+)' is not assignable to type 'VNode'/,
1167
- diagnose: (m) => ({
1168
- cause: `Component returned ${m[1]} instead of VNode. Components must return JSX, null, or a string.`,
1169
- fix: 'Make sure your component returns a JSX element, null, or a string.',
1170
- fixCode: 'const MyComponent = (props) => {\n return <div>{props.children}</div>\n}',
1171
- }),
1172
- },
1173
- {
1174
- pattern: /onMount callback must return/,
1175
- diagnose: () => ({
1176
- cause: 'onMount expects a callback that optionally returns a CleanupFn.',
1177
- fix: 'Return a cleanup function, or return nothing.',
1178
- fixCode: 'onMount(() => {\n // setup code\n})',
1179
- }),
1180
- },
1181
- {
1182
- pattern: /Expected 'by' prop on <For>/,
1183
- diagnose: () => ({
1184
- cause: "<For> requires a 'by' prop for efficient keyed reconciliation.",
1185
- fix: 'Add a by prop that returns a unique key for each item.',
1186
- fixCode:
1187
- '<For each={items()} by={item => item.id}>\n {item => <li>{item.name}</li>}\n</For>',
1188
- }),
1189
- },
1190
- {
1191
- pattern: /useHook.*outside.*component/i,
1192
- diagnose: () => ({
1193
- cause:
1194
- 'Hook called outside a component function. Pyreon hooks must be called during component setup.',
1195
- fix: 'Move the hook call inside a component function body.',
1196
- }),
1197
- },
1198
- {
1199
- pattern: /Hydration mismatch/,
1200
- diagnose: () => ({
1201
- cause: "Server-rendered HTML doesn't match client-rendered output.",
1202
- fix: 'Ensure SSR and client render the same initial content. Check for browser-only APIs (window, document) in SSR code.',
1203
- related: "Use typeof window !== 'undefined' checks or onMount() for client-only code.",
1204
- }),
1205
- },
1206
- ]
1207
-
1208
- /** Diagnose an error message and return structured fix information */
1209
- export function diagnoseError(error: string): ErrorDiagnosis | null {
1210
- for (const { pattern, diagnose } of ERROR_PATTERNS) {
1211
- const match = error.match(pattern)
1212
- if (match) {
1213
- return diagnose(match)
1214
- }
1215
- }
1216
- return null
1217
- }