@pyreon/compiler 0.3.1 → 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/index.js CHANGED
@@ -634,5 +634,678 @@ function containsCall(node) {
634
634
  }
635
635
 
636
636
  //#endregion
637
- export { transformJSX };
637
+ //#region src/react-intercept.ts
638
+ /**
639
+ * React Pattern Interceptor — detects React/Vue patterns in code and provides
640
+ * structured diagnostics with exact fix suggestions for AI-assisted migration.
641
+ *
642
+ * Two modes:
643
+ * - `detectReactPatterns(code)` — returns diagnostics only (non-destructive)
644
+ * - `migrateReactCode(code)` — applies auto-fixes and returns transformed code
645
+ *
646
+ * Designed for three consumers:
647
+ * 1. Compiler pre-pass (warnings during build)
648
+ * 2. CLI `pyreon doctor` (project-wide scanning)
649
+ * 3. MCP server `migrate_react` / `validate` tools (AI agent integration)
650
+ */
651
+ /** React import sources → Pyreon equivalents */
652
+ const IMPORT_REWRITES = {
653
+ react: "@pyreon/core",
654
+ "react-dom": "@pyreon/runtime-dom",
655
+ "react-dom/client": "@pyreon/runtime-dom",
656
+ "react-dom/server": "@pyreon/runtime-server",
657
+ "react-router": "@pyreon/router",
658
+ "react-router-dom": "@pyreon/router"
659
+ };
660
+ /** React specifiers that map to specific Pyreon imports */
661
+ const SPECIFIER_REWRITES = {
662
+ useState: {
663
+ name: "signal",
664
+ from: "@pyreon/reactivity"
665
+ },
666
+ useEffect: {
667
+ name: "effect",
668
+ from: "@pyreon/reactivity"
669
+ },
670
+ useLayoutEffect: {
671
+ name: "effect",
672
+ from: "@pyreon/reactivity"
673
+ },
674
+ useMemo: {
675
+ name: "computed",
676
+ from: "@pyreon/reactivity"
677
+ },
678
+ useReducer: {
679
+ name: "signal",
680
+ from: "@pyreon/reactivity"
681
+ },
682
+ useRef: {
683
+ name: "signal",
684
+ from: "@pyreon/reactivity"
685
+ },
686
+ createContext: {
687
+ name: "createContext",
688
+ from: "@pyreon/core"
689
+ },
690
+ useContext: {
691
+ name: "useContext",
692
+ from: "@pyreon/core"
693
+ },
694
+ Fragment: {
695
+ name: "Fragment",
696
+ from: "@pyreon/core"
697
+ },
698
+ Suspense: {
699
+ name: "Suspense",
700
+ from: "@pyreon/core"
701
+ },
702
+ lazy: {
703
+ name: "lazy",
704
+ from: "@pyreon/core"
705
+ },
706
+ memo: {
707
+ name: "",
708
+ from: ""
709
+ },
710
+ forwardRef: {
711
+ name: "",
712
+ from: ""
713
+ },
714
+ createRoot: {
715
+ name: "mount",
716
+ from: "@pyreon/runtime-dom"
717
+ },
718
+ hydrateRoot: {
719
+ name: "hydrateRoot",
720
+ from: "@pyreon/runtime-dom"
721
+ },
722
+ useNavigate: {
723
+ name: "useRouter",
724
+ from: "@pyreon/router"
725
+ },
726
+ useParams: {
727
+ name: "useRoute",
728
+ from: "@pyreon/router"
729
+ },
730
+ useLocation: {
731
+ name: "useRoute",
732
+ from: "@pyreon/router"
733
+ },
734
+ Link: {
735
+ name: "RouterLink",
736
+ from: "@pyreon/router"
737
+ },
738
+ NavLink: {
739
+ name: "RouterLink",
740
+ from: "@pyreon/router"
741
+ },
742
+ Outlet: {
743
+ name: "RouterView",
744
+ from: "@pyreon/router"
745
+ },
746
+ useSearchParams: {
747
+ name: "useSearchParams",
748
+ from: "@pyreon/router"
749
+ }
750
+ };
751
+ /** JSX attribute rewrites (React → standard HTML) */
752
+ const JSX_ATTR_REWRITES = {
753
+ className: "class",
754
+ htmlFor: "for"
755
+ };
756
+ function detectGetNodeText(ctx, node) {
757
+ return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
758
+ }
759
+ function detectDiag(ctx, node, diagCode, message, current, suggested, fixable) {
760
+ const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf));
761
+ ctx.diagnostics.push({
762
+ code: diagCode,
763
+ message,
764
+ line: line + 1,
765
+ column: character,
766
+ current: current.trim(),
767
+ suggested: suggested.trim(),
768
+ fixable
769
+ });
770
+ }
771
+ function detectImportDeclaration(ctx, node) {
772
+ if (!node.moduleSpecifier) return;
773
+ const source = node.moduleSpecifier.text;
774
+ const pyreonSource = IMPORT_REWRITES[source];
775
+ if (pyreonSource !== void 0) {
776
+ if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) for (const spec of node.importClause.namedBindings.elements) ctx.reactImportedHooks.add(spec.name.text);
777
+ detectDiag(ctx, node, source.startsWith("react-router") ? "react-router-import" : source.startsWith("react-dom") ? "react-dom-import" : "react-import", `Import from '${source}' is a React package. Use Pyreon equivalent.`, detectGetNodeText(ctx, node), pyreonSource ? `import { ... } from "${pyreonSource}"` : "Remove this import — not needed in Pyreon", true);
778
+ }
779
+ }
780
+ function detectUseState(ctx, node) {
781
+ const parent = node.parent;
782
+ if (ts.isVariableDeclaration(parent) && parent.name && ts.isArrayBindingPattern(parent.name) && parent.name.elements.length >= 1) {
783
+ const firstEl = parent.name.elements[0];
784
+ const valueName = firstEl && ts.isBindingElement(firstEl) ? firstEl.name.text : "value";
785
+ const initArg = node.arguments[0] ? detectGetNodeText(ctx, node.arguments[0]) : "undefined";
786
+ detectDiag(ctx, node, "use-state", `useState is a React API. In Pyreon, use signal(). Read: ${valueName}(), Write: ${valueName}.set(x)`, detectGetNodeText(ctx, parent), `${valueName} = signal(${initArg})`, true);
787
+ } else detectDiag(ctx, node, "use-state", "useState is a React API. In Pyreon, use signal().", detectGetNodeText(ctx, node), "signal(initialValue)", true);
788
+ }
789
+ function callbackHasCleanup(callbackArg) {
790
+ if (!ts.isArrowFunction(callbackArg) && !ts.isFunctionExpression(callbackArg)) return false;
791
+ const body = callbackArg.body;
792
+ if (!ts.isBlock(body)) return false;
793
+ for (const stmt of body.statements) if (ts.isReturnStatement(stmt) && stmt.expression) return true;
794
+ return false;
795
+ }
796
+ function detectUseEffect(ctx, node) {
797
+ const hookName = node.expression.text;
798
+ const depsArg = node.arguments[1];
799
+ const callbackArg = node.arguments[0];
800
+ if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0) {
801
+ const hasCleanup = callbackArg ? callbackHasCleanup(callbackArg) : false;
802
+ detectDiag(ctx, node, "use-effect-mount", `${hookName} with empty deps [] means "run once on mount". Use onMount() in Pyreon.`, detectGetNodeText(ctx, node), hasCleanup ? "onMount(() => {\n // setup...\n return () => { /* cleanup */ }\n})" : "onMount(() => {\n // setup...\n return undefined\n})", true);
803
+ } else if (depsArg && ts.isArrayLiteralExpression(depsArg)) detectDiag(ctx, node, "use-effect-deps", `${hookName} with dependency array. In Pyreon, effect() auto-tracks dependencies — no array needed.`, detectGetNodeText(ctx, node), "effect(() => {\n // reads are auto-tracked\n})", true);
804
+ else if (!depsArg) detectDiag(ctx, node, "use-effect-no-deps", `${hookName} with no dependency array. In Pyreon, use effect() — it auto-tracks signal reads.`, detectGetNodeText(ctx, node), "effect(() => {\n // runs when accessed signals change\n})", true);
805
+ }
806
+ function detectUseMemo(ctx, node) {
807
+ const computeFn = node.arguments[0];
808
+ const computeText = computeFn ? detectGetNodeText(ctx, computeFn) : "() => value";
809
+ detectDiag(ctx, node, "use-memo", "useMemo is a React API. In Pyreon, use computed() — dependencies auto-tracked.", detectGetNodeText(ctx, node), `computed(${computeText})`, true);
810
+ }
811
+ function detectUseCallback(ctx, node) {
812
+ const callbackFn = node.arguments[0];
813
+ const callbackText = callbackFn ? detectGetNodeText(ctx, callbackFn) : "() => {}";
814
+ detectDiag(ctx, node, "use-callback", "useCallback is not needed in Pyreon. Components run once, so closures never go stale. Use a plain function.", detectGetNodeText(ctx, node), callbackText, true);
815
+ }
816
+ function detectUseRef(ctx, node) {
817
+ const arg = node.arguments[0];
818
+ if (arg && (arg.kind === ts.SyntaxKind.NullKeyword || ts.isIdentifier(arg) && arg.text === "undefined")) detectDiag(ctx, node, "use-ref-dom", "useRef(null) for DOM refs. In Pyreon, use createRef() from @pyreon/core.", detectGetNodeText(ctx, node), "createRef()", true);
819
+ else {
820
+ const initText = arg ? detectGetNodeText(ctx, arg) : "undefined";
821
+ detectDiag(ctx, node, "use-ref-box", "useRef for mutable values. In Pyreon, use signal() — it works the same way but is reactive.", detectGetNodeText(ctx, node), `signal(${initText})`, true);
822
+ }
823
+ }
824
+ function detectUseReducer(ctx, node) {
825
+ detectDiag(ctx, node, "use-reducer", "useReducer is a React API. In Pyreon, use signal() with update() for reducer patterns.", detectGetNodeText(ctx, node), "const state = signal(initialState)\nconst dispatch = (action) => state.update(s => reducer(s, action))", false);
826
+ }
827
+ function isCallToReactDot(callee, methodName) {
828
+ return ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === "React" && callee.name.text === methodName;
829
+ }
830
+ function detectMemoWrapper(ctx, node) {
831
+ const callee = node.expression;
832
+ if (ts.isIdentifier(callee) && callee.text === "memo" || isCallToReactDot(callee, "memo")) {
833
+ const inner = node.arguments[0];
834
+ const innerText = inner ? detectGetNodeText(ctx, inner) : "Component";
835
+ detectDiag(ctx, node, "memo-wrapper", "memo() is not needed in Pyreon. Components run once — only signals trigger updates, not re-renders.", detectGetNodeText(ctx, node), innerText, true);
836
+ }
837
+ }
838
+ function detectForwardRef(ctx, node) {
839
+ const callee = node.expression;
840
+ if (ts.isIdentifier(callee) && callee.text === "forwardRef" || isCallToReactDot(callee, "forwardRef")) detectDiag(ctx, node, "forward-ref", "forwardRef is not needed in Pyreon. Pass ref as a regular prop.", detectGetNodeText(ctx, node), "// Just pass ref as a prop:\nconst MyInput = (props) => <input ref={props.ref} />", true);
841
+ }
842
+ function detectJsxAttributes(ctx, node) {
843
+ const attrName = node.name.text;
844
+ if (attrName in JSX_ATTR_REWRITES) {
845
+ const htmlAttr = JSX_ATTR_REWRITES[attrName];
846
+ detectDiag(ctx, node, attrName === "className" ? "class-name-prop" : "html-for-prop", `'${attrName}' is a React JSX attribute. Use '${htmlAttr}' in Pyreon (standard HTML).`, detectGetNodeText(ctx, node), detectGetNodeText(ctx, node).replace(attrName, htmlAttr), true);
847
+ }
848
+ if (attrName === "onChange") {
849
+ const jsxElement = findParentJsxElement(node);
850
+ if (jsxElement) {
851
+ const tagName = getJsxTagName(jsxElement);
852
+ if (tagName === "input" || tagName === "textarea" || tagName === "select") detectDiag(ctx, node, "on-change-input", `onChange on <${tagName}> fires on blur in Pyreon (native DOM behavior). For keypress-by-keypress updates, use onInput.`, detectGetNodeText(ctx, node), detectGetNodeText(ctx, node).replace("onChange", "onInput"), true);
853
+ }
854
+ }
855
+ if (attrName === "dangerouslySetInnerHTML") detectDiag(ctx, node, "dangerously-set-inner-html", "dangerouslySetInnerHTML is React-specific. Use innerHTML prop in Pyreon.", detectGetNodeText(ctx, node), "innerHTML={htmlString}", true);
856
+ }
857
+ function detectDotValueSignal(ctx, node) {
858
+ const varName = node.expression.text;
859
+ const parent = node.parent;
860
+ if (ts.isBinaryExpression(parent) && parent.left === node) detectDiag(ctx, node, "dot-value-signal", `'${varName}.value' looks like a Vue ref pattern. Pyreon signals are callable functions. Use ${varName}.set(x) to write.`, detectGetNodeText(ctx, parent), `${varName}.set(${detectGetNodeText(ctx, parent.right)})`, false);
861
+ }
862
+ function detectArrayMapJsx(ctx, node) {
863
+ const parent = node.parent;
864
+ if (ts.isJsxExpression(parent)) {
865
+ const arrayExpr = detectGetNodeText(ctx, node.expression.expression);
866
+ const mapCallback = node.arguments[0];
867
+ const mapCallbackText = mapCallback ? detectGetNodeText(ctx, mapCallback) : "item => <li>{item}</li>";
868
+ detectDiag(ctx, node, "array-map-jsx", "Array.map() in JSX is not reactive in Pyreon. Use <For> for efficient keyed list rendering.", detectGetNodeText(ctx, node), `<For each={${arrayExpr}} by={item => item.id}>\n {${mapCallbackText}}\n</For>`, false);
869
+ }
870
+ }
871
+ function isCallToHook(node, hookName) {
872
+ return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === hookName;
873
+ }
874
+ function isCallToEffectHook(node) {
875
+ return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (node.expression.text === "useEffect" || node.expression.text === "useLayoutEffect");
876
+ }
877
+ function isMapCallExpression(node) {
878
+ return ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name) && node.expression.name.text === "map";
879
+ }
880
+ function isDotValueAccess(node) {
881
+ return ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name) && node.name.text === "value" && ts.isIdentifier(node.expression);
882
+ }
883
+ function detectVisitNode(ctx, node) {
884
+ if (ts.isImportDeclaration(node)) detectImportDeclaration(ctx, node);
885
+ if (isCallToHook(node, "useState")) detectUseState(ctx, node);
886
+ if (isCallToEffectHook(node)) detectUseEffect(ctx, node);
887
+ if (isCallToHook(node, "useMemo")) detectUseMemo(ctx, node);
888
+ if (isCallToHook(node, "useCallback")) detectUseCallback(ctx, node);
889
+ if (isCallToHook(node, "useRef")) detectUseRef(ctx, node);
890
+ if (isCallToHook(node, "useReducer")) detectUseReducer(ctx, node);
891
+ if (ts.isCallExpression(node)) detectMemoWrapper(ctx, node);
892
+ if (ts.isCallExpression(node)) detectForwardRef(ctx, node);
893
+ if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) detectJsxAttributes(ctx, node);
894
+ if (isDotValueAccess(node)) detectDotValueSignal(ctx, node);
895
+ if (isMapCallExpression(node)) detectArrayMapJsx(ctx, node);
896
+ }
897
+ function detectVisit(ctx, node) {
898
+ ts.forEachChild(node, (child) => {
899
+ detectVisitNode(ctx, child);
900
+ detectVisit(ctx, child);
901
+ });
902
+ }
903
+ function detectReactPatterns(code, filename = "input.tsx") {
904
+ const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
905
+ const ctx = {
906
+ sf,
907
+ code,
908
+ diagnostics: [],
909
+ reactImportedHooks: /* @__PURE__ */ new Set()
910
+ };
911
+ detectVisit(ctx, sf);
912
+ return ctx.diagnostics;
913
+ }
914
+ function migrateAddImport(ctx, source, specifier) {
915
+ if (!source || !specifier) return;
916
+ let specs = ctx.pyreonImports.get(source);
917
+ if (!specs) {
918
+ specs = /* @__PURE__ */ new Set();
919
+ ctx.pyreonImports.set(source, specs);
920
+ }
921
+ specs.add(specifier);
922
+ }
923
+ function migrateReplace(ctx, node, text) {
924
+ ctx.replacements.push({
925
+ start: node.getStart(ctx.sf),
926
+ end: node.getEnd(),
927
+ text
928
+ });
929
+ }
930
+ function migrateGetNodeText(ctx, node) {
931
+ return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
932
+ }
933
+ function migrateGetLine(ctx, node) {
934
+ return ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf)).line + 1;
935
+ }
936
+ function migrateImportDeclaration(ctx, node) {
937
+ if (!node.moduleSpecifier) return;
938
+ if (!(node.moduleSpecifier.text in IMPORT_REWRITES)) return;
939
+ if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) for (const spec of node.importClause.namedBindings.elements) {
940
+ const rewrite = SPECIFIER_REWRITES[spec.name.text];
941
+ if (rewrite) {
942
+ if (rewrite.name) migrateAddImport(ctx, rewrite.from, rewrite.name);
943
+ ctx.specifierRewrites.set(spec, rewrite);
944
+ }
945
+ }
946
+ ctx.importsToRemove.add(node);
947
+ }
948
+ function migrateUseState(ctx, node) {
949
+ const parent = node.parent;
950
+ if (ts.isVariableDeclaration(parent) && parent.name && ts.isArrayBindingPattern(parent.name) && parent.name.elements.length >= 1) {
951
+ const firstEl = parent.name.elements[0];
952
+ const valueName = firstEl && ts.isBindingElement(firstEl) ? firstEl.name.text : "value";
953
+ const initArg = node.arguments[0] ? migrateGetNodeText(ctx, node.arguments[0]) : "undefined";
954
+ const declStart = parent.getStart(ctx.sf);
955
+ const declEnd = parent.getEnd();
956
+ ctx.replacements.push({
957
+ start: declStart,
958
+ end: declEnd,
959
+ text: `${valueName} = signal(${initArg})`
960
+ });
961
+ migrateAddImport(ctx, "@pyreon/reactivity", "signal");
962
+ ctx.changes.push({
963
+ type: "replace",
964
+ line: migrateGetLine(ctx, node),
965
+ description: `useState → signal: ${valueName}`
966
+ });
967
+ }
968
+ }
969
+ function migrateUseEffect(ctx, node) {
970
+ const depsArg = node.arguments[1];
971
+ const callbackArg = node.arguments[0];
972
+ const hookName = node.expression.text;
973
+ if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0 && callbackArg) {
974
+ migrateReplace(ctx, node, `onMount(${migrateGetNodeText(ctx, callbackArg)})`);
975
+ migrateAddImport(ctx, "@pyreon/core", "onMount");
976
+ ctx.changes.push({
977
+ type: "replace",
978
+ line: migrateGetLine(ctx, node),
979
+ description: `${hookName}(fn, []) → onMount(fn)`
980
+ });
981
+ } else if (callbackArg) {
982
+ migrateReplace(ctx, node, `effect(${migrateGetNodeText(ctx, callbackArg)})`);
983
+ migrateAddImport(ctx, "@pyreon/reactivity", "effect");
984
+ ctx.changes.push({
985
+ type: "replace",
986
+ line: migrateGetLine(ctx, node),
987
+ description: `${hookName} → effect (auto-tracks deps)`
988
+ });
989
+ }
990
+ }
991
+ function migrateUseMemo(ctx, node) {
992
+ const computeFn = node.arguments[0];
993
+ if (computeFn) {
994
+ migrateReplace(ctx, node, `computed(${migrateGetNodeText(ctx, computeFn)})`);
995
+ migrateAddImport(ctx, "@pyreon/reactivity", "computed");
996
+ ctx.changes.push({
997
+ type: "replace",
998
+ line: migrateGetLine(ctx, node),
999
+ description: "useMemo → computed (auto-tracks deps)"
1000
+ });
1001
+ }
1002
+ }
1003
+ function migrateUseCallback(ctx, node) {
1004
+ const callbackFn = node.arguments[0];
1005
+ if (callbackFn) {
1006
+ migrateReplace(ctx, node, migrateGetNodeText(ctx, callbackFn));
1007
+ ctx.changes.push({
1008
+ type: "replace",
1009
+ line: migrateGetLine(ctx, node),
1010
+ description: "useCallback → plain function (not needed in Pyreon)"
1011
+ });
1012
+ }
1013
+ }
1014
+ function migrateUseRef(ctx, node) {
1015
+ const arg = node.arguments[0];
1016
+ if (arg && (arg.kind === ts.SyntaxKind.NullKeyword || ts.isIdentifier(arg) && arg.text === "undefined") || !arg) {
1017
+ migrateReplace(ctx, node, "createRef()");
1018
+ migrateAddImport(ctx, "@pyreon/core", "createRef");
1019
+ ctx.changes.push({
1020
+ type: "replace",
1021
+ line: migrateGetLine(ctx, node),
1022
+ description: "useRef(null) → createRef()"
1023
+ });
1024
+ } else {
1025
+ migrateReplace(ctx, node, `signal(${migrateGetNodeText(ctx, arg)})`);
1026
+ migrateAddImport(ctx, "@pyreon/reactivity", "signal");
1027
+ ctx.changes.push({
1028
+ type: "replace",
1029
+ line: migrateGetLine(ctx, node),
1030
+ description: "useRef(value) → signal(value)"
1031
+ });
1032
+ }
1033
+ }
1034
+ function migrateMemoWrapper(ctx, node) {
1035
+ const callee = node.expression;
1036
+ if ((ts.isIdentifier(callee) && callee.text === "memo" || isCallToReactDot(callee, "memo")) && node.arguments[0]) {
1037
+ migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]));
1038
+ ctx.changes.push({
1039
+ type: "remove",
1040
+ line: migrateGetLine(ctx, node),
1041
+ description: "Removed memo() wrapper (not needed in Pyreon)"
1042
+ });
1043
+ }
1044
+ }
1045
+ function migrateForwardRef(ctx, node) {
1046
+ const callee = node.expression;
1047
+ if ((ts.isIdentifier(callee) && callee.text === "forwardRef" || isCallToReactDot(callee, "forwardRef")) && node.arguments[0]) {
1048
+ migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]));
1049
+ ctx.changes.push({
1050
+ type: "remove",
1051
+ line: migrateGetLine(ctx, node),
1052
+ description: "Removed forwardRef wrapper (pass ref as normal prop in Pyreon)"
1053
+ });
1054
+ }
1055
+ }
1056
+ function migrateJsxAttributes(ctx, node) {
1057
+ const attrName = node.name.text;
1058
+ if (attrName in JSX_ATTR_REWRITES) {
1059
+ const htmlAttr = JSX_ATTR_REWRITES[attrName];
1060
+ ctx.replacements.push({
1061
+ start: node.name.getStart(ctx.sf),
1062
+ end: node.name.getEnd(),
1063
+ text: htmlAttr
1064
+ });
1065
+ ctx.changes.push({
1066
+ type: "replace",
1067
+ line: migrateGetLine(ctx, node),
1068
+ description: `${attrName} → ${htmlAttr}`
1069
+ });
1070
+ }
1071
+ if (attrName === "onChange") {
1072
+ const jsxElement = findParentJsxElement(node);
1073
+ if (jsxElement) {
1074
+ const tagName = getJsxTagName(jsxElement);
1075
+ if (tagName === "input" || tagName === "textarea" || tagName === "select") {
1076
+ ctx.replacements.push({
1077
+ start: node.name.getStart(ctx.sf),
1078
+ end: node.name.getEnd(),
1079
+ text: "onInput"
1080
+ });
1081
+ ctx.changes.push({
1082
+ type: "replace",
1083
+ line: migrateGetLine(ctx, node),
1084
+ description: `onChange on <${tagName}> → onInput (native DOM events)`
1085
+ });
1086
+ }
1087
+ }
1088
+ }
1089
+ if (attrName === "dangerouslySetInnerHTML") migrateDangerouslySetInnerHTML(ctx, node);
1090
+ }
1091
+ function migrateDangerouslySetInnerHTML(ctx, node) {
1092
+ if (!node.initializer || !ts.isJsxExpression(node.initializer) || !node.initializer.expression) return;
1093
+ const expr = node.initializer.expression;
1094
+ if (!ts.isObjectLiteralExpression(expr)) return;
1095
+ const htmlProp = expr.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "__html");
1096
+ if (htmlProp) {
1097
+ migrateReplace(ctx, node, `innerHTML={${migrateGetNodeText(ctx, htmlProp.initializer)}}`);
1098
+ ctx.changes.push({
1099
+ type: "replace",
1100
+ line: migrateGetLine(ctx, node),
1101
+ description: "dangerouslySetInnerHTML → innerHTML"
1102
+ });
1103
+ }
1104
+ }
1105
+ function applyReplacements(code, ctx) {
1106
+ for (const imp of ctx.importsToRemove) {
1107
+ ctx.replacements.push({
1108
+ start: imp.getStart(ctx.sf),
1109
+ end: imp.getEnd(),
1110
+ text: ""
1111
+ });
1112
+ ctx.changes.push({
1113
+ type: "remove",
1114
+ line: ctx.sf.getLineAndCharacterOfPosition(imp.getStart(ctx.sf)).line + 1,
1115
+ description: "Removed React import"
1116
+ });
1117
+ }
1118
+ ctx.replacements.sort((a, b) => b.start - a.start);
1119
+ const applied = /* @__PURE__ */ new Set();
1120
+ const deduped = [];
1121
+ for (const r of ctx.replacements) {
1122
+ const key = `${r.start}:${r.end}`;
1123
+ let overlaps = false;
1124
+ for (const d of deduped) if (r.start < d.end && r.end > d.start) {
1125
+ overlaps = true;
1126
+ break;
1127
+ }
1128
+ if (!overlaps && !applied.has(key)) {
1129
+ applied.add(key);
1130
+ deduped.push(r);
1131
+ }
1132
+ }
1133
+ let result = code;
1134
+ for (const r of deduped) result = result.slice(0, r.start) + r.text + result.slice(r.end);
1135
+ return result;
1136
+ }
1137
+ function insertPyreonImports(code, pyreonImports) {
1138
+ if (pyreonImports.size === 0) return code;
1139
+ const importLines = [];
1140
+ const sorted = [...pyreonImports.entries()].sort(([a], [b]) => a.localeCompare(b));
1141
+ for (const [source, specs] of sorted) {
1142
+ const specList = [...specs].sort().join(", ");
1143
+ importLines.push(`import { ${specList} } from "${source}"`);
1144
+ }
1145
+ const importBlock = importLines.join("\n");
1146
+ const lastImportEnd = findLastImportEnd(code);
1147
+ if (lastImportEnd > 0) return `${code.slice(0, lastImportEnd)}\n${importBlock}${code.slice(lastImportEnd)}`;
1148
+ return `${importBlock}\n\n${code}`;
1149
+ }
1150
+ function migrateVisitNode(ctx, node) {
1151
+ if (ts.isImportDeclaration(node)) migrateImportDeclaration(ctx, node);
1152
+ if (isCallToHook(node, "useState")) migrateUseState(ctx, node);
1153
+ if (isCallToEffectHook(node)) migrateUseEffect(ctx, node);
1154
+ if (isCallToHook(node, "useMemo")) migrateUseMemo(ctx, node);
1155
+ if (isCallToHook(node, "useCallback")) migrateUseCallback(ctx, node);
1156
+ if (isCallToHook(node, "useRef")) migrateUseRef(ctx, node);
1157
+ if (ts.isCallExpression(node)) migrateMemoWrapper(ctx, node);
1158
+ if (ts.isCallExpression(node)) migrateForwardRef(ctx, node);
1159
+ if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) migrateJsxAttributes(ctx, node);
1160
+ }
1161
+ function migrateVisit(ctx, node) {
1162
+ ts.forEachChild(node, (child) => {
1163
+ migrateVisitNode(ctx, child);
1164
+ migrateVisit(ctx, child);
1165
+ });
1166
+ }
1167
+ function migrateReactCode(code, filename = "input.tsx") {
1168
+ const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
1169
+ const diagnostics = detectReactPatterns(code, filename);
1170
+ const ctx = {
1171
+ sf,
1172
+ code,
1173
+ replacements: [],
1174
+ changes: [],
1175
+ pyreonImports: /* @__PURE__ */ new Map(),
1176
+ importsToRemove: /* @__PURE__ */ new Set(),
1177
+ specifierRewrites: /* @__PURE__ */ new Map()
1178
+ };
1179
+ migrateVisit(ctx, sf);
1180
+ let result = applyReplacements(code, ctx);
1181
+ result = insertPyreonImports(result, ctx.pyreonImports);
1182
+ result = result.replace(/\n{3,}/g, "\n\n");
1183
+ return {
1184
+ code: result,
1185
+ diagnostics,
1186
+ changes: ctx.changes
1187
+ };
1188
+ }
1189
+ function findParentJsxElement(node) {
1190
+ let current = node.parent;
1191
+ while (current) {
1192
+ if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current)) return current;
1193
+ if (ts.isJsxElement(current)) return current.openingElement;
1194
+ if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) return null;
1195
+ current = current.parent;
1196
+ }
1197
+ return null;
1198
+ }
1199
+ function getJsxTagName(node) {
1200
+ const tagName = node.tagName;
1201
+ if (ts.isIdentifier(tagName)) return tagName.text;
1202
+ return "";
1203
+ }
1204
+ function findLastImportEnd(code) {
1205
+ const importRe = /^import\s.+$/gm;
1206
+ let lastEnd = 0;
1207
+ let match;
1208
+ while (true) {
1209
+ match = importRe.exec(code);
1210
+ if (!match) break;
1211
+ lastEnd = match.index + match[0].length;
1212
+ }
1213
+ return lastEnd;
1214
+ }
1215
+ /** Fast regex check — returns true if code likely contains React patterns worth analyzing */
1216
+ function hasReactPatterns(code) {
1217
+ return /\bfrom\s+['"]react/.test(code) || /\bfrom\s+['"]react-dom/.test(code) || /\bfrom\s+['"]react-router/.test(code) || /\buseState\s*[<(]/.test(code) || /\buseEffect\s*\(/.test(code) || /\buseMemo\s*\(/.test(code) || /\buseCallback\s*\(/.test(code) || /\buseRef\s*[<(]/.test(code) || /\buseReducer\s*[<(]/.test(code) || /\bReact\.memo\b/.test(code) || /\bforwardRef\s*[<(]/.test(code) || /\bclassName[=\s]/.test(code) || /\bhtmlFor[=\s]/.test(code) || /\.value\s*=/.test(code);
1218
+ }
1219
+ const ERROR_PATTERNS = [
1220
+ {
1221
+ pattern: /Cannot read properties of undefined \(reading '(set|update|peek|subscribe)'\)/,
1222
+ diagnose: (m) => ({
1223
+ cause: `Calling .${m[1]}() on undefined. The signal variable is likely out of scope, misspelled, or not yet initialized.`,
1224
+ fix: "Check that the signal is defined and in scope. Signals must be created with signal() before use.",
1225
+ fixCode: `const mySignal = signal(initialValue)\nmySignal.${m[1]}(newValue)`
1226
+ })
1227
+ },
1228
+ {
1229
+ pattern: /(\w+) is not a function/,
1230
+ diagnose: (m) => ({
1231
+ cause: `'${m[1]}' is not callable. If this is a signal, you need to call it: ${m[1]}()`,
1232
+ fix: "Pyreon signals are callable functions. Read: signal(), Write: signal.set(value)",
1233
+ fixCode: `// Read value:\nconst value = ${m[1]}()\n// Set value:\n${m[1]}.set(newValue)`
1234
+ })
1235
+ },
1236
+ {
1237
+ pattern: /Cannot find module '(@pyreon\/\w[\w-]*)'/,
1238
+ diagnose: (m) => ({
1239
+ cause: `Package ${m[1]} is not installed.`,
1240
+ fix: `Run: bun add ${m[1]}`,
1241
+ fixCode: `bun add ${m[1]}`
1242
+ })
1243
+ },
1244
+ {
1245
+ pattern: /Cannot find module 'react'/,
1246
+ diagnose: () => ({
1247
+ cause: "Importing from 'react' in a Pyreon project.",
1248
+ fix: "Replace React imports with Pyreon equivalents.",
1249
+ fixCode: "// Instead of:\nimport { useState } from \"react\"\n// Use:\nimport { signal } from \"@pyreon/reactivity\""
1250
+ })
1251
+ },
1252
+ {
1253
+ pattern: /Property '(\w+)' does not exist on type 'Signal<\w+>'/,
1254
+ diagnose: (m) => ({
1255
+ cause: `Accessing .${m[1]} on a signal. Pyreon signals don't have a .${m[1]} property.`,
1256
+ fix: m[1] === "value" ? "Pyreon signals are callable functions, not .value getters. Call signal() to read, signal.set() to write." : `Signals have these methods: .set(), .update(), .peek(), .subscribe(). '${m[1]}' is not one of them.`,
1257
+ fixCode: m[1] === "value" ? "// Read: mySignal()\n// Write: mySignal.set(newValue)" : void 0
1258
+ })
1259
+ },
1260
+ {
1261
+ pattern: /Type '(\w+)' is not assignable to type 'VNode'/,
1262
+ diagnose: (m) => ({
1263
+ cause: `Component returned ${m[1]} instead of VNode. Components must return JSX, null, or a string.`,
1264
+ fix: "Make sure your component returns a JSX element, null, or a string.",
1265
+ fixCode: "const MyComponent = (props) => {\n return <div>{props.children}</div>\n}"
1266
+ })
1267
+ },
1268
+ {
1269
+ pattern: /onMount callback must return/,
1270
+ diagnose: () => ({
1271
+ cause: "onMount expects a return of CleanupFn | undefined, not void.",
1272
+ fix: "Return undefined explicitly, or return a cleanup function.",
1273
+ fixCode: "onMount(() => {\n // setup code\n return undefined\n})"
1274
+ })
1275
+ },
1276
+ {
1277
+ pattern: /Expected 'by' prop on <For>/,
1278
+ diagnose: () => ({
1279
+ cause: "<For> requires a 'by' prop for efficient keyed reconciliation.",
1280
+ fix: "Add a by prop that returns a unique key for each item.",
1281
+ fixCode: "<For each={items()} by={item => item.id}>\n {item => <li>{item.name}</li>}\n</For>"
1282
+ })
1283
+ },
1284
+ {
1285
+ pattern: /useHook.*outside.*component/i,
1286
+ diagnose: () => ({
1287
+ cause: "Hook called outside a component function. Pyreon hooks must be called during component setup.",
1288
+ fix: "Move the hook call inside a component function body."
1289
+ })
1290
+ },
1291
+ {
1292
+ pattern: /Hydration mismatch/,
1293
+ diagnose: () => ({
1294
+ cause: "Server-rendered HTML doesn't match client-rendered output.",
1295
+ fix: "Ensure SSR and client render the same initial content. Check for browser-only APIs (window, document) in SSR code.",
1296
+ related: "Use typeof window !== 'undefined' checks or onMount() for client-only code."
1297
+ })
1298
+ }
1299
+ ];
1300
+ /** Diagnose an error message and return structured fix information */
1301
+ function diagnoseError(error) {
1302
+ for (const { pattern, diagnose } of ERROR_PATTERNS) {
1303
+ const match = error.match(pattern);
1304
+ if (match) return diagnose(match);
1305
+ }
1306
+ return null;
1307
+ }
1308
+
1309
+ //#endregion
1310
+ export { detectReactPatterns, diagnoseError, hasReactPatterns, migrateReactCode, transformJSX };
638
1311
  //# sourceMappingURL=index.js.map