@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.
@@ -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
+ }