@pyreon/compiler 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +2081 -1262
- package/lib/types/index.d.ts +310 -125
- package/package.json +14 -12
- package/src/defer-inline.ts +397 -157
- package/src/index.ts +14 -2
- package/src/jsx.ts +784 -19
- package/src/load-native.ts +1 -0
- package/src/manifest.ts +280 -0
- package/src/pyreon-intercept.ts +164 -0
- package/src/react-intercept.ts +59 -0
- package/src/reactivity-lens.ts +190 -0
- package/src/tests/backend-parity-r7-r9.test.ts +91 -0
- package/src/tests/backend-prop-derived-callback-divergence.test.ts +74 -0
- package/src/tests/collapse-bail-census.test.ts +245 -0
- package/src/tests/collapse-key-source-hygiene.test.ts +88 -0
- package/src/tests/defer-inline.test.ts +209 -21
- package/src/tests/detector-tag-consistency.test.ts +2 -0
- package/src/tests/element-valued-const-child.test.ts +61 -0
- package/src/tests/falsy-child-characterization.test.ts +48 -0
- package/src/tests/malformed-input-resilience.test.ts +50 -0
- package/src/tests/manifest-snapshot.test.ts +55 -0
- package/src/tests/native-equivalence.test.ts +104 -3
- package/src/tests/partial-collapse-detector.test.ts +121 -0
- package/src/tests/partial-collapse-emit.test.ts +104 -0
- package/src/tests/partial-collapse-robustness.test.ts +53 -0
- package/src/tests/prop-derived-shadow.test.ts +96 -0
- package/src/tests/pure-call-reactive-args.test.ts +50 -0
- package/src/tests/pyreon-intercept.test.ts +189 -0
- package/src/tests/r13-callback-stmt-equivalence.test.ts +58 -0
- package/src/tests/r14-ssr-mode-parity.test.ts +51 -0
- package/src/tests/r15-elemconst-propderived.test.ts +47 -0
- package/src/tests/r19-defer-inline-robust.test.ts +54 -0
- package/src/tests/r20-backend-equivalence-sweep.test.ts +50 -0
- package/src/tests/react-intercept.test.ts +50 -2
- package/src/tests/reactivity-lens.test.ts +170 -0
- package/src/tests/rocketstyle-collapse.test.ts +208 -0
- package/src/tests/signal-autocall-shadow.test.ts +86 -0
- package/src/tests/sourcemap-fidelity.test.ts +77 -0
- package/src/tests/static-text-baking.test.ts +64 -0
- package/src/tests/transform-state-isolation.test.ts +49 -0
package/lib/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { parseSync } from "oxc-parser";
|
|
2
|
+
import MagicString from "magic-string";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
import * as path from "node:path";
|
|
5
6
|
import { dirname, join, relative, resolve } from "node:path";
|
|
7
|
+
import ts from "typescript";
|
|
6
8
|
import * as fs from "node:fs";
|
|
7
9
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
8
|
-
import ts from "typescript";
|
|
9
10
|
|
|
10
11
|
//#region src/defer-inline.ts
|
|
11
12
|
/**
|
|
@@ -14,40 +15,38 @@ import ts from "typescript";
|
|
|
14
15
|
* Rewrites:
|
|
15
16
|
*
|
|
16
17
|
* import { Modal } from './Modal'
|
|
17
|
-
* <Defer when={open()}><Modal /></Defer>
|
|
18
|
+
* <Defer when={open()}><Modal title="hi" /></Defer>
|
|
18
19
|
*
|
|
19
20
|
* into:
|
|
20
21
|
*
|
|
21
|
-
* <Defer when={open()} chunk={() => import('./Modal').then(
|
|
22
|
-
* {
|
|
22
|
+
* <Defer when={open()} chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}>
|
|
23
|
+
* {(__C) => <__C title="hi" />}
|
|
23
24
|
* </Defer>
|
|
24
25
|
*
|
|
25
26
|
* The static `import { Modal } from './Modal'` is removed when `Modal` is
|
|
26
27
|
* referenced ONLY inside the Defer subtree — otherwise Rolldown would
|
|
27
28
|
* bundle the module statically and the dynamic import becomes a no-op.
|
|
28
29
|
*
|
|
29
|
-
* Scope
|
|
30
|
-
* -
|
|
31
|
-
* - Children: exactly ONE JSXElement,
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
30
|
+
* Scope (v2 — post #587 + this PR):
|
|
31
|
+
* - Multiple Defer elements per file: each rewritten independently.
|
|
32
|
+
* - Children: exactly ONE JSXElement, capitalised name (component
|
|
33
|
+
* reference). Self-closing OR with children. **Props ARE allowed**
|
|
34
|
+
* (post-v2) and pass through unchanged into the render-prop body —
|
|
35
|
+
* closure capture works naturally because the render-prop arrow
|
|
36
|
+
* captures the surrounding lexical scope.
|
|
37
|
+
* - Multiple non-whitespace children → bail with a warning.
|
|
38
|
+
* User must use the explicit `chunk` form with a render-prop.
|
|
39
|
+
* - Imports: default, named, **renamed** (`{ X as Y }`). Namespace
|
|
40
|
+
* imports (`* as M` + `<M.X />`) NOT supported — bail with a warning.
|
|
41
|
+
* - **Multi-specifier imports** (`{ A, B } from './x'`): only the
|
|
42
|
+
* binding used in Defer is removed; siblings stay intact.
|
|
36
43
|
* - Triggers (`when={...}`, `on="visible"`, `on="idle"`) pass through.
|
|
37
44
|
* - Other props on `<Defer>` (e.g. `fallback`) pass through.
|
|
38
45
|
*
|
|
39
46
|
* The transform is intentionally conservative — anything unusual leaves
|
|
40
|
-
* the source unchanged + emits a warning.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* Pipeline: this runs BEFORE `transformJSX()` in the vite plugin. The
|
|
44
|
-
* output is still JSX — `transformJSX` then converts it to `h()` /
|
|
45
|
-
* `_tpl()` calls as usual.
|
|
46
|
-
*/
|
|
47
|
-
/**
|
|
48
|
-
* Detect the language for `parseSync`. `oxc-parser` infers from filename
|
|
49
|
-
* by extension — we mirror the same logic for the few extensions we
|
|
50
|
-
* support so the parser is invoked correctly.
|
|
47
|
+
* the source unchanged + emits a warning. Pipeline: runs BEFORE
|
|
48
|
+
* `transformJSX()` in the vite plugin. The output is still JSX —
|
|
49
|
+
* `transformJSX` then converts to runtime calls as usual.
|
|
51
50
|
*/
|
|
52
51
|
function getLang$1(filename) {
|
|
53
52
|
if (filename.endsWith(".tsx")) return "tsx";
|
|
@@ -57,9 +56,9 @@ function getLang$1(filename) {
|
|
|
57
56
|
}
|
|
58
57
|
/**
|
|
59
58
|
* Returns the JSX tag name as a string when the opening element's name
|
|
60
|
-
* is a simple identifier (
|
|
61
|
-
*
|
|
62
|
-
* (
|
|
59
|
+
* is a simple identifier (`<Modal />`). Member-expression names
|
|
60
|
+
* (`<M.Modal />`) and namespaced names (`<svg:rect />`) return null —
|
|
61
|
+
* use `getJsxMemberName()` for the namespace-import case.
|
|
63
62
|
*/
|
|
64
63
|
function getJsxName(node) {
|
|
65
64
|
const open = node.openingElement;
|
|
@@ -69,33 +68,83 @@ function getJsxName(node) {
|
|
|
69
68
|
return name.name;
|
|
70
69
|
}
|
|
71
70
|
/**
|
|
72
|
-
* `<
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
71
|
+
* For `<M.Modal />` — returns `{ object: 'M', property: 'Modal' }` when
|
|
72
|
+
* the JSX name is a depth-1 JSXMemberExpression. Returns null for any
|
|
73
|
+
* other shape (deeper nesting like `<M.Sub.X />`, JSXNamespacedName,
|
|
74
|
+
* non-identifier). The depth-1 restriction keeps the rewrite simple:
|
|
75
|
+
* `M.Modal` is replaced with `__C` in the source.
|
|
77
76
|
*/
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
77
|
+
function getJsxMemberName(node) {
|
|
78
|
+
const open = node.openingElement;
|
|
79
|
+
if (!open) return null;
|
|
80
|
+
const name = open.name;
|
|
81
|
+
if (!name || name.type !== "JSXMemberExpression") return null;
|
|
82
|
+
const obj = name.object;
|
|
83
|
+
const prop = name.property;
|
|
84
|
+
if (!obj || obj.type !== "JSXIdentifier") return null;
|
|
85
|
+
if (!prop || prop.type !== "JSXIdentifier") return null;
|
|
86
|
+
return {
|
|
87
|
+
object: obj.name,
|
|
88
|
+
property: prop.name
|
|
89
|
+
};
|
|
89
90
|
}
|
|
90
91
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
92
|
+
* Verify a JSX child node is a single component-element we can rewrite.
|
|
93
|
+
* Handles two shapes:
|
|
94
|
+
* 1. `<Modal />` — identifier name, capitalised (component, not HTML).
|
|
95
|
+
* 2. `<M.Modal />` — depth-1 member expression with capitalised
|
|
96
|
+
* property name. The object (`M`) is the local binding to look up;
|
|
97
|
+
* the property (`Modal`) is the actual export to extract.
|
|
98
|
+
*
|
|
99
|
+
* Both shapes allow props (post-v2). Deeper nesting (`<M.Sub.X />`),
|
|
100
|
+
* JSXNamespacedName (`<svg:rect />`), and non-component lowercase names
|
|
101
|
+
* return null.
|
|
94
102
|
*/
|
|
103
|
+
function analyzeChildElement(node) {
|
|
104
|
+
if (node.type !== "JSXElement") return null;
|
|
105
|
+
const openName = node.openingElement.name;
|
|
106
|
+
const close = node.closingElement;
|
|
107
|
+
const identName = getJsxName(node);
|
|
108
|
+
if (identName) {
|
|
109
|
+
if (!/^[A-Z]/.test(identName)) return null;
|
|
110
|
+
return {
|
|
111
|
+
kind: "identifier",
|
|
112
|
+
lookupName: identName,
|
|
113
|
+
propertyName: "",
|
|
114
|
+
openNameRange: {
|
|
115
|
+
start: openName.start,
|
|
116
|
+
end: openName.end
|
|
117
|
+
},
|
|
118
|
+
closeNameRange: close ? {
|
|
119
|
+
start: close.name.start,
|
|
120
|
+
end: close.name.end
|
|
121
|
+
} : null
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const memberName = getJsxMemberName(node);
|
|
125
|
+
if (memberName) {
|
|
126
|
+
if (!/^[A-Z]/.test(memberName.property)) return null;
|
|
127
|
+
return {
|
|
128
|
+
kind: "member",
|
|
129
|
+
lookupName: memberName.object,
|
|
130
|
+
propertyName: memberName.property,
|
|
131
|
+
openNameRange: {
|
|
132
|
+
start: openName.start,
|
|
133
|
+
end: openName.end
|
|
134
|
+
},
|
|
135
|
+
closeNameRange: close ? {
|
|
136
|
+
start: close.name.start,
|
|
137
|
+
end: close.name.end
|
|
138
|
+
} : null
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
/** Filter whitespace-only JSXText nodes (formatting noise between JSX elements). */
|
|
95
144
|
function nonWhitespaceChildren(node) {
|
|
96
145
|
return (node.children ?? []).filter((c) => !(c.type === "JSXText" && /^\s*$/.test(c.value)));
|
|
97
146
|
}
|
|
98
|
-
function findDeferMatches(program) {
|
|
147
|
+
function findDeferMatches(program, warnings, code) {
|
|
99
148
|
const matches = [];
|
|
100
149
|
const walk = (node) => {
|
|
101
150
|
if (!node || typeof node !== "object") return;
|
|
@@ -103,20 +152,36 @@ function findDeferMatches(program) {
|
|
|
103
152
|
const open = node.openingElement;
|
|
104
153
|
if (!(open.attributes ?? []).some((a) => a.type === "JSXAttribute" && a.name?.type === "JSXIdentifier" && a.name.name === "chunk")) {
|
|
105
154
|
const live = nonWhitespaceChildren(node);
|
|
106
|
-
if (live.length
|
|
107
|
-
const
|
|
108
|
-
|
|
155
|
+
if (live.length > 1) {
|
|
156
|
+
const loc = getLoc(code, node.start ?? 0);
|
|
157
|
+
warnings.push({
|
|
158
|
+
message: `<Defer> inline form requires exactly one component child (got ${live.length}). Wrap the children in a single component, or use the explicit \`chunk\` prop with a render-prop body.`,
|
|
159
|
+
line: loc.line,
|
|
160
|
+
column: loc.column,
|
|
161
|
+
code: "defer-inline/multiple-children"
|
|
162
|
+
});
|
|
163
|
+
} else if (live.length === 1) {
|
|
164
|
+
const analysis = analyzeChildElement(live[0]);
|
|
165
|
+
if (analysis) {
|
|
109
166
|
const close = node.closingElement;
|
|
110
167
|
matches.push({
|
|
111
168
|
node,
|
|
112
169
|
child: live[0],
|
|
113
|
-
|
|
170
|
+
childAnalysis: analysis,
|
|
114
171
|
insertChunkAt: open.end - 1,
|
|
115
172
|
childrenRange: {
|
|
116
173
|
start: open.end,
|
|
117
174
|
end: close?.start ?? node.end
|
|
118
175
|
}
|
|
119
176
|
});
|
|
177
|
+
} else {
|
|
178
|
+
const loc = getLoc(code, live[0].start ?? 0);
|
|
179
|
+
warnings.push({
|
|
180
|
+
message: `<Defer> inline form requires a single component-element child (capitalised JSX identifier). Use the explicit \`chunk\` prop for any other shape.`,
|
|
181
|
+
line: loc.line,
|
|
182
|
+
column: loc.column,
|
|
183
|
+
code: "defer-inline/non-component-child"
|
|
184
|
+
});
|
|
120
185
|
}
|
|
121
186
|
}
|
|
122
187
|
}
|
|
@@ -131,69 +196,117 @@ function findDeferMatches(program) {
|
|
|
131
196
|
walk(program);
|
|
132
197
|
return matches;
|
|
133
198
|
}
|
|
134
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Locate the import declaration that binds `localName`.
|
|
201
|
+
*
|
|
202
|
+
* For namespace imports (`import * as M`), `localName` is the namespace
|
|
203
|
+
* identifier (`M`). The caller's `propertyName` provides the actual
|
|
204
|
+
* export name to extract — `findImportFor` returns `importedName: ''`
|
|
205
|
+
* for the namespace case and the caller substitutes its own property.
|
|
206
|
+
*/
|
|
207
|
+
function findImportFor(program, localName) {
|
|
135
208
|
const body = program.body ?? [];
|
|
136
209
|
for (const stmt of body) {
|
|
137
210
|
if (stmt.type !== "ImportDeclaration") continue;
|
|
138
211
|
const specifiers = stmt.specifiers ?? [];
|
|
139
212
|
for (const spec of specifiers) if (spec.type === "ImportDefaultSpecifier") {
|
|
140
|
-
if (spec.local.name ===
|
|
141
|
-
|
|
213
|
+
if (spec.local.name === localName) return {
|
|
214
|
+
declaration: stmt,
|
|
215
|
+
specifier: spec,
|
|
142
216
|
source: stmt.source.value,
|
|
143
|
-
kind: "default"
|
|
217
|
+
kind: "default",
|
|
218
|
+
importedName: "default"
|
|
144
219
|
};
|
|
145
220
|
} else if (spec.type === "ImportSpecifier") {
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
149
|
-
|
|
221
|
+
const lname = spec.local.name;
|
|
222
|
+
const iname = spec.imported?.name;
|
|
223
|
+
if (lname === localName && iname !== void 0) return {
|
|
224
|
+
declaration: stmt,
|
|
225
|
+
specifier: spec,
|
|
150
226
|
source: stmt.source.value,
|
|
151
|
-
kind: "named"
|
|
227
|
+
kind: "named",
|
|
228
|
+
importedName: iname
|
|
229
|
+
};
|
|
230
|
+
} else if (spec.type === "ImportNamespaceSpecifier") {
|
|
231
|
+
if (spec.local.name === localName) return {
|
|
232
|
+
declaration: stmt,
|
|
233
|
+
specifier: spec,
|
|
234
|
+
source: stmt.source.value,
|
|
235
|
+
kind: "namespace",
|
|
236
|
+
importedName: ""
|
|
152
237
|
};
|
|
153
238
|
}
|
|
154
239
|
}
|
|
155
240
|
return null;
|
|
156
241
|
}
|
|
157
242
|
/**
|
|
158
|
-
* Count references to `
|
|
159
|
-
*
|
|
160
|
-
*
|
|
243
|
+
* Count references to `localName` outside the given Defer subtree, AND
|
|
244
|
+
* outside the import declaration that defines it. The static import can
|
|
245
|
+
* only be safely removed when the local binding is used EXCLUSIVELY
|
|
246
|
+
* inside that Defer subtree — otherwise removing the import would break
|
|
247
|
+
* the other usage AND the dynamic import would be a no-op (Rolldown
|
|
248
|
+
* static-bundles the module on shared usage).
|
|
161
249
|
*/
|
|
162
|
-
function countReferencesOutside(program,
|
|
250
|
+
function countReferencesOutside(program, localName, skipSubtree, skipDeclaration) {
|
|
163
251
|
let count = 0;
|
|
164
252
|
const skipStart = skipSubtree.start;
|
|
165
253
|
const skipEnd = skipSubtree.end;
|
|
166
|
-
const
|
|
254
|
+
const declStart = skipDeclaration.start;
|
|
255
|
+
const declEnd = skipDeclaration.end;
|
|
256
|
+
const inSkip = (s, e) => s >= skipStart && e <= skipEnd || s >= declStart && e <= declEnd;
|
|
257
|
+
const visit = (node) => {
|
|
167
258
|
if (!node || typeof node !== "object") return;
|
|
168
259
|
const ns = node.start;
|
|
169
260
|
const ne = node.end;
|
|
170
|
-
if (typeof ns === "number" && typeof ne === "number" && ns
|
|
171
|
-
if (node.type === "Identifier" && node.name ===
|
|
172
|
-
if (node.type === "JSXIdentifier" && node.name ===
|
|
261
|
+
if (typeof ns === "number" && typeof ne === "number" && inSkip(ns, ne)) return;
|
|
262
|
+
if (node.type === "Identifier" && node.name === localName) count++;
|
|
263
|
+
if (node.type === "JSXIdentifier" && node.name === localName) count++;
|
|
173
264
|
for (const key in node) {
|
|
174
265
|
if (key === "parent") continue;
|
|
175
266
|
const v = node[key];
|
|
176
|
-
if (Array.isArray(v)) for (const item of v)
|
|
177
|
-
else if (v && typeof v === "object" && typeof v.type === "string")
|
|
267
|
+
if (Array.isArray(v)) for (const item of v) visit(item);
|
|
268
|
+
else if (v && typeof v === "object" && typeof v.type === "string") visit(v);
|
|
178
269
|
}
|
|
179
270
|
};
|
|
180
271
|
const body = program.body ?? [];
|
|
181
|
-
for (const stmt of body)
|
|
182
|
-
if (stmt.type === "ImportDeclaration") continue;
|
|
183
|
-
countInNode(stmt);
|
|
184
|
-
}
|
|
272
|
+
for (const stmt of body) visit(stmt);
|
|
185
273
|
return count;
|
|
186
274
|
}
|
|
187
|
-
/**
|
|
188
|
-
|
|
275
|
+
/**
|
|
276
|
+
* Build the chunk={...} attribute string.
|
|
277
|
+
*
|
|
278
|
+
* - `default` → `chunk={() => import('./x')}`. The default export IS the
|
|
279
|
+
* component; no re-wrapping needed.
|
|
280
|
+
* - `named` / `namespace` → `chunk={() => import('./x').then((__m) => ({
|
|
281
|
+
* default: __m.X }))}`. The `default` slot points at the named export
|
|
282
|
+
* (for `named`) or the member-expression property (for `namespace`).
|
|
283
|
+
*
|
|
284
|
+
* The caller picks `exportName` — for `named`, it's `info.importedName`;
|
|
285
|
+
* for `namespace`, it's the JSX member-expression property.
|
|
286
|
+
*/
|
|
287
|
+
function buildChunkAttr(source, kind, exportName) {
|
|
189
288
|
if (kind === "default") return ` chunk={() => import('${source}')}`;
|
|
190
|
-
return ` chunk={() => import('${source}').then((__m) => ({ default: __m.${
|
|
289
|
+
return ` chunk={() => import('${source}').then((__m) => ({ default: __m.${exportName} }))}`;
|
|
191
290
|
}
|
|
192
291
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
292
|
+
* Build the render-prop body from the original child JSX. Replaces the
|
|
293
|
+
* component name (in both opening and closing tags) with `__C` — every
|
|
294
|
+
* other character of the original source survives verbatim. Props /
|
|
295
|
+
* nested children / event handlers / closure captures all flow through
|
|
296
|
+
* unchanged. The render-prop arrow's lexical scope captures whatever
|
|
297
|
+
* was in scope at the call site.
|
|
196
298
|
*/
|
|
299
|
+
function buildRenderPropBody(code, analysis, childRange) {
|
|
300
|
+
const start = childRange.start;
|
|
301
|
+
const end = childRange.end;
|
|
302
|
+
let body = code.slice(start, end);
|
|
303
|
+
if (analysis.closeNameRange) {
|
|
304
|
+
const r = analysis.closeNameRange;
|
|
305
|
+
body = body.slice(0, r.start - start) + "__C" + body.slice(r.end - start);
|
|
306
|
+
}
|
|
307
|
+
body = body.slice(0, analysis.openNameRange.start - start) + "__C" + body.slice(analysis.openNameRange.end - start);
|
|
308
|
+
return `{(__C) => ${body}}`;
|
|
309
|
+
}
|
|
197
310
|
function applyEdits(source, edits) {
|
|
198
311
|
const sorted = [...edits].sort((a, b) => b.start - a.start);
|
|
199
312
|
let out = source;
|
|
@@ -201,22 +314,44 @@ function applyEdits(source, edits) {
|
|
|
201
314
|
return out;
|
|
202
315
|
}
|
|
203
316
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* -
|
|
209
|
-
*
|
|
210
|
-
* -
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* - Child name isn't imported → can't resolve the chunk source
|
|
216
|
-
* - Child binding is used outside the Defer subtree → can't remove
|
|
217
|
-
* the static import (dynamic import would be a no-op via Rolldown's
|
|
218
|
-
* same-module dedup)
|
|
317
|
+
* Compute the edit that removes the import binding for the given match.
|
|
318
|
+
* Handles three shapes:
|
|
319
|
+
* 1. Single-specifier declaration → remove the entire ImportDeclaration
|
|
320
|
+
* (including its trailing newline).
|
|
321
|
+
* 2. Multi-specifier declaration where this is the FIRST specifier →
|
|
322
|
+
* remove the specifier + the comma + whitespace that follows it.
|
|
323
|
+
* 3. Multi-specifier declaration where this is a LATER specifier →
|
|
324
|
+
* remove the preceding comma + whitespace + the specifier.
|
|
325
|
+
*
|
|
326
|
+
* Case (1) is the simple v1 path; cases (2) and (3) are the v2
|
|
327
|
+
* multi-specifier handling.
|
|
219
328
|
*/
|
|
329
|
+
function buildImportRemovalEdit(code, info) {
|
|
330
|
+
const specifiers = info.declaration.specifiers ?? [];
|
|
331
|
+
if (specifiers.length === 1) {
|
|
332
|
+
const start = info.declaration.start;
|
|
333
|
+
let end = info.declaration.end;
|
|
334
|
+
if (code[end] === "\n") end += 1;
|
|
335
|
+
return {
|
|
336
|
+
start,
|
|
337
|
+
end,
|
|
338
|
+
replacement: ""
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const idx = specifiers.indexOf(info.specifier);
|
|
342
|
+
const specStart = info.specifier.start;
|
|
343
|
+
const specEnd = info.specifier.end;
|
|
344
|
+
if (idx === 0) return {
|
|
345
|
+
start: specStart,
|
|
346
|
+
end: specifiers[1].start,
|
|
347
|
+
replacement: ""
|
|
348
|
+
};
|
|
349
|
+
return {
|
|
350
|
+
start: specifiers[idx - 1].end,
|
|
351
|
+
end: specEnd,
|
|
352
|
+
replacement: ""
|
|
353
|
+
};
|
|
354
|
+
}
|
|
220
355
|
function transformDeferInline(code, filename = "input.tsx") {
|
|
221
356
|
const warnings = [];
|
|
222
357
|
if (!code.includes("Defer")) return {
|
|
@@ -237,7 +372,7 @@ function transformDeferInline(code, filename = "input.tsx") {
|
|
|
237
372
|
warnings
|
|
238
373
|
};
|
|
239
374
|
}
|
|
240
|
-
const matches = findDeferMatches(program);
|
|
375
|
+
const matches = findDeferMatches(program, warnings, code);
|
|
241
376
|
if (matches.length === 0) return {
|
|
242
377
|
code,
|
|
243
378
|
changed: false,
|
|
@@ -246,45 +381,51 @@ function transformDeferInline(code, filename = "input.tsx") {
|
|
|
246
381
|
const edits = [];
|
|
247
382
|
let changed = false;
|
|
248
383
|
for (const m of matches) {
|
|
249
|
-
const
|
|
384
|
+
const displayName = m.childAnalysis.kind === "member" ? `${m.childAnalysis.lookupName}.${m.childAnalysis.propertyName}` : m.childAnalysis.lookupName;
|
|
385
|
+
const importInfo = findImportFor(program, m.childAnalysis.lookupName);
|
|
250
386
|
if (!importInfo) {
|
|
251
387
|
const loc = getLoc(code, m.child.start ?? 0);
|
|
252
388
|
warnings.push({
|
|
253
|
-
message: `<Defer>'s inline child <${
|
|
389
|
+
message: `<Defer>'s inline child <${displayName} /> isn't imported — can't resolve a chunk source. Use the explicit \`chunk\` prop, or import ${m.childAnalysis.lookupName} from a module.`,
|
|
254
390
|
line: loc.line,
|
|
255
391
|
column: loc.column,
|
|
256
392
|
code: "defer-inline/import-not-found"
|
|
257
393
|
});
|
|
258
394
|
continue;
|
|
259
395
|
}
|
|
260
|
-
if (
|
|
396
|
+
if (m.childAnalysis.kind === "member" && importInfo.kind !== "namespace") {
|
|
397
|
+
const loc = getLoc(code, m.child.start ?? 0);
|
|
398
|
+
warnings.push({
|
|
399
|
+
message: `<Defer>'s inline child <${displayName} /> uses a member expression but \`${m.childAnalysis.lookupName}\` isn't a namespace import. Inline form requires \`import * as ${m.childAnalysis.lookupName} from '...'\`. Use the explicit \`chunk\` prop for other shapes.`,
|
|
400
|
+
line: loc.line,
|
|
401
|
+
column: loc.column,
|
|
402
|
+
code: "defer-inline/unsupported-import-shape"
|
|
403
|
+
});
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (m.childAnalysis.kind === "identifier" && importInfo.kind === "namespace") continue;
|
|
407
|
+
if (countReferencesOutside(program, m.childAnalysis.lookupName, m.node, importInfo.declaration) > 0) {
|
|
261
408
|
const loc = getLoc(code, m.node.start ?? 0);
|
|
262
409
|
warnings.push({
|
|
263
|
-
message: `<Defer>'s inline child <${
|
|
410
|
+
message: `<Defer>'s inline child <${displayName} /> is also referenced elsewhere in this file. Inline form requires the import to be used exclusively inside this Defer. Use the explicit \`chunk\` prop form to split despite shared usage.`,
|
|
264
411
|
line: loc.line,
|
|
265
412
|
column: loc.column,
|
|
266
413
|
code: "defer-inline/import-used-elsewhere"
|
|
267
414
|
});
|
|
268
415
|
continue;
|
|
269
416
|
}
|
|
417
|
+
const exportName = importInfo.kind === "namespace" ? m.childAnalysis.propertyName : importInfo.importedName;
|
|
270
418
|
edits.push({
|
|
271
419
|
start: m.insertChunkAt,
|
|
272
420
|
end: m.insertChunkAt,
|
|
273
|
-
replacement: buildChunkAttr(importInfo.source, importInfo.kind,
|
|
421
|
+
replacement: buildChunkAttr(importInfo.source, importInfo.kind, exportName)
|
|
274
422
|
});
|
|
275
423
|
edits.push({
|
|
276
424
|
start: m.childrenRange.start,
|
|
277
425
|
end: m.childrenRange.end,
|
|
278
|
-
replacement:
|
|
279
|
-
});
|
|
280
|
-
const impStart = importInfo.node.start;
|
|
281
|
-
let impEnd = importInfo.node.end;
|
|
282
|
-
if (code[impEnd] === "\n") impEnd += 1;
|
|
283
|
-
edits.push({
|
|
284
|
-
start: impStart,
|
|
285
|
-
end: impEnd,
|
|
286
|
-
replacement: ""
|
|
426
|
+
replacement: buildRenderPropBody(code, m.childAnalysis, m.childrenRange)
|
|
287
427
|
});
|
|
428
|
+
edits.push(buildImportRemovalEdit(code, importInfo));
|
|
288
429
|
changed = true;
|
|
289
430
|
}
|
|
290
431
|
if (!changed) return {
|
|
@@ -298,7 +439,6 @@ function transformDeferInline(code, filename = "input.tsx") {
|
|
|
298
439
|
warnings
|
|
299
440
|
};
|
|
300
441
|
}
|
|
301
|
-
/** Resolve a byte offset into 1-based line + 0-based column. */
|
|
302
442
|
function getLoc(code, offset) {
|
|
303
443
|
let line = 1;
|
|
304
444
|
let lastNl = -1;
|
|
@@ -518,6 +658,21 @@ const DELEGATED_EVENTS = new Set([
|
|
|
518
658
|
"touchmove",
|
|
519
659
|
"submit"
|
|
520
660
|
]);
|
|
661
|
+
/**
|
|
662
|
+
* Canonical key for a collapsible rocketstyle call site. The Vite plugin
|
|
663
|
+
* computes this when it resolves a site; the compiler recomputes the
|
|
664
|
+
* IDENTICAL key from the JSX node to look the resolution up. Stable
|
|
665
|
+
* ordering of props so attribute order in source doesn't change the key.
|
|
666
|
+
*/
|
|
667
|
+
function rocketstyleCollapseKey(componentName, props, childrenText) {
|
|
668
|
+
const src = `${componentName}\u0000${Object.keys(props).sort().map((k) => `${k}=${props[k]}`).join("")}\u0000${childrenText}`;
|
|
669
|
+
let h = 2166136261;
|
|
670
|
+
for (let i = 0; i < src.length; i++) {
|
|
671
|
+
h ^= src.charCodeAt(i);
|
|
672
|
+
h = Math.imul(h, 16777619);
|
|
673
|
+
}
|
|
674
|
+
return (h >>> 0).toString(36);
|
|
675
|
+
}
|
|
521
676
|
function getLang(filename) {
|
|
522
677
|
if (filename.endsWith(".jsx")) return "jsx";
|
|
523
678
|
return "tsx";
|
|
@@ -570,9 +725,165 @@ function jsxAttrs(node) {
|
|
|
570
725
|
function jsxChildren(node) {
|
|
571
726
|
return node.children ?? [];
|
|
572
727
|
}
|
|
728
|
+
/**
|
|
729
|
+
* Build a `localName → { imported, source }` table from a module's
|
|
730
|
+
* import declarations. Only named imports (`import { X as Y }`) are
|
|
731
|
+
* relevant — the collapsible components are always named exports.
|
|
732
|
+
*/
|
|
733
|
+
function collectImportTable(program) {
|
|
734
|
+
const table = /* @__PURE__ */ new Map();
|
|
735
|
+
for (const stmt of program.body ?? []) {
|
|
736
|
+
if (stmt.type !== "ImportDeclaration") continue;
|
|
737
|
+
const source = stmt.source?.value;
|
|
738
|
+
if (typeof source !== "string") continue;
|
|
739
|
+
for (const spec of stmt.specifiers ?? []) {
|
|
740
|
+
if (spec.type !== "ImportSpecifier") continue;
|
|
741
|
+
const local = spec.local?.name;
|
|
742
|
+
const imported = spec.imported?.name ?? local;
|
|
743
|
+
if (typeof local === "string") table.set(local, {
|
|
744
|
+
imported,
|
|
745
|
+
source
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return table;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Pure detector — finds every collapsible rocketstyle call site in a
|
|
753
|
+
* module. Used by `@pyreon/vite-plugin` to know which (component, props,
|
|
754
|
+
* text) tuples to SSR-resolve. The bail catalogue here MUST stay
|
|
755
|
+
* byte-identical to `tryRocketstyleCollapse`'s (RFC decision 3): a
|
|
756
|
+
* candidate PascalCase tag whose import source is in `collapsibleSources`,
|
|
757
|
+
* every attr a plain string literal (no spread, no `{expr}`, no boolean
|
|
758
|
+
* attr), children empty or static text only. A consistency test asserts
|
|
759
|
+
* the keys this produces equal the keys the compiler looks up.
|
|
760
|
+
*/
|
|
761
|
+
function scanCollapsibleSites(code, filename, collapsibleSources) {
|
|
762
|
+
let program;
|
|
763
|
+
try {
|
|
764
|
+
program = parseSync(filename, code, {
|
|
765
|
+
sourceType: "module",
|
|
766
|
+
lang: getLang(filename)
|
|
767
|
+
}).program;
|
|
768
|
+
} catch {
|
|
769
|
+
return [];
|
|
770
|
+
}
|
|
771
|
+
const imports = collectImportTable(program);
|
|
772
|
+
const out = [];
|
|
773
|
+
const visit = (node) => {
|
|
774
|
+
if (!node || typeof node !== "object") return;
|
|
775
|
+
if (node.type === "JSXElement") {
|
|
776
|
+
const tag = jsxTagName(node);
|
|
777
|
+
const imp = tag ? imports.get(tag) : void 0;
|
|
778
|
+
if (tag && tag.charAt(0) !== tag.charAt(0).toLowerCase() && imp && collapsibleSources.has(imp.source)) {
|
|
779
|
+
const site = detectCollapsibleShape(node, tag);
|
|
780
|
+
if (site) out.push({
|
|
781
|
+
componentName: tag,
|
|
782
|
+
source: imp.source,
|
|
783
|
+
importedName: imp.imported,
|
|
784
|
+
props: site.props,
|
|
785
|
+
childrenText: site.childrenText,
|
|
786
|
+
key: rocketstyleCollapseKey(tag, site.props, site.childrenText)
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
for (const k in node) {
|
|
791
|
+
const v = node[k];
|
|
792
|
+
if (Array.isArray(v)) for (const c of v) visit(c);
|
|
793
|
+
else if (v && typeof v === "object" && typeof v.type === "string") visit(v);
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
visit(program);
|
|
797
|
+
return out;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* The shared bail catalogue — every attr a string literal (no spread, no
|
|
801
|
+
* `{expr}`, no boolean attr), children empty or static text. Returns the
|
|
802
|
+
* extracted {props, childrenText} or null (bail). `tryRocketstyleCollapse`
|
|
803
|
+
* inlines the identical checks; a consistency test locks them together.
|
|
804
|
+
*/
|
|
805
|
+
function detectCollapsibleShape(node, _tag) {
|
|
806
|
+
const props = {};
|
|
807
|
+
for (const attr of jsxAttrs(node)) {
|
|
808
|
+
if (attr.type !== "JSXAttribute") return null;
|
|
809
|
+
const nm = attr.name?.type === "JSXIdentifier" ? attr.name.name : null;
|
|
810
|
+
if (!nm) return null;
|
|
811
|
+
const v = attr.value;
|
|
812
|
+
if (!v) return null;
|
|
813
|
+
if (!(v.type === "StringLiteral" || v.type === "Literal" && typeof v.value === "string")) return null;
|
|
814
|
+
props[nm] = String(v.value);
|
|
815
|
+
}
|
|
816
|
+
let childrenText = "";
|
|
817
|
+
for (const c of jsxChildren(node)) if (c.type === "JSXText") childrenText += c.value ?? "";
|
|
818
|
+
else return null;
|
|
819
|
+
return {
|
|
820
|
+
props,
|
|
821
|
+
childrenText: childrenText.trim()
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Partial-collapse detector — PR 1 of the partial-collapse spec
|
|
826
|
+
* (`.claude/plans/open-work-2026-q3.md` → #1). The `on*`-handler-only
|
|
827
|
+
* subset the bail-reason census measured at 7.8% of all
|
|
828
|
+
* `@pyreon/ui-components` call sites (`collapse-bail-census.test.ts`).
|
|
829
|
+
*
|
|
830
|
+
* It is the EXACT `detectCollapsibleShape` bail catalogue with ONE
|
|
831
|
+
* relaxation: a `{expr}`-valued attribute whose name matches `on[A-Z]…`
|
|
832
|
+
* (an event handler) does NOT bail — it is peeled into `handlers[]`
|
|
833
|
+
* instead. Handlers are orthogonal to the SSR-resolved styler class (an
|
|
834
|
+
* event binding never changes rendered CSS), so the literal-prop subset
|
|
835
|
+
* still feeds the UNCHANGED `rocketstyleCollapseKey` and the resolver's
|
|
836
|
+
* pre-resolved `templateHtml` / `lightClass` / `darkClass` are
|
|
837
|
+
* byte-identical to a full-collapse site's. The collapsed runtime node
|
|
838
|
+
* just re-attaches the residual handlers (PR 2 — `_rsCollapseH`).
|
|
839
|
+
*
|
|
840
|
+
* Every OTHER non-literal shape still bails (spread, non-handler
|
|
841
|
+
* `{expr}` prop, boolean attr, element/expression child) — conservative
|
|
842
|
+
* by construction, exactly like the full detector. Returns `null` when
|
|
843
|
+
* there are ZERO handlers so the full-collapse path stays byte-unchanged
|
|
844
|
+
* and the two detectors never both claim the same site (full-collapse
|
|
845
|
+
* sites have no handlers; partial sites have ≥1). A consistency test
|
|
846
|
+
* will lock this catalogue against the plugin scan in PR 3, mirroring
|
|
847
|
+
* the existing `detectCollapsibleShape` ↔ `scanCollapsibleSites`
|
|
848
|
+
* invariant — keys cannot drift.
|
|
849
|
+
*/
|
|
850
|
+
function detectPartialCollapsibleShape(node, _tag) {
|
|
851
|
+
const props = {};
|
|
852
|
+
const handlers = [];
|
|
853
|
+
for (const attr of jsxAttrs(node)) {
|
|
854
|
+
if (attr.type !== "JSXAttribute") return null;
|
|
855
|
+
const nm = attr.name?.type === "JSXIdentifier" ? attr.name.name : null;
|
|
856
|
+
if (!nm) return null;
|
|
857
|
+
const v = attr.value;
|
|
858
|
+
if (!v) return null;
|
|
859
|
+
if (v.type === "StringLiteral" || v.type === "Literal" && typeof v.value === "string") {
|
|
860
|
+
props[nm] = String(v.value);
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (/^on[A-Z]/.test(nm) && v.type === "JSXExpressionContainer" && v.expression && typeof v.expression.start === "number" && typeof v.expression.end === "number") {
|
|
864
|
+
handlers.push({
|
|
865
|
+
name: nm,
|
|
866
|
+
exprStart: v.expression.start,
|
|
867
|
+
exprEnd: v.expression.end
|
|
868
|
+
});
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
let childrenText = "";
|
|
874
|
+
for (const c of jsxChildren(node)) if (c.type === "JSXText") childrenText += c.value ?? "";
|
|
875
|
+
else return null;
|
|
876
|
+
if (handlers.length === 0) return null;
|
|
877
|
+
return {
|
|
878
|
+
props,
|
|
879
|
+
childrenText: childrenText.trim(),
|
|
880
|
+
handlers
|
|
881
|
+
};
|
|
882
|
+
}
|
|
573
883
|
function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
884
|
+
if (options.collapseRocketstyle) return transformJSX_JS(code, filename, options);
|
|
574
885
|
if (nativeTransformJsx) try {
|
|
575
|
-
return nativeTransformJsx(code, filename, options.ssr === true, options.knownSignals ?? null);
|
|
886
|
+
return nativeTransformJsx(code, filename, options.ssr === true, options.knownSignals ?? null, options.reactivityLens === true);
|
|
576
887
|
} catch {}
|
|
577
888
|
return transformJSX_JS(code, filename, options);
|
|
578
889
|
}
|
|
@@ -603,6 +914,23 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
603
914
|
code: warnCode
|
|
604
915
|
});
|
|
605
916
|
}
|
|
917
|
+
const collectLens = options.reactivityLens === true;
|
|
918
|
+
const reactivityLens = [];
|
|
919
|
+
function lens(start, end, kind, detail) {
|
|
920
|
+
if (!collectLens) return;
|
|
921
|
+
const a = locate(start);
|
|
922
|
+
const b = locate(end);
|
|
923
|
+
reactivityLens.push({
|
|
924
|
+
start,
|
|
925
|
+
end,
|
|
926
|
+
line: a.line,
|
|
927
|
+
column: a.column,
|
|
928
|
+
endLine: b.line,
|
|
929
|
+
endColumn: b.column,
|
|
930
|
+
kind,
|
|
931
|
+
detail
|
|
932
|
+
});
|
|
933
|
+
}
|
|
606
934
|
const parentMap = /* @__PURE__ */ new WeakMap();
|
|
607
935
|
const childrenMap = /* @__PURE__ */ new WeakMap();
|
|
608
936
|
/** Build parent pointers + cached children arrays for the entire AST. */
|
|
@@ -645,6 +973,102 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
645
973
|
let needsBindImportGlobal = false;
|
|
646
974
|
let needsApplyPropsImportGlobal = false;
|
|
647
975
|
let needsMountSlotImportGlobal = false;
|
|
976
|
+
let needsCollapse = false;
|
|
977
|
+
let needsCollapseH = false;
|
|
978
|
+
const collapseRuleKeys = /* @__PURE__ */ new Set();
|
|
979
|
+
const collapseRules = [];
|
|
980
|
+
/**
|
|
981
|
+
* Detect + collapse a literal-prop rocketstyle call site. Conservative
|
|
982
|
+
* bail catalogue (RFC decision 3): PascalCase candidate, every attr a
|
|
983
|
+
* StringLiteral (no spread, no `{expr}`, no boolean attr), children
|
|
984
|
+
* empty or a single static JSXText. The plugin must already have
|
|
985
|
+
* SSR-resolved this exact (component, props, text) tuple — an absent
|
|
986
|
+
* `sites` entry is a hard bail (covers resolver-bailed shapes,
|
|
987
|
+
* cross-package-without-data, anything uncertain). Emits ONE
|
|
988
|
+
* `_rsCollapse(tpl, light, dark, () => mode()==='dark')` (dual-emit)
|
|
989
|
+
* plus a once-per-module idempotent `injectRules`. A false negative is
|
|
990
|
+
* correct-but-slow; a false positive is wrong output — so every
|
|
991
|
+
* uncertain signal returns false.
|
|
992
|
+
*/
|
|
993
|
+
function tryRocketstyleCollapse(node) {
|
|
994
|
+
const cfg = options.collapseRocketstyle;
|
|
995
|
+
if (!cfg) return false;
|
|
996
|
+
const tag = jsxTagName(node);
|
|
997
|
+
if (!tag || tag.charAt(0) === tag.charAt(0).toLowerCase()) return false;
|
|
998
|
+
if (!cfg.candidates.has(tag)) return false;
|
|
999
|
+
const shape = detectCollapsibleShape(node, tag);
|
|
1000
|
+
if (!shape) return tryPartialCollapse(node, tag);
|
|
1001
|
+
const { props, childrenText } = shape;
|
|
1002
|
+
const key = rocketstyleCollapseKey(tag, props, childrenText);
|
|
1003
|
+
const site = cfg.sites.get(key);
|
|
1004
|
+
if (!site) return false;
|
|
1005
|
+
const call = `__rsCollapse(${JSON.stringify(site.templateHtml)}, ${JSON.stringify(site.lightClass)}, ${JSON.stringify(site.darkClass)}, () => __pyrMode() === "dark")`;
|
|
1006
|
+
const start = node.start;
|
|
1007
|
+
const end = node.end;
|
|
1008
|
+
const parent = findParent(node);
|
|
1009
|
+
const needsBraces = parent && (parent.type === "JSXElement" || parent.type === "JSXFragment");
|
|
1010
|
+
replacements.push({
|
|
1011
|
+
start,
|
|
1012
|
+
end,
|
|
1013
|
+
text: needsBraces ? `{${call}}` : call
|
|
1014
|
+
});
|
|
1015
|
+
needsCollapse = true;
|
|
1016
|
+
if (!collapseRuleKeys.has(site.ruleKey)) {
|
|
1017
|
+
collapseRuleKeys.add(site.ruleKey);
|
|
1018
|
+
collapseRules.push({
|
|
1019
|
+
ruleKey: site.ruleKey,
|
|
1020
|
+
rules: site.rules
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
return true;
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* PR 3 of the partial-collapse build (open-work #1). The `on*`-handler-
|
|
1027
|
+
* only fallback `tryRocketstyleCollapse` defers to when the FULL
|
|
1028
|
+
* `detectCollapsibleShape` bails. Identical site-resolution contract as
|
|
1029
|
+
* the full path — handlers are orthogonal to the SSR-resolved styler
|
|
1030
|
+
* class, so the literal-prop subset feeds the UNCHANGED
|
|
1031
|
+
* `rocketstyleCollapseKey` and the resolver's pre-resolved
|
|
1032
|
+
* `templateHtml`/`lightClass`/`darkClass` are byte-identical to a
|
|
1033
|
+
* full-collapse site's. The ONLY difference vs the full emit is
|
|
1034
|
+
* `__rsCollapseH(...)` with a handlers object literal built from the
|
|
1035
|
+
* sliced source spans `detectPartialCollapsibleShape` (PR 1) returned;
|
|
1036
|
+
* the runtime helper (`_rsCollapseH`, PR 2 / #681) re-attaches them
|
|
1037
|
+
* through the canonical event path. Same conservative discipline: an
|
|
1038
|
+
* unresolved key, the option absent, or any non-handler non-literal
|
|
1039
|
+
* shape ⇒ keep the normal mount (return false).
|
|
1040
|
+
*/
|
|
1041
|
+
function tryPartialCollapse(node, tag) {
|
|
1042
|
+
const cfg = options.collapseRocketstyle;
|
|
1043
|
+
if (!cfg) return false;
|
|
1044
|
+
const partial = detectPartialCollapsibleShape(node, tag);
|
|
1045
|
+
if (!partial) return false;
|
|
1046
|
+
const { props, childrenText, handlers } = partial;
|
|
1047
|
+
const key = rocketstyleCollapseKey(tag, props, childrenText);
|
|
1048
|
+
const site = cfg.sites.get(key);
|
|
1049
|
+
if (!site) return false;
|
|
1050
|
+
const handlerObj = `{ ${handlers.map((h) => `${JSON.stringify(h.name)}: (${code.slice(h.exprStart, h.exprEnd)})`).join(", ")} }`;
|
|
1051
|
+
const call = `__rsCollapseH(${JSON.stringify(site.templateHtml)}, ${JSON.stringify(site.lightClass)}, ${JSON.stringify(site.darkClass)}, () => __pyrMode() === "dark", ${handlerObj})`;
|
|
1052
|
+
const start = node.start;
|
|
1053
|
+
const end = node.end;
|
|
1054
|
+
const parent = findParent(node);
|
|
1055
|
+
const needsBraces = parent && (parent.type === "JSXElement" || parent.type === "JSXFragment");
|
|
1056
|
+
replacements.push({
|
|
1057
|
+
start,
|
|
1058
|
+
end,
|
|
1059
|
+
text: needsBraces ? `{${call}}` : call
|
|
1060
|
+
});
|
|
1061
|
+
needsCollapse = true;
|
|
1062
|
+
needsCollapseH = true;
|
|
1063
|
+
if (!collapseRuleKeys.has(site.ruleKey)) {
|
|
1064
|
+
collapseRuleKeys.add(site.ruleKey);
|
|
1065
|
+
collapseRules.push({
|
|
1066
|
+
ruleKey: site.ruleKey,
|
|
1067
|
+
rules: site.rules
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
return true;
|
|
1071
|
+
}
|
|
648
1072
|
function maybeHoist(node) {
|
|
649
1073
|
if ((node.type === "JSXElement" || node.type === "JSXFragment") && isStaticJSXNode(node)) {
|
|
650
1074
|
const name = `_$h${hoistIdx++}`;
|
|
@@ -653,6 +1077,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
653
1077
|
name,
|
|
654
1078
|
text
|
|
655
1079
|
});
|
|
1080
|
+
lens(node.start, node.end, "hoisted-static", "static — hoisted once to module scope, never re-evaluated");
|
|
656
1081
|
return name;
|
|
657
1082
|
}
|
|
658
1083
|
return null;
|
|
@@ -667,6 +1092,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
667
1092
|
end,
|
|
668
1093
|
text
|
|
669
1094
|
});
|
|
1095
|
+
lens(start, end, "reactive", "live — re-evaluates whenever its signals change");
|
|
670
1096
|
}
|
|
671
1097
|
function hoistOrWrap(expr) {
|
|
672
1098
|
const hoistName = maybeHoist(expr);
|
|
@@ -763,6 +1189,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
763
1189
|
text: `_rp(() => ${inner})`
|
|
764
1190
|
});
|
|
765
1191
|
needsRpImport = true;
|
|
1192
|
+
lens(start, end, "reactive-prop", "live prop — signal reads here are tracked into the component");
|
|
766
1193
|
}
|
|
767
1194
|
} else hoistOrWrap(expr);
|
|
768
1195
|
}
|
|
@@ -786,6 +1213,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
786
1213
|
}
|
|
787
1214
|
const propsNames = /* @__PURE__ */ new Set();
|
|
788
1215
|
const propDerivedVars = /* @__PURE__ */ new Map();
|
|
1216
|
+
const elementVars = /* @__PURE__ */ new Set();
|
|
789
1217
|
const signalVars = new Set(options.knownSignals);
|
|
790
1218
|
const shadowedSignals = /* @__PURE__ */ new Set();
|
|
791
1219
|
/** Check if an identifier name is an active (non-shadowed) signal variable. */
|
|
@@ -851,6 +1279,11 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
851
1279
|
for (const el of decl.id.elements ?? []) if (el?.type === "Identifier") propsNames.add(el.name);
|
|
852
1280
|
}
|
|
853
1281
|
}
|
|
1282
|
+
if ((node.kind === "const" || node.kind === "let") && decl.id?.type === "Identifier" && decl.init) {
|
|
1283
|
+
let initNode = decl.init;
|
|
1284
|
+
while (initNode?.type === "ParenthesizedExpression") initNode = initNode.expression;
|
|
1285
|
+
if (initNode?.type === "JSXElement" || initNode?.type === "JSXFragment") elementVars.add(decl.id.name);
|
|
1286
|
+
}
|
|
854
1287
|
if (node.kind !== "const") continue;
|
|
855
1288
|
if (callbackDepth > 0) continue;
|
|
856
1289
|
if (decl.id?.type === "Identifier" && decl.init) {
|
|
@@ -911,11 +1344,68 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
911
1344
|
function resolveIdentifiersInText(text, baseOffset, sourceNode) {
|
|
912
1345
|
const endOffset = baseOffset + text.length;
|
|
913
1346
|
const idents = [];
|
|
1347
|
+
const shadowed = /* @__PURE__ */ new Set();
|
|
1348
|
+
/** Collect identifier names bound by a pattern (params / declarators). */
|
|
1349
|
+
function patternBindingNames(p, out) {
|
|
1350
|
+
if (!p) return;
|
|
1351
|
+
switch (p.type) {
|
|
1352
|
+
case "Identifier":
|
|
1353
|
+
out.push(p.name);
|
|
1354
|
+
break;
|
|
1355
|
+
case "ObjectPattern":
|
|
1356
|
+
for (const pr of p.properties ?? []) if (pr.type === "RestElement") patternBindingNames(pr.argument, out);
|
|
1357
|
+
else patternBindingNames(pr.value ?? pr.key, out);
|
|
1358
|
+
break;
|
|
1359
|
+
case "ArrayPattern":
|
|
1360
|
+
for (const el of p.elements ?? []) patternBindingNames(el, out);
|
|
1361
|
+
break;
|
|
1362
|
+
case "AssignmentPattern":
|
|
1363
|
+
patternBindingNames(p.left, out);
|
|
1364
|
+
break;
|
|
1365
|
+
case "RestElement":
|
|
1366
|
+
patternBindingNames(p.argument, out);
|
|
1367
|
+
break;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Prop-derived names bound by `node` FOR ITS OWN SUBTREE (block-accurate
|
|
1372
|
+
* lexical scoping). Excludes the prop-derived const's own defining
|
|
1373
|
+
* declaration (matched by init span) so the binding we inline FROM is
|
|
1374
|
+
* never mistaken for a shadow of itself.
|
|
1375
|
+
*/
|
|
1376
|
+
function scopeBoundPropDerived(node) {
|
|
1377
|
+
const out = [];
|
|
1378
|
+
const t = node.type;
|
|
1379
|
+
const declNames = (declNode) => {
|
|
1380
|
+
for (const d of declNode.declarations ?? []) {
|
|
1381
|
+
if (d.id?.type === "Identifier" && propDerivedVars.has(d.id.name)) {
|
|
1382
|
+
const span = propDerivedVars.get(d.id.name);
|
|
1383
|
+
if (d.init && d.init.start === span.start) continue;
|
|
1384
|
+
}
|
|
1385
|
+
patternBindingNames(d.id, out);
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
if (t === "ArrowFunctionExpression" || t === "FunctionExpression" || t === "FunctionDeclaration") for (const p of node.params ?? []) patternBindingNames(p, out);
|
|
1389
|
+
else if (t === "CatchClause") patternBindingNames(node.param, out);
|
|
1390
|
+
else if (t === "ForStatement") {
|
|
1391
|
+
if (node.init?.type === "VariableDeclaration") declNames(node.init);
|
|
1392
|
+
} else if (t === "ForInStatement" || t === "ForOfStatement") {
|
|
1393
|
+
if (node.left?.type === "VariableDeclaration") declNames(node.left);
|
|
1394
|
+
} else if (t === "BlockStatement" || t === "Program" || t === "StaticBlock") {
|
|
1395
|
+
const stmts = node.body ?? node.statements;
|
|
1396
|
+
if (Array.isArray(stmts)) {
|
|
1397
|
+
for (const s of stmts) if (s.type === "VariableDeclaration") declNames(s);
|
|
1398
|
+
else if (s.type === "FunctionDeclaration" && s.id?.type === "Identifier") out.push(s.id.name);
|
|
1399
|
+
else if (s.type === "ClassDeclaration" && s.id?.type === "Identifier") out.push(s.id.name);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
return out.filter((n) => propDerivedVars.has(n));
|
|
1403
|
+
}
|
|
914
1404
|
function findIdents(node, parent) {
|
|
915
1405
|
const nodeStart = node.start;
|
|
916
1406
|
const nodeEnd = node.end;
|
|
917
1407
|
if (nodeStart >= endOffset || nodeEnd <= baseOffset) return;
|
|
918
|
-
if (node.type === "Identifier" && propDerivedVars.has(node.name)) {
|
|
1408
|
+
if (node.type === "Identifier" && propDerivedVars.has(node.name) && !shadowed.has(node.name)) {
|
|
919
1409
|
if (parent) {
|
|
920
1410
|
if (parent.type === "MemberExpression" && parent.property === node && !parent.computed) {} else if (parent.type === "VariableDeclarator" && parent.id === node) {} else if (parent.type === "Property" && parent.key === node && !parent.computed) {} else if (parent.type === "Property" && parent.shorthand) {} else if (nodeStart >= baseOffset && nodeEnd <= endOffset) idents.push({
|
|
921
1411
|
start: nodeStart,
|
|
@@ -928,7 +1418,10 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
928
1418
|
name: node.name
|
|
929
1419
|
});
|
|
930
1420
|
}
|
|
1421
|
+
const introduced = scopeBoundPropDerived(node).filter((n) => !shadowed.has(n));
|
|
1422
|
+
for (const n of introduced) shadowed.add(n);
|
|
931
1423
|
forEachChildFast(node, (child) => findIdents(child, node));
|
|
1424
|
+
for (const n of introduced) shadowed.delete(n);
|
|
932
1425
|
}
|
|
933
1426
|
findIdents(program, null);
|
|
934
1427
|
if (idents.length === 0) return text;
|
|
@@ -1016,6 +1509,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
1016
1509
|
}
|
|
1017
1510
|
if (node.type === "VariableDeclaration") collectPropDerivedFromDecl(node, _callbackDepth);
|
|
1018
1511
|
if (node.type === "JSXElement") {
|
|
1512
|
+
if (tryRocketstyleCollapse(node)) return;
|
|
1019
1513
|
if (!isSelfClosing(node) && tryTemplateEmit(node)) return;
|
|
1020
1514
|
checkForWarnings(node);
|
|
1021
1515
|
for (const attr of jsxAttrs(node)) if (attr.type === "JSXAttribute") handleJsxAttribute(attr, node);
|
|
@@ -1036,21 +1530,20 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
1036
1530
|
if (scopeShadows) for (const name of scopeShadows) shadowedSignals.delete(name);
|
|
1037
1531
|
}
|
|
1038
1532
|
walkNode(program);
|
|
1039
|
-
if (replacements.length === 0 && hoists.length === 0) return {
|
|
1533
|
+
if (replacements.length === 0 && hoists.length === 0) return collectLens ? {
|
|
1534
|
+
code,
|
|
1535
|
+
warnings,
|
|
1536
|
+
reactivityLens
|
|
1537
|
+
} : {
|
|
1040
1538
|
code,
|
|
1041
1539
|
warnings
|
|
1042
1540
|
};
|
|
1043
1541
|
replacements.sort((a, b) => a.start - b.start);
|
|
1044
|
-
const
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
outPos = r.end;
|
|
1050
|
-
}
|
|
1051
|
-
outParts.push(code.slice(outPos));
|
|
1052
|
-
let output = outParts.join("");
|
|
1053
|
-
if (hoists.length > 0) output = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join("") + output;
|
|
1542
|
+
const s = new MagicString(code);
|
|
1543
|
+
for (const r of replacements) if (r.start === r.end) s.appendLeft(r.start, r.text);
|
|
1544
|
+
else s.update(r.start, r.end, r.text);
|
|
1545
|
+
let preamble = "";
|
|
1546
|
+
if (hoists.length > 0) preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join("") + preamble;
|
|
1054
1547
|
if (needsTplImport) {
|
|
1055
1548
|
const runtimeDomImports = ["_tpl"];
|
|
1056
1549
|
if (needsBindDirectImportGlobal) runtimeDomImports.push("_bindDirect");
|
|
@@ -1058,18 +1551,39 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
1058
1551
|
if (needsApplyPropsImportGlobal) runtimeDomImports.push("_applyProps");
|
|
1059
1552
|
if (needsMountSlotImportGlobal) runtimeDomImports.push("_mountSlot");
|
|
1060
1553
|
const reactivityImports = needsBindImportGlobal ? `\nimport { _bind } from "@pyreon/reactivity";` : "";
|
|
1061
|
-
|
|
1554
|
+
preamble = `import { ${runtimeDomImports.join(", ")} } from "@pyreon/runtime-dom";${reactivityImports}\n` + preamble;
|
|
1062
1555
|
}
|
|
1063
1556
|
if (needsRpImport || needsWrapSpreadImport) {
|
|
1064
1557
|
const coreImports = [];
|
|
1065
1558
|
if (needsRpImport) coreImports.push("_rp");
|
|
1066
1559
|
if (needsWrapSpreadImport) coreImports.push("_wrapSpread");
|
|
1067
|
-
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1560
|
+
preamble = `import { ${coreImports.join(", ")} } from "@pyreon/core";\n` + preamble;
|
|
1561
|
+
}
|
|
1562
|
+
if (needsCollapse) {
|
|
1563
|
+
const cfg = options.collapseRocketstyle;
|
|
1564
|
+
const rd = cfg.runtimeDomSource ?? "@pyreon/runtime-dom";
|
|
1565
|
+
const st = cfg.stylerSource ?? "@pyreon/styler";
|
|
1566
|
+
const inj = collapseRules.map((r) => `__rsSheet.injectRules(${JSON.stringify(r.rules)},${JSON.stringify(r.ruleKey)});`).join("");
|
|
1567
|
+
preamble = `import { _rsCollapse as __rsCollapse${needsCollapseH ? ", _rsCollapseH as __rsCollapseH" : ""} } from "${rd}";\nimport { sheet as __rsSheet } from "${st}";\nimport { ${cfg.mode.name} as __pyrMode } from "${cfg.mode.source}";\n${inj}\n` + preamble;
|
|
1568
|
+
}
|
|
1569
|
+
if (preamble) s.prepend(preamble);
|
|
1570
|
+
const output = s.toString();
|
|
1571
|
+
const map = s.generateMap({
|
|
1572
|
+
source: filename,
|
|
1573
|
+
includeContent: true,
|
|
1574
|
+
hires: true
|
|
1575
|
+
});
|
|
1576
|
+
return collectLens ? {
|
|
1070
1577
|
code: output,
|
|
1071
1578
|
usesTemplates: needsTplImport,
|
|
1072
|
-
warnings
|
|
1579
|
+
warnings,
|
|
1580
|
+
map,
|
|
1581
|
+
reactivityLens
|
|
1582
|
+
} : {
|
|
1583
|
+
code: output,
|
|
1584
|
+
usesTemplates: needsTplImport,
|
|
1585
|
+
warnings,
|
|
1586
|
+
map
|
|
1073
1587
|
};
|
|
1074
1588
|
function hasBailAttr(node, isRoot = false) {
|
|
1075
1589
|
for (const attr of jsxAttrs(node)) {
|
|
@@ -1203,6 +1717,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
1203
1717
|
bindLines.push(attrSetter(htmlAttrName, varName, expr));
|
|
1204
1718
|
return;
|
|
1205
1719
|
}
|
|
1720
|
+
lens(exprNode.start, exprNode.end, "reactive-attr", `live attribute — \`${htmlAttrName}\` re-applies whenever its signals change`);
|
|
1206
1721
|
const directRef = tryDirectSignalRef(exprNode);
|
|
1207
1722
|
if (directRef) {
|
|
1208
1723
|
needsBindDirectImport = true;
|
|
@@ -1356,14 +1871,20 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
1356
1871
|
}
|
|
1357
1872
|
const needsPlaceholder = useMixed || useMultiExpr;
|
|
1358
1873
|
const { expr, isReactive } = unwrapAccessor(child.expression);
|
|
1359
|
-
|
|
1874
|
+
const isElementValuedIdent = child.expression?.type === "Identifier" && elementVars.has(child.expression.name) || !isReactive && /^[A-Za-z_$][\w$]*$/.test(expr) && elementVars.has(expr);
|
|
1875
|
+
if (isChildrenExpression(child.expression, expr) || isElementValuedIdent) {
|
|
1360
1876
|
needsMountSlotImport = true;
|
|
1361
1877
|
const placeholder = `${parentRef}.childNodes[${childNodeIdx}]`;
|
|
1362
1878
|
const d = nextDisp();
|
|
1363
1879
|
bindLines.push(`const ${d} = _mountSlot(${expr}, ${parentRef}, ${placeholder})`);
|
|
1364
1880
|
return "<!>";
|
|
1365
1881
|
}
|
|
1366
|
-
|
|
1882
|
+
const cx = child.expression;
|
|
1883
|
+
if (isReactive) {
|
|
1884
|
+
lens(cx.start, cx.end, "reactive", "live — this text re-renders whenever its signals change");
|
|
1885
|
+
return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder);
|
|
1886
|
+
}
|
|
1887
|
+
lens(cx.start, cx.end, "static-text", "baked once into the DOM — never re-renders (no signal read here)");
|
|
1367
1888
|
return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder);
|
|
1368
1889
|
}
|
|
1369
1890
|
function processChildren(el, varName, accessor) {
|
|
@@ -1444,12 +1965,64 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
1444
1965
|
}
|
|
1445
1966
|
/** Auto-insert () after signal variable references in the expression source.
|
|
1446
1967
|
* Uses the AST to find exact Identifier positions — never scans raw text. */
|
|
1968
|
+
function sigPatternNames(p, out) {
|
|
1969
|
+
if (!p) return;
|
|
1970
|
+
switch (p.type) {
|
|
1971
|
+
case "Identifier":
|
|
1972
|
+
out.push(p.name);
|
|
1973
|
+
break;
|
|
1974
|
+
case "ObjectPattern":
|
|
1975
|
+
for (const pr of p.properties ?? []) if (pr.type === "RestElement") sigPatternNames(pr.argument, out);
|
|
1976
|
+
else sigPatternNames(pr.value ?? pr.key, out);
|
|
1977
|
+
break;
|
|
1978
|
+
case "ArrayPattern":
|
|
1979
|
+
for (const el of p.elements ?? []) sigPatternNames(el, out);
|
|
1980
|
+
break;
|
|
1981
|
+
case "AssignmentPattern":
|
|
1982
|
+
sigPatternNames(p.left, out);
|
|
1983
|
+
break;
|
|
1984
|
+
case "RestElement":
|
|
1985
|
+
sigPatternNames(p.argument, out);
|
|
1986
|
+
break;
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
function scopeBoundSignals(node) {
|
|
1990
|
+
const out = [];
|
|
1991
|
+
const t = node.type;
|
|
1992
|
+
const declNames = (declNode) => {
|
|
1993
|
+
for (const d of declNode.declarations ?? []) {
|
|
1994
|
+
if (d.id?.type === "Identifier" && d.init && isSignalCall(d.init)) continue;
|
|
1995
|
+
sigPatternNames(d.id, out);
|
|
1996
|
+
}
|
|
1997
|
+
};
|
|
1998
|
+
if (t === "ArrowFunctionExpression" || t === "FunctionExpression" || t === "FunctionDeclaration") for (const p of node.params ?? []) sigPatternNames(p, out);
|
|
1999
|
+
else if (t === "CatchClause") sigPatternNames(node.param, out);
|
|
2000
|
+
else if (t === "ForStatement") {
|
|
2001
|
+
if (node.init?.type === "VariableDeclaration") declNames(node.init);
|
|
2002
|
+
} else if (t === "ForInStatement" || t === "ForOfStatement") {
|
|
2003
|
+
if (node.left?.type === "VariableDeclaration") declNames(node.left);
|
|
2004
|
+
} else if (t === "BlockStatement" || t === "StaticBlock") {
|
|
2005
|
+
const stmts = node.body ?? node.statements;
|
|
2006
|
+
if (Array.isArray(stmts)) {
|
|
2007
|
+
for (const s of stmts) if (s.type === "VariableDeclaration") declNames(s);
|
|
2008
|
+
else if (s.type === "FunctionDeclaration" && s.id?.type === "Identifier") out.push(s.id.name);
|
|
2009
|
+
else if (s.type === "ClassDeclaration" && s.id?.type === "Identifier") out.push(s.id.name);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
return out.filter((n) => signalVars.has(n));
|
|
2013
|
+
}
|
|
1447
2014
|
function autoCallSignals(text, expr) {
|
|
1448
2015
|
const start = expr.start;
|
|
1449
2016
|
const idents = [];
|
|
2017
|
+
const shadowed = /* @__PURE__ */ new Set();
|
|
1450
2018
|
function findSignalIdents(node) {
|
|
1451
2019
|
if (node.start >= start + text.length || node.end <= start) return;
|
|
1452
|
-
|
|
2020
|
+
const introduced = [];
|
|
2021
|
+
for (const n of scopeBoundSignals(node)) if (!shadowed.has(n)) {
|
|
2022
|
+
shadowed.add(n);
|
|
2023
|
+
introduced.push(n);
|
|
2024
|
+
}
|
|
2025
|
+
if (node.type === "Identifier" && isActiveSignal(node.name) && !shadowed.has(node.name)) {
|
|
1453
2026
|
const parent = findParent(node);
|
|
1454
2027
|
if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) return;
|
|
1455
2028
|
if (parent && parent.type === "MemberExpression" && parent.object === node) {
|
|
@@ -1468,6 +2041,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
1468
2041
|
});
|
|
1469
2042
|
}
|
|
1470
2043
|
forEachChildFast(node, findSignalIdents);
|
|
2044
|
+
for (const n of introduced) shadowed.delete(n);
|
|
1471
2045
|
}
|
|
1472
2046
|
findSignalIdents(expr);
|
|
1473
2047
|
if (idents.length === 0) return text;
|
|
@@ -1666,1266 +2240,1511 @@ function isPureStaticCall(node) {
|
|
|
1666
2240
|
}
|
|
1667
2241
|
|
|
1668
2242
|
//#endregion
|
|
1669
|
-
//#region src/
|
|
2243
|
+
//#region src/pyreon-intercept.ts
|
|
1670
2244
|
/**
|
|
1671
|
-
*
|
|
2245
|
+
* Pyreon Pattern Interceptor — detects Pyreon-specific anti-patterns in
|
|
2246
|
+
* code that has ALREADY committed to the framework (imports are Pyreon,
|
|
2247
|
+
* not React). Complements `react-intercept.ts` — the React detector
|
|
2248
|
+
* catches "coming from React" mistakes; this one catches "using Pyreon
|
|
2249
|
+
* wrong" mistakes.
|
|
2250
|
+
*
|
|
2251
|
+
* Catalog of detected patterns (grounded in `.claude/rules/anti-patterns.md`):
|
|
2252
|
+
*
|
|
2253
|
+
* - `for-missing-by` — `<For each={...}>` without a `by` prop
|
|
2254
|
+
* - `for-with-key` — `<For key={...}>` (JSX reserves `key`; the keying
|
|
2255
|
+
* prop is `by` in Pyreon)
|
|
2256
|
+
* - `props-destructured` — `({ foo }: Props) => <JSX />` destructures at
|
|
2257
|
+
* the component signature; reading is captured once
|
|
2258
|
+
* and loses reactivity. Access `props.foo` instead
|
|
2259
|
+
* or use `splitProps(props, [...])`.
|
|
2260
|
+
* - `props-destructured-body` — `const { foo } = props` written
|
|
2261
|
+
* SYNCHRONOUSLY in a component body — the body-scope
|
|
2262
|
+
* companion to `props-destructured`. Same capture-
|
|
2263
|
+
* once death; nested-function destructures (handler
|
|
2264
|
+
* / effect / returned accessor) are NOT flagged
|
|
2265
|
+
* (they re-read `props` per invocation).
|
|
2266
|
+
* - `process-dev-gate` — `typeof process !== 'undefined' &&
|
|
2267
|
+
* process.env.NODE_ENV !== 'production'` is dead
|
|
2268
|
+
* code in real Vite browser bundles. Use
|
|
2269
|
+
* `import.meta.env?.DEV` instead.
|
|
2270
|
+
* - `empty-theme` — `.theme({})` chain is a no-op; remove it.
|
|
2271
|
+
* - `raw-add-event-listener` — raw `addEventListener(...)` in a component
|
|
2272
|
+
* or hook body. Use `useEventListener(...)` from
|
|
2273
|
+
* `@pyreon/hooks` for auto-cleanup.
|
|
2274
|
+
* - `raw-remove-event-listener` — same, for removeEventListener.
|
|
2275
|
+
* - `date-math-random-id` — `Date.now() + Math.random()` / template-concat
|
|
2276
|
+
* variants. Under rapid operations (paste, clone)
|
|
2277
|
+
* collision probability is non-trivial. Use a
|
|
2278
|
+
* monotonic counter.
|
|
2279
|
+
* - `on-click-undefined` — `onClick={undefined}` explicitly; the runtime
|
|
2280
|
+
* used to crash on this pattern. Omit the prop.
|
|
2281
|
+
* - `signal-write-as-call` — `sig(value)` is a no-op read that ignores
|
|
2282
|
+
* its argument; the runtime warns in dev. Static
|
|
2283
|
+
* detector spots it pre-runtime when `sig` was
|
|
2284
|
+
* declared as `const sig = signal(...)` /
|
|
2285
|
+
* `computed(...)` and called with ≥1 argument.
|
|
2286
|
+
* - `static-return-null-conditional` — `if (cond) return null` at the
|
|
2287
|
+
* top of a component body runs ONCE; signal changes
|
|
2288
|
+
* in `cond` never re-evaluate the early-return.
|
|
2289
|
+
* Wrap in a returned reactive accessor.
|
|
2290
|
+
* - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
|
|
2291
|
+
* cast on JSX returns is unnecessary (`JSX.Element`
|
|
2292
|
+
* is already assignable to `VNodeChild`).
|
|
2293
|
+
* - `island-never-with-registry-entry` — an `island()` declared with
|
|
2294
|
+
* `hydrate: 'never'` is also registered in the same
|
|
2295
|
+
* file's `hydrateIslands({ ... })` call. The whole
|
|
2296
|
+
* point of `'never'` is shipping zero client JS;
|
|
2297
|
+
* registering pulls the component module into the
|
|
2298
|
+
* client bundle graph (the runtime short-circuits
|
|
2299
|
+
* and never calls the loader, but the bundler still
|
|
2300
|
+
* includes the import). Drop the registry entry.
|
|
2301
|
+
*
|
|
2302
|
+
* Two-mode surface mirrors `react-intercept.ts`:
|
|
2303
|
+
* - `detectPyreonPatterns(code)` — diagnostics only
|
|
2304
|
+
* - `hasPyreonPatterns(code)` — fast regex pre-filter
|
|
2305
|
+
*
|
|
2306
|
+
* ## fixable: false (invariant)
|
|
2307
|
+
*
|
|
2308
|
+
* Every Pyreon diagnostic reports `fixable: false` — no exceptions.
|
|
2309
|
+
* The `migrate_react` MCP tool only knows React mappings, so claiming
|
|
2310
|
+
* a Pyreon code is auto-fixable would mislead a consumer who wires
|
|
2311
|
+
* their UX off the flag and finds nothing applies the fix. Flip to
|
|
2312
|
+
* `true` ONLY when a companion `migrate_pyreon` tool ships in a
|
|
2313
|
+
* subsequent PR. The invariant is locked in
|
|
2314
|
+
* `tests/pyreon-intercept.test.ts` under "fixable contract".
|
|
2315
|
+
*
|
|
2316
|
+
* Designed for three consumers:
|
|
2317
|
+
* 1. Compiler pre-pass warnings during build
|
|
2318
|
+
* 2. CLI `pyreon doctor`
|
|
2319
|
+
* 3. MCP server `validate` tool
|
|
1672
2320
|
*/
|
|
1673
|
-
function
|
|
1674
|
-
|
|
1675
|
-
return {
|
|
1676
|
-
framework: "pyreon",
|
|
1677
|
-
version: readVersion(cwd),
|
|
1678
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1679
|
-
routes: extractRoutes(files, cwd),
|
|
1680
|
-
components: extractComponents(files, cwd),
|
|
1681
|
-
islands: extractIslands(files, cwd)
|
|
1682
|
-
};
|
|
2321
|
+
function getNodeText(ctx, node) {
|
|
2322
|
+
return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
|
|
1683
2323
|
}
|
|
1684
|
-
function
|
|
1685
|
-
const
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
"lib",
|
|
1696
|
-
".pyreon",
|
|
1697
|
-
".git",
|
|
1698
|
-
"build"
|
|
1699
|
-
]);
|
|
1700
|
-
function walk(dir) {
|
|
1701
|
-
let entries;
|
|
1702
|
-
try {
|
|
1703
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1704
|
-
} catch {
|
|
1705
|
-
return;
|
|
1706
|
-
}
|
|
1707
|
-
for (const entry of entries) {
|
|
1708
|
-
if (entry.name.startsWith(".") && entry.isDirectory()) continue;
|
|
1709
|
-
if (ignoreDirs.has(entry.name) && entry.isDirectory()) continue;
|
|
1710
|
-
const fullPath = path.join(dir, entry.name);
|
|
1711
|
-
if (entry.isDirectory()) walk(fullPath);
|
|
1712
|
-
else if (entry.isFile() && extensions.has(path.extname(entry.name))) results.push(fullPath);
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1715
|
-
walk(cwd);
|
|
1716
|
-
return results;
|
|
2324
|
+
function pushDiag(ctx, node, code, message, current, suggested, fixable) {
|
|
2325
|
+
const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf));
|
|
2326
|
+
ctx.diagnostics.push({
|
|
2327
|
+
code,
|
|
2328
|
+
message,
|
|
2329
|
+
line: line + 1,
|
|
2330
|
+
column: character,
|
|
2331
|
+
current: current.trim(),
|
|
2332
|
+
suggested: suggested.trim(),
|
|
2333
|
+
fixable
|
|
2334
|
+
});
|
|
1717
2335
|
}
|
|
1718
|
-
function
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
try {
|
|
1723
|
-
code = fs.readFileSync(file, "utf-8");
|
|
1724
|
-
} catch {
|
|
1725
|
-
continue;
|
|
1726
|
-
}
|
|
1727
|
-
const routeArrayRe = /(?:createRouter\s*\(\s*\[|(?:const|let)\s+routes\s*(?::\s*RouteRecord\[\])?\s*=\s*\[)([\s\S]*?)\]/g;
|
|
1728
|
-
let match;
|
|
1729
|
-
for (match = routeArrayRe.exec(code); match; match = routeArrayRe.exec(code)) {
|
|
1730
|
-
const block = match[1] ?? "";
|
|
1731
|
-
const routeObjRe = /path\s*:\s*["']([^"']+)["']/g;
|
|
1732
|
-
let routeMatch;
|
|
1733
|
-
for (routeMatch = routeObjRe.exec(block); routeMatch; routeMatch = routeObjRe.exec(block)) {
|
|
1734
|
-
const routePath = routeMatch[1] ?? "";
|
|
1735
|
-
const surroundingStart = Math.max(0, routeMatch.index - 50);
|
|
1736
|
-
const surroundingEnd = Math.min(block.length, routeMatch.index + 200);
|
|
1737
|
-
const surrounding = block.slice(surroundingStart, surroundingEnd);
|
|
1738
|
-
routes.push({
|
|
1739
|
-
path: routePath,
|
|
1740
|
-
name: surrounding.match(/name\s*:\s*["']([^"']+)["']/)?.[1],
|
|
1741
|
-
hasLoader: /loader\s*:/.test(surrounding),
|
|
1742
|
-
hasGuard: /beforeEnter\s*:|beforeLeave\s*:/.test(surrounding),
|
|
1743
|
-
params: extractParams(routePath)
|
|
1744
|
-
});
|
|
1745
|
-
}
|
|
1746
|
-
}
|
|
1747
|
-
}
|
|
1748
|
-
return routes;
|
|
2336
|
+
function getJsxTagName$1(node) {
|
|
2337
|
+
const t = node.tagName;
|
|
2338
|
+
if (ts.isIdentifier(t)) return t.text;
|
|
2339
|
+
return "";
|
|
1749
2340
|
}
|
|
1750
|
-
function
|
|
1751
|
-
const
|
|
1752
|
-
for (const file of files) {
|
|
1753
|
-
let code;
|
|
1754
|
-
try {
|
|
1755
|
-
code = fs.readFileSync(file, "utf-8");
|
|
1756
|
-
} catch {
|
|
1757
|
-
continue;
|
|
1758
|
-
}
|
|
1759
|
-
const componentRe = /(?:export\s+)?(?:const|function)\s+([A-Z]\w*)\s*(?::\s*ComponentFn<[^>]+>\s*)?=?\s*\(?(?:\s*\{?\s*([^)]*?)\s*\}?\s*)?\)?\s*(?:=>|{)/g;
|
|
1760
|
-
let match;
|
|
1761
|
-
for (match = componentRe.exec(code); match; match = componentRe.exec(code)) {
|
|
1762
|
-
const name = match[1] ?? "Unknown";
|
|
1763
|
-
const props = (match[2] ?? "").split(/[,;]/).map((p) => p.trim().replace(/[{}]/g, "").trim().split(":")[0]?.split("=")[0]?.trim() ?? "").filter((p) => p && p !== "props");
|
|
1764
|
-
const bodyStart = match.index + match[0].length;
|
|
1765
|
-
const body = code.slice(bodyStart, Math.min(code.length, bodyStart + 2e3));
|
|
1766
|
-
const signalNames = [];
|
|
1767
|
-
const signalRe = /(?:const|let)\s+(\w+)\s*=\s*signal\s*[<(]/g;
|
|
1768
|
-
let sigMatch;
|
|
1769
|
-
for (sigMatch = signalRe.exec(body); sigMatch; sigMatch = signalRe.exec(body)) if (sigMatch[1]) signalNames.push(sigMatch[1]);
|
|
1770
|
-
components.push({
|
|
1771
|
-
name,
|
|
1772
|
-
file: path.relative(cwd, file),
|
|
1773
|
-
hasSignals: signalNames.length > 0,
|
|
1774
|
-
signalNames,
|
|
1775
|
-
props
|
|
1776
|
-
});
|
|
1777
|
-
}
|
|
1778
|
-
}
|
|
1779
|
-
return components;
|
|
2341
|
+
function findJsxAttribute(node, name) {
|
|
2342
|
+
for (const attr of node.attributes.properties) if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === name) return attr;
|
|
1780
2343
|
}
|
|
1781
|
-
function
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
2344
|
+
function detectForKeying(ctx, node) {
|
|
2345
|
+
if (getJsxTagName$1(node) !== "For") return;
|
|
2346
|
+
const keyAttr = findJsxAttribute(node, "key");
|
|
2347
|
+
if (keyAttr) pushDiag(ctx, keyAttr, "for-with-key", "`key` on <For> is reserved by JSX for VNode reconciliation and is extracted before the prop reaches the runtime. In Pyreon, use `by` for list identity.", getNodeText(ctx, keyAttr), getNodeText(ctx, keyAttr).replace(/^key\b/, "by"), false);
|
|
2348
|
+
const eachAttr = findJsxAttribute(node, "each");
|
|
2349
|
+
const byAttr = findJsxAttribute(node, "by");
|
|
2350
|
+
if (eachAttr && !byAttr && !keyAttr) pushDiag(ctx, node, "for-missing-by", "<For each={...}> requires a `by` prop so the keyed reconciler can preserve item identity across reorders. Without `by`, every update remounts the full list.", getNodeText(ctx, node), "<For each={items} by={(item) => item.id}>", false);
|
|
2351
|
+
}
|
|
2352
|
+
function containsJsx(node) {
|
|
2353
|
+
let found = false;
|
|
2354
|
+
function walk(n) {
|
|
2355
|
+
if (found) return;
|
|
2356
|
+
if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n) || ts.isJsxOpeningElement(n)) {
|
|
2357
|
+
found = true;
|
|
2358
|
+
return;
|
|
1789
2359
|
}
|
|
1790
|
-
|
|
1791
|
-
let match;
|
|
1792
|
-
for (match = islandRe.exec(code); match; match = islandRe.exec(code)) if (match[1]) islands.push({
|
|
1793
|
-
name: match[1],
|
|
1794
|
-
file: path.relative(cwd, file),
|
|
1795
|
-
hydrate: match[2] ?? "load"
|
|
1796
|
-
});
|
|
2360
|
+
ts.forEachChild(n, walk);
|
|
1797
2361
|
}
|
|
1798
|
-
|
|
2362
|
+
ts.forEachChild(node, walk);
|
|
2363
|
+
if (!found) {
|
|
2364
|
+
if (ts.isArrowFunction(node) && !ts.isBlock(node.body) && (ts.isJsxElement(node.body) || ts.isJsxSelfClosingElement(node.body) || ts.isJsxFragment(node.body))) found = true;
|
|
2365
|
+
}
|
|
2366
|
+
return found;
|
|
1799
2367
|
}
|
|
1800
|
-
function
|
|
1801
|
-
|
|
1802
|
-
const
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
return
|
|
2368
|
+
function detectPropsDestructured(ctx, node) {
|
|
2369
|
+
if (!node.parameters.length) return;
|
|
2370
|
+
const first = node.parameters[0];
|
|
2371
|
+
if (!first || !ts.isObjectBindingPattern(first.name)) return;
|
|
2372
|
+
if (first.name.elements.length === 0) return;
|
|
2373
|
+
if (!containsJsx(node)) return;
|
|
2374
|
+
pushDiag(ctx, first, "props-destructured", "Destructuring props at the component signature captures the values ONCE during setup — subsequent signal writes in the parent do not update the destructured locals. Access `props.x` directly, or use `splitProps(props, [...])` to carve out a group while preserving reactivity.", getNodeText(ctx, first), "(props: Props) => /* read props.x directly */", false);
|
|
1806
2375
|
}
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
2376
|
+
/**
|
|
2377
|
+
* Strip the wrappers that can sit between `=` and the props identifier
|
|
2378
|
+
* (`const { x } = (props as Props)!`) so we can compare the base
|
|
2379
|
+
* expression's identity to the component's first-parameter name.
|
|
2380
|
+
*/
|
|
2381
|
+
function unwrapInitializer(expr) {
|
|
2382
|
+
let cur = expr;
|
|
2383
|
+
let prev;
|
|
2384
|
+
while (cur !== prev) {
|
|
2385
|
+
prev = cur;
|
|
2386
|
+
if (ts.isParenthesizedExpression(cur)) cur = cur.expression;
|
|
2387
|
+
else if (ts.isAsExpression(cur)) cur = cur.expression;
|
|
2388
|
+
else if (ts.isSatisfiesExpression(cur)) cur = cur.expression;
|
|
2389
|
+
else if (ts.isNonNullExpression(cur)) cur = cur.expression;
|
|
2390
|
+
}
|
|
2391
|
+
return cur;
|
|
1819
2392
|
}
|
|
1820
|
-
|
|
1821
|
-
//#endregion
|
|
1822
|
-
//#region src/react-intercept.ts
|
|
1823
2393
|
/**
|
|
1824
|
-
*
|
|
1825
|
-
*
|
|
2394
|
+
* Body-scope companion to {@link detectPropsDestructured}. Flags
|
|
2395
|
+
* `const { x } = props` (also `let` / `var`, aliases, defaults, rest,
|
|
2396
|
+
* nested patterns) written SYNCHRONOUSLY in a component's body.
|
|
1826
2397
|
*
|
|
1827
|
-
*
|
|
1828
|
-
*
|
|
1829
|
-
*
|
|
2398
|
+
* Why this is the footgun: the compiler emits `<C prop={sig()} />` as a
|
|
2399
|
+
* getter-shaped reactive prop. `const { x } = props` fires that getter
|
|
2400
|
+
* exactly ONCE at setup — `x` is a dead snapshot, never re-reads when
|
|
2401
|
+
* the signal changes. `props.x` (live member access inside a tracking
|
|
2402
|
+
* scope) or `splitProps(props, ['x'])` preserve the subscription.
|
|
1830
2403
|
*
|
|
1831
|
-
*
|
|
1832
|
-
*
|
|
1833
|
-
*
|
|
1834
|
-
*
|
|
2404
|
+
* Precision (zero false positives is the priority — a missed body-scope
|
|
2405
|
+
* destructure is acceptable, a wrong one is not):
|
|
2406
|
+
* - Only PascalCase, JSX-rendering functions (`isComponentShapedFunction`
|
|
2407
|
+
* + `containsJsx`) — a plain helper that happens to destructure an
|
|
2408
|
+
* options bag named `props` is NOT a component and is left alone.
|
|
2409
|
+
* - The initializer must be the bare first-parameter identifier
|
|
2410
|
+
* (`= props`), unwrapped through paren / `as` / `satisfies` / `!`.
|
|
2411
|
+
* `const { x } = props.nested` and `= someOtherObject` are NOT
|
|
2412
|
+
* flagged (rarer shapes; out of the canonical scope).
|
|
2413
|
+
* - The destructure must be at the component-body top scope. A nested
|
|
2414
|
+
* function boundary (`onClick` handler, `effect(() => …)`, a returned
|
|
2415
|
+
* reactive accessor) re-reads `props` on each invocation, so those
|
|
2416
|
+
* destructures are reactivity-correct — the walk does NOT descend
|
|
2417
|
+
* into nested functions.
|
|
2418
|
+
* - The first parameter must itself be a plain identifier; the
|
|
2419
|
+
* parameter-destructure shape (`({ x }) => …`) is the existing
|
|
2420
|
+
* `detectPropsDestructured`'s job, not this one.
|
|
1835
2421
|
*/
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
name: "effect",
|
|
1853
|
-
from: "@pyreon/reactivity"
|
|
1854
|
-
},
|
|
1855
|
-
useLayoutEffect: {
|
|
1856
|
-
name: "effect",
|
|
1857
|
-
from: "@pyreon/reactivity"
|
|
1858
|
-
},
|
|
1859
|
-
useMemo: {
|
|
1860
|
-
name: "computed",
|
|
1861
|
-
from: "@pyreon/reactivity"
|
|
1862
|
-
},
|
|
1863
|
-
useReducer: {
|
|
1864
|
-
name: "signal",
|
|
1865
|
-
from: "@pyreon/reactivity"
|
|
1866
|
-
},
|
|
1867
|
-
useRef: {
|
|
1868
|
-
name: "signal",
|
|
1869
|
-
from: "@pyreon/reactivity"
|
|
1870
|
-
},
|
|
1871
|
-
createContext: {
|
|
1872
|
-
name: "createContext",
|
|
1873
|
-
from: "@pyreon/core"
|
|
1874
|
-
},
|
|
1875
|
-
useContext: {
|
|
1876
|
-
name: "useContext",
|
|
1877
|
-
from: "@pyreon/core"
|
|
1878
|
-
},
|
|
1879
|
-
Fragment: {
|
|
1880
|
-
name: "Fragment",
|
|
1881
|
-
from: "@pyreon/core"
|
|
1882
|
-
},
|
|
1883
|
-
Suspense: {
|
|
1884
|
-
name: "Suspense",
|
|
1885
|
-
from: "@pyreon/core"
|
|
1886
|
-
},
|
|
1887
|
-
lazy: {
|
|
1888
|
-
name: "lazy",
|
|
1889
|
-
from: "@pyreon/core"
|
|
1890
|
-
},
|
|
1891
|
-
memo: {
|
|
1892
|
-
name: "",
|
|
1893
|
-
from: ""
|
|
1894
|
-
},
|
|
1895
|
-
forwardRef: {
|
|
1896
|
-
name: "",
|
|
1897
|
-
from: ""
|
|
1898
|
-
},
|
|
1899
|
-
createRoot: {
|
|
1900
|
-
name: "mount",
|
|
1901
|
-
from: "@pyreon/runtime-dom"
|
|
1902
|
-
},
|
|
1903
|
-
hydrateRoot: {
|
|
1904
|
-
name: "hydrateRoot",
|
|
1905
|
-
from: "@pyreon/runtime-dom"
|
|
1906
|
-
},
|
|
1907
|
-
useNavigate: {
|
|
1908
|
-
name: "useRouter",
|
|
1909
|
-
from: "@pyreon/router"
|
|
1910
|
-
},
|
|
1911
|
-
useParams: {
|
|
1912
|
-
name: "useRoute",
|
|
1913
|
-
from: "@pyreon/router"
|
|
1914
|
-
},
|
|
1915
|
-
useLocation: {
|
|
1916
|
-
name: "useRoute",
|
|
1917
|
-
from: "@pyreon/router"
|
|
1918
|
-
},
|
|
1919
|
-
Link: {
|
|
1920
|
-
name: "RouterLink",
|
|
1921
|
-
from: "@pyreon/router"
|
|
1922
|
-
},
|
|
1923
|
-
NavLink: {
|
|
1924
|
-
name: "RouterLink",
|
|
1925
|
-
from: "@pyreon/router"
|
|
1926
|
-
},
|
|
1927
|
-
Outlet: {
|
|
1928
|
-
name: "RouterView",
|
|
1929
|
-
from: "@pyreon/router"
|
|
1930
|
-
},
|
|
1931
|
-
useSearchParams: {
|
|
1932
|
-
name: "useSearchParams",
|
|
1933
|
-
from: "@pyreon/router"
|
|
1934
|
-
}
|
|
1935
|
-
};
|
|
1936
|
-
/** JSX attribute rewrites (React → standard HTML) */
|
|
1937
|
-
const JSX_ATTR_REWRITES = {
|
|
1938
|
-
className: "class",
|
|
1939
|
-
htmlFor: "for"
|
|
1940
|
-
};
|
|
1941
|
-
function detectGetNodeText(ctx, node) {
|
|
1942
|
-
return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
|
|
1943
|
-
}
|
|
1944
|
-
function detectDiag(ctx, node, diagCode, message, current, suggested, fixable) {
|
|
1945
|
-
const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf));
|
|
1946
|
-
ctx.diagnostics.push({
|
|
1947
|
-
code: diagCode,
|
|
1948
|
-
message,
|
|
1949
|
-
line: line + 1,
|
|
1950
|
-
column: character,
|
|
1951
|
-
current: current.trim(),
|
|
1952
|
-
suggested: suggested.trim(),
|
|
1953
|
-
fixable
|
|
1954
|
-
});
|
|
1955
|
-
}
|
|
1956
|
-
function detectImportDeclaration(ctx, node) {
|
|
1957
|
-
if (!node.moduleSpecifier) return;
|
|
1958
|
-
const source = node.moduleSpecifier.text;
|
|
1959
|
-
const pyreonSource = IMPORT_REWRITES[source];
|
|
1960
|
-
if (pyreonSource !== void 0) {
|
|
1961
|
-
if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) for (const spec of node.importClause.namedBindings.elements) ctx.reactImportedHooks.add(spec.name.text);
|
|
1962
|
-
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);
|
|
2422
|
+
function detectPropsDestructuredBody(ctx, node) {
|
|
2423
|
+
if (!isComponentShapedFunction(node)) return;
|
|
2424
|
+
if (!containsJsx(node)) return;
|
|
2425
|
+
if (!node.parameters.length) return;
|
|
2426
|
+
const first = node.parameters[0];
|
|
2427
|
+
if (!first || !ts.isIdentifier(first.name)) return;
|
|
2428
|
+
const paramName = first.name.text;
|
|
2429
|
+
const body = node.body;
|
|
2430
|
+
if (!body || !ts.isBlock(body)) return;
|
|
2431
|
+
function walk(n) {
|
|
2432
|
+
if (ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n) || ts.isMethodDeclaration(n) || ts.isGetAccessorDeclaration(n) || ts.isSetAccessorDeclaration(n)) return;
|
|
2433
|
+
if (ts.isVariableDeclaration(n) && ts.isObjectBindingPattern(n.name) && n.name.elements.length > 0 && n.initializer) {
|
|
2434
|
+
const base = unwrapInitializer(n.initializer);
|
|
2435
|
+
if (ts.isIdentifier(base) && base.text === paramName) pushDiag(ctx, n, "props-destructured-body", `Destructuring \`${paramName}\` in the component body captures the values ONCE during setup — the compiler emits signal-driven props as getters, so the destructured locals are dead snapshots that never update when the parent rewrites them. Read \`${paramName}.x\` directly inside the reactive scope (JSX / effect / computed), or use \`splitProps(${paramName}, ['x', ...])\` to carve out a group while preserving reactivity.`, getNodeText(ctx, n), `// read ${paramName}.x directly, or: const [local] = splitProps(${paramName}, ['x'])`, false);
|
|
2436
|
+
}
|
|
2437
|
+
ts.forEachChild(n, walk);
|
|
1963
2438
|
}
|
|
2439
|
+
for (const stmt of body.statements) walk(stmt);
|
|
1964
2440
|
}
|
|
1965
|
-
function
|
|
1966
|
-
|
|
1967
|
-
if (
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
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);
|
|
1972
|
-
} else detectDiag(ctx, node, "use-state", "useState is a React API. In Pyreon, use signal().", detectGetNodeText(ctx, node), "signal(initialValue)", true);
|
|
1973
|
-
}
|
|
1974
|
-
function callbackHasCleanup(callbackArg) {
|
|
1975
|
-
if (!ts.isArrowFunction(callbackArg) && !ts.isFunctionExpression(callbackArg)) return false;
|
|
1976
|
-
const body = callbackArg.body;
|
|
1977
|
-
if (!ts.isBlock(body)) return false;
|
|
1978
|
-
for (const stmt of body.statements) if (ts.isReturnStatement(stmt) && stmt.expression) return true;
|
|
1979
|
-
return false;
|
|
1980
|
-
}
|
|
1981
|
-
function detectUseEffect(ctx, node) {
|
|
1982
|
-
const hookName = node.expression.text;
|
|
1983
|
-
const depsArg = node.arguments[1];
|
|
1984
|
-
const callbackArg = node.arguments[0];
|
|
1985
|
-
if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0) {
|
|
1986
|
-
const hasCleanup = callbackArg ? callbackHasCleanup(callbackArg) : false;
|
|
1987
|
-
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})", true);
|
|
1988
|
-
} 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);
|
|
1989
|
-
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);
|
|
2441
|
+
function isTypeofProcess(node) {
|
|
2442
|
+
if (!ts.isBinaryExpression(node)) return false;
|
|
2443
|
+
if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false;
|
|
2444
|
+
if (!ts.isTypeOfExpression(node.left)) return false;
|
|
2445
|
+
if (!ts.isIdentifier(node.left.expression) || node.left.expression.text !== "process") return false;
|
|
2446
|
+
return ts.isStringLiteral(node.right) && node.right.text === "undefined";
|
|
1990
2447
|
}
|
|
1991
|
-
function
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
2448
|
+
function isProcessNodeEnvProdGuard(node) {
|
|
2449
|
+
if (!ts.isBinaryExpression(node)) return false;
|
|
2450
|
+
if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false;
|
|
2451
|
+
const left = node.left;
|
|
2452
|
+
if (!ts.isPropertyAccessExpression(left)) return false;
|
|
2453
|
+
if (!ts.isIdentifier(left.name) || left.name.text !== "NODE_ENV") return false;
|
|
2454
|
+
if (!ts.isPropertyAccessExpression(left.expression)) return false;
|
|
2455
|
+
if (!ts.isIdentifier(left.expression.name) || left.expression.name.text !== "env") return false;
|
|
2456
|
+
if (!ts.isIdentifier(left.expression.expression)) return false;
|
|
2457
|
+
if (left.expression.expression.text !== "process") return false;
|
|
2458
|
+
return ts.isStringLiteral(node.right) && node.right.text === "production";
|
|
1995
2459
|
}
|
|
1996
|
-
function
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2460
|
+
function detectProcessDevGate(ctx, node) {
|
|
2461
|
+
if (node.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) return;
|
|
2462
|
+
if (!(isTypeofProcess(node.left) && isProcessNodeEnvProdGuard(node.right) || isTypeofProcess(node.right) && isProcessNodeEnvProdGuard(node.left))) return;
|
|
2463
|
+
pushDiag(ctx, node, "process-dev-gate", "The `typeof process !== \"undefined\" && process.env.NODE_ENV !== \"production\"` gate is DEAD CODE in real Vite browser bundles — Vite does not polyfill `process`. Unit tests pass (vitest has `process`) but the warning never fires in production. Use `import.meta.env?.DEV` instead, which Vite literal-replaces at build time.", getNodeText(ctx, node), "import.meta.env?.DEV === true", false);
|
|
2000
2464
|
}
|
|
2001
|
-
function
|
|
2465
|
+
function detectEmptyTheme(ctx, node) {
|
|
2466
|
+
const callee = node.expression;
|
|
2467
|
+
if (!ts.isPropertyAccessExpression(callee)) return;
|
|
2468
|
+
if (!ts.isIdentifier(callee.name) || callee.name.text !== "theme") return;
|
|
2469
|
+
if (node.arguments.length !== 1) return;
|
|
2002
2470
|
const arg = node.arguments[0];
|
|
2003
|
-
if (arg
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
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);
|
|
2007
|
-
}
|
|
2008
|
-
}
|
|
2009
|
-
function detectUseReducer(ctx, node) {
|
|
2010
|
-
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);
|
|
2471
|
+
if (!arg || !ts.isObjectLiteralExpression(arg)) return;
|
|
2472
|
+
if (arg.properties.length !== 0) return;
|
|
2473
|
+
pushDiag(ctx, node, "empty-theme", "`.theme({})` is a no-op chain. If the component needs no base theme, skip `.theme()` entirely rather than calling it with an empty object.", getNodeText(ctx, node), getNodeText(ctx, callee.expression), false);
|
|
2011
2474
|
}
|
|
2012
|
-
|
|
2013
|
-
|
|
2475
|
+
const QUERY_OPTS_HOOKS = new Set([
|
|
2476
|
+
"useQuery",
|
|
2477
|
+
"useInfiniteQuery",
|
|
2478
|
+
"useQueries",
|
|
2479
|
+
"useSuspenseQuery"
|
|
2480
|
+
]);
|
|
2481
|
+
function detectQueryOptionsAsFunction(ctx, node) {
|
|
2482
|
+
if (!ts.isIdentifier(node.expression)) return;
|
|
2483
|
+
const hook = node.expression.text;
|
|
2484
|
+
if (!QUERY_OPTS_HOOKS.has(hook)) return;
|
|
2485
|
+
const arg0 = node.arguments[0];
|
|
2486
|
+
if (!arg0 || !ts.isObjectLiteralExpression(arg0)) return;
|
|
2487
|
+
const objText = getNodeText(ctx, arg0);
|
|
2488
|
+
pushDiag(ctx, node, "query-options-as-function", `\`${hook}\` takes options as a FUNCTION so \`queryKey\` can read signals and refetch reactively — an object literal is captured once and never reacts. Wrap it: \`${hook}(() => (...))\`.`, getNodeText(ctx, node), `${hook}(() => (${objText}))`, false);
|
|
2014
2489
|
}
|
|
2015
|
-
function
|
|
2490
|
+
function detectRawEventListener(ctx, node) {
|
|
2016
2491
|
const callee = node.expression;
|
|
2017
|
-
if (ts.
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2492
|
+
if (!ts.isPropertyAccessExpression(callee)) return;
|
|
2493
|
+
if (!ts.isIdentifier(callee.name)) return;
|
|
2494
|
+
const method = callee.name.text;
|
|
2495
|
+
if (method !== "addEventListener" && method !== "removeEventListener") return;
|
|
2496
|
+
const target = callee.expression;
|
|
2497
|
+
const targetName = ts.isIdentifier(target) ? target.text : ts.isPropertyAccessExpression(target) && ts.isIdentifier(target.name) ? target.name.text : "";
|
|
2498
|
+
if (!new Set([
|
|
2499
|
+
"window",
|
|
2500
|
+
"document",
|
|
2501
|
+
"body",
|
|
2502
|
+
"el",
|
|
2503
|
+
"element",
|
|
2504
|
+
"node",
|
|
2505
|
+
"target"
|
|
2506
|
+
]).has(targetName)) return;
|
|
2507
|
+
if (method === "addEventListener") pushDiag(ctx, node, "raw-add-event-listener", "Raw `addEventListener` in a component / hook body bypasses Pyreon's lifecycle cleanup — listeners leak on unmount. Use `useEventListener` from `@pyreon/hooks` for auto-cleanup.", getNodeText(ctx, node), "useEventListener(target, event, handler)", false);
|
|
2508
|
+
else pushDiag(ctx, node, "raw-remove-event-listener", "Raw `removeEventListener` is the symptom of manual listener management. Replace the paired `addEventListener` with `useEventListener` from `@pyreon/hooks` — it registers the cleanup automatically.", getNodeText(ctx, node), "useEventListener(target, event, handler) // cleanup is automatic", false);
|
|
2022
2509
|
}
|
|
2023
|
-
function
|
|
2024
|
-
|
|
2025
|
-
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);
|
|
2510
|
+
function isCallTo(node, object, method) {
|
|
2511
|
+
return ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === object && ts.isIdentifier(node.expression.name) && node.expression.name.text === method;
|
|
2026
2512
|
}
|
|
2027
|
-
function
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
const jsxElement = findParentJsxElement(node);
|
|
2035
|
-
if (jsxElement) {
|
|
2036
|
-
const tagName = getJsxTagName$1(jsxElement);
|
|
2037
|
-
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);
|
|
2513
|
+
function subtreeHas(node, predicate) {
|
|
2514
|
+
let found = false;
|
|
2515
|
+
function walk(n) {
|
|
2516
|
+
if (found) return;
|
|
2517
|
+
if (predicate(n)) {
|
|
2518
|
+
found = true;
|
|
2519
|
+
return;
|
|
2038
2520
|
}
|
|
2521
|
+
ts.forEachChild(n, walk);
|
|
2039
2522
|
}
|
|
2040
|
-
|
|
2523
|
+
walk(node);
|
|
2524
|
+
return found;
|
|
2041
2525
|
}
|
|
2042
|
-
function
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2526
|
+
function detectDateMathRandomId(ctx, node) {
|
|
2527
|
+
if (!subtreeHas(node, (n) => isCallTo(n, "Date", "now"))) return;
|
|
2528
|
+
if (!subtreeHas(node, (n) => isCallTo(n, "Math", "random"))) return;
|
|
2529
|
+
pushDiag(ctx, node, "date-math-random-id", "Combining `Date.now()` + `Math.random()` for unique IDs is collision-prone under rapid operations (paste, clone) — `Date.now()` returns the same value within a millisecond and `Math.random().toString(36).slice(2, 6)` has only ~1.67M combinations. Use a monotonic counter instead.", getNodeText(ctx, node), "let _counter = 0; const nextId = () => String(++_counter)", false);
|
|
2046
2530
|
}
|
|
2047
|
-
function
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2531
|
+
function detectOnClickUndefined(ctx, node) {
|
|
2532
|
+
if (!ts.isIdentifier(node.name)) return;
|
|
2533
|
+
const attrName = node.name.text;
|
|
2534
|
+
if (!attrName.startsWith("on") || attrName.length < 3) return;
|
|
2535
|
+
if (!node.initializer || !ts.isJsxExpression(node.initializer)) return;
|
|
2536
|
+
const expr = node.initializer.expression;
|
|
2537
|
+
if (!expr) return;
|
|
2538
|
+
if (!(ts.isIdentifier(expr) && expr.text === "undefined" || expr.kind === ts.SyntaxKind.VoidExpression)) return;
|
|
2539
|
+
pushDiag(ctx, node, "on-click-undefined", `\`${attrName}={undefined}\` explicitly passes undefined as a listener. Pyreon's runtime guards against this, but the cleanest pattern is to omit the attribute entirely or use a conditional: \`${attrName}={condition ? handler : undefined}\`.`, getNodeText(ctx, node), `/* omit ${attrName} when the handler is not defined */`, false);
|
|
2055
2540
|
}
|
|
2056
|
-
|
|
2057
|
-
|
|
2541
|
+
/**
|
|
2542
|
+
* Walks the file and collects every identifier bound to a `signal(...)` or
|
|
2543
|
+
* `computed(...)` call. Only `const` declarations are tracked — `let`/`var`
|
|
2544
|
+
* may be reassigned to non-signal values, so a use-site call wouldn't be a
|
|
2545
|
+
* reliable signal-write.
|
|
2546
|
+
*
|
|
2547
|
+
* The collection is intentionally scope-blind: a name shadowed in a nested
|
|
2548
|
+
* scope (`const x = signal(0); function f() { const x = 5; x(7) }`) would
|
|
2549
|
+
* produce a false positive on `x(7)`. That tradeoff is acceptable because
|
|
2550
|
+
* (1) shadowing a signal name with a non-signal is itself unusual and
|
|
2551
|
+
* (2) the detector message points at exactly the wrong-shape call so a
|
|
2552
|
+
* human reviewer can dismiss the rare false positive in seconds.
|
|
2553
|
+
*/
|
|
2554
|
+
function collectSignalBindings(sf) {
|
|
2555
|
+
const names = /* @__PURE__ */ new Set();
|
|
2556
|
+
function isSignalFactoryCall(init) {
|
|
2557
|
+
if (!init || !ts.isCallExpression(init)) return false;
|
|
2558
|
+
const callee = init.expression;
|
|
2559
|
+
if (!ts.isIdentifier(callee)) return false;
|
|
2560
|
+
return callee.text === "signal" || callee.text === "computed";
|
|
2561
|
+
}
|
|
2562
|
+
function walk(node) {
|
|
2563
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
2564
|
+
const list = node.parent;
|
|
2565
|
+
if (ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && isSignalFactoryCall(node.initializer)) names.add(node.name.text);
|
|
2566
|
+
}
|
|
2567
|
+
ts.forEachChild(node, walk);
|
|
2568
|
+
}
|
|
2569
|
+
walk(sf);
|
|
2570
|
+
return names;
|
|
2058
2571
|
}
|
|
2059
|
-
function
|
|
2060
|
-
|
|
2572
|
+
function detectSignalWriteAsCall(ctx, node) {
|
|
2573
|
+
if (ctx.signalBindings.size === 0) return;
|
|
2574
|
+
const callee = node.expression;
|
|
2575
|
+
if (!ts.isIdentifier(callee)) return;
|
|
2576
|
+
if (!ctx.signalBindings.has(callee.text)) return;
|
|
2577
|
+
if (node.arguments.length === 0) return;
|
|
2578
|
+
pushDiag(ctx, node, "signal-write-as-call", `\`${callee.text}(value)\` does NOT write the signal — \`signal()\` is the read-only callable surface and ignores its arguments. Use \`${callee.text}.set(value)\` to assign or \`${callee.text}.update((prev) => …)\` to derive from the previous value. Pyreon's runtime warns about this pattern in dev, but the warning fires AFTER the silent no-op.`, getNodeText(ctx, node), `${callee.text}.set(${node.arguments.map((a) => getNodeText(ctx, a)).join(", ")})`, false);
|
|
2061
2579
|
}
|
|
2062
|
-
|
|
2063
|
-
|
|
2580
|
+
/**
|
|
2581
|
+
* `if (cond) return null` at the top of a component body runs ONCE — Pyreon
|
|
2582
|
+
* components mount and never re-execute their function bodies. A signal
|
|
2583
|
+
* change inside `cond` therefore never re-evaluates the condition; the
|
|
2584
|
+
* component is permanently stuck on whichever branch the first run picked.
|
|
2585
|
+
*
|
|
2586
|
+
* The fix is to wrap the conditional in a returned reactive accessor:
|
|
2587
|
+
* return (() => { if (!cond()) return null; return <div /> })
|
|
2588
|
+
*
|
|
2589
|
+
* Detection:
|
|
2590
|
+
* - The function contains JSX (i.e. it's a component)
|
|
2591
|
+
* - The function body has an `IfStatement` whose `thenStatement` is
|
|
2592
|
+
* `return null` (either bare `return null` or `{ return null }`)
|
|
2593
|
+
* - The `if` is at the function body's top level, NOT inside a returned
|
|
2594
|
+
* arrow / IIFE (those are reactive scopes — flagging them would be a
|
|
2595
|
+
* false positive)
|
|
2596
|
+
*/
|
|
2597
|
+
function returnsNullStatement(stmt) {
|
|
2598
|
+
if (ts.isReturnStatement(stmt)) {
|
|
2599
|
+
const expr = stmt.expression;
|
|
2600
|
+
return !!expr && expr.kind === ts.SyntaxKind.NullKeyword;
|
|
2601
|
+
}
|
|
2602
|
+
if (ts.isBlock(stmt)) return stmt.statements.length === 1 && returnsNullStatement(stmt.statements[0]);
|
|
2603
|
+
return false;
|
|
2064
2604
|
}
|
|
2065
|
-
|
|
2066
|
-
|
|
2605
|
+
/**
|
|
2606
|
+
* Returns true if the function looks like a top-level component:
|
|
2607
|
+
* - `function PascalName(...) { ... }` (FunctionDeclaration with PascalCase id), OR
|
|
2608
|
+
* - `const PascalName = (...) => { ... }` (arrow inside a VariableDeclaration whose name is PascalCase).
|
|
2609
|
+
*
|
|
2610
|
+
* Anonymous nested arrows — most importantly the reactive accessor
|
|
2611
|
+
* `return (() => { if (!cond()) return null; return <div /> })` — are
|
|
2612
|
+
* NOT considered components here, even when they contain JSX. Without
|
|
2613
|
+
* this filter the detector would fire on the very pattern the
|
|
2614
|
+
* diagnostic recommends as the fix.
|
|
2615
|
+
*/
|
|
2616
|
+
function isComponentShapedFunction(node) {
|
|
2617
|
+
if (ts.isFunctionDeclaration(node)) return !!node.name && /^[A-Z]/.test(node.name.text);
|
|
2618
|
+
const parent = node.parent;
|
|
2619
|
+
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return /^[A-Z]/.test(parent.name.text);
|
|
2620
|
+
return false;
|
|
2067
2621
|
}
|
|
2068
|
-
function
|
|
2069
|
-
if (
|
|
2070
|
-
if (
|
|
2071
|
-
|
|
2072
|
-
if (
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
if (isDotValueAccess(node)) detectDotValueSignal(ctx, node);
|
|
2080
|
-
if (isMapCallExpression(node)) detectArrayMapJsx(ctx, node);
|
|
2622
|
+
function detectStaticReturnNullConditional(ctx, node) {
|
|
2623
|
+
if (!isComponentShapedFunction(node)) return;
|
|
2624
|
+
if (!containsJsx(node)) return;
|
|
2625
|
+
const body = node.body;
|
|
2626
|
+
if (!body || !ts.isBlock(body)) return;
|
|
2627
|
+
for (const stmt of body.statements) {
|
|
2628
|
+
if (!ts.isIfStatement(stmt)) continue;
|
|
2629
|
+
if (!returnsNullStatement(stmt.thenStatement)) continue;
|
|
2630
|
+
pushDiag(ctx, stmt, "static-return-null-conditional", "Pyreon components run ONCE — `if (cond) return null` at the top of a component body is evaluated exactly once at mount. Reading a signal inside `cond` will NOT re-trigger the early return when the signal changes; the component is stuck on whichever branch the first run picked. Wrap the conditional in a returned reactive accessor: `return (() => { if (!cond()) return null; return <div /> })` — the accessor re-runs whenever its tracked signals change.", getNodeText(ctx, stmt), "return (() => { if (!cond()) return null; return <JSX /> })", false);
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2081
2633
|
}
|
|
2082
|
-
|
|
2634
|
+
/**
|
|
2635
|
+
* `JSX.Element` (which is what JSX evaluates to) is already assignable to
|
|
2636
|
+
* `VNodeChild`. The `as unknown as VNodeChild` double-cast is unnecessary
|
|
2637
|
+
* — it's been showing up in `@pyreon/ui-primitives` as a defensive habit
|
|
2638
|
+
* carried over from earlier framework versions. The cast is never load-
|
|
2639
|
+
* bearing today; removing it never changes runtime behavior. Pure cosmetic
|
|
2640
|
+
* but a useful proxy for non-idiomatic Pyreon code in primitives.
|
|
2641
|
+
*/
|
|
2642
|
+
function detectAsUnknownAsVNodeChild(ctx, node) {
|
|
2643
|
+
const outerType = node.type;
|
|
2644
|
+
if (!ts.isTypeReferenceNode(outerType)) return;
|
|
2645
|
+
if (!ts.isIdentifier(outerType.typeName) || outerType.typeName.text !== "VNodeChild") return;
|
|
2646
|
+
const inner = node.expression;
|
|
2647
|
+
if (!ts.isAsExpression(inner)) return;
|
|
2648
|
+
if (inner.type.kind !== ts.SyntaxKind.UnknownKeyword) return;
|
|
2649
|
+
pushDiag(ctx, node, "as-unknown-as-vnodechild", "`as unknown as VNodeChild` is unnecessary — `JSX.Element` (the type produced by JSX) is already assignable to `VNodeChild`. Remove the double cast; it is pure noise that hides genuine type issues if they ever appear at this site.", getNodeText(ctx, node), getNodeText(ctx, inner.expression), false);
|
|
2650
|
+
}
|
|
2651
|
+
/**
|
|
2652
|
+
* Pre-pass: walk the source for `island(loader, { name: 'X', hydrate: 'never' })`
|
|
2653
|
+
* call expressions and collect the `name` field of each never-strategy island.
|
|
2654
|
+
*
|
|
2655
|
+
* Recognized shape (mirrors `@pyreon/vite-plugin`'s `scanIslandDeclarations`):
|
|
2656
|
+
*
|
|
2657
|
+
* island(() => import('./X'), { name: 'X', hydrate: 'never' })
|
|
2658
|
+
*
|
|
2659
|
+
* Edge cases the AST-walker deliberately doesn't cover (unrecognized calls
|
|
2660
|
+
* fall through and don't populate the set — false-negatives, not false
|
|
2661
|
+
* positives):
|
|
2662
|
+
*
|
|
2663
|
+
* - Loader is a variable, not an inline arrow
|
|
2664
|
+
* - Name is a variable / template / spread, not a string literal
|
|
2665
|
+
* - Options come from a spread (`island(loader, opts)`)
|
|
2666
|
+
*
|
|
2667
|
+
* The same rules apply on the registry side (`detectIslandNeverWithRegistry`):
|
|
2668
|
+
* unrecognized keys won't match. Both halves are syntactic — a semantic
|
|
2669
|
+
* cross-package audit lives in `pyreon doctor --check-islands` (separate PR).
|
|
2670
|
+
*/
|
|
2671
|
+
function collectNeverIslandNames(sf) {
|
|
2672
|
+
const names = /* @__PURE__ */ new Set();
|
|
2673
|
+
function walk(node) {
|
|
2674
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "island" && node.arguments.length >= 2) {
|
|
2675
|
+
const opts = node.arguments[1];
|
|
2676
|
+
if (opts && ts.isObjectLiteralExpression(opts)) {
|
|
2677
|
+
let nameVal;
|
|
2678
|
+
let hydrateVal;
|
|
2679
|
+
for (const prop of opts.properties) {
|
|
2680
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
2681
|
+
const key = prop.name;
|
|
2682
|
+
const keyText = ts.isIdentifier(key) ? key.text : ts.isStringLiteral(key) ? key.text : "";
|
|
2683
|
+
if (keyText === "name" && ts.isStringLiteral(prop.initializer)) nameVal = prop.initializer.text;
|
|
2684
|
+
else if (keyText === "hydrate" && ts.isStringLiteral(prop.initializer)) hydrateVal = prop.initializer.text;
|
|
2685
|
+
}
|
|
2686
|
+
if (nameVal && hydrateVal === "never") names.add(nameVal);
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
ts.forEachChild(node, walk);
|
|
2690
|
+
}
|
|
2691
|
+
walk(sf);
|
|
2692
|
+
return names;
|
|
2693
|
+
}
|
|
2694
|
+
/**
|
|
2695
|
+
* Flag entries in `hydrateIslands({ X: () => import('./X'), ... })` whose
|
|
2696
|
+
* key matches an `island()` name declared with `hydrate: 'never'` in the
|
|
2697
|
+
* same file. Each matching entry produces one diagnostic at the property's
|
|
2698
|
+
* location so the IDE highlights exactly which key needs to go.
|
|
2699
|
+
*/
|
|
2700
|
+
function detectIslandNeverWithRegistry(ctx, node) {
|
|
2701
|
+
if (ctx.neverIslandNames.size === 0) return;
|
|
2702
|
+
const callee = node.expression;
|
|
2703
|
+
if (!ts.isIdentifier(callee) || callee.text !== "hydrateIslands") return;
|
|
2704
|
+
const arg = node.arguments[0];
|
|
2705
|
+
if (!arg || !ts.isObjectLiteralExpression(arg)) return;
|
|
2706
|
+
for (const prop of arg.properties) {
|
|
2707
|
+
if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue;
|
|
2708
|
+
const key = prop.name;
|
|
2709
|
+
const keyText = ts.isIdentifier(key) ? key.text : ts.isStringLiteral(key) ? key.text : "";
|
|
2710
|
+
if (!keyText || !ctx.neverIslandNames.has(keyText)) continue;
|
|
2711
|
+
pushDiag(ctx, prop, "island-never-with-registry-entry", `island "${keyText}" was declared with \`hydrate: 'never'\` and MUST NOT be registered in \`hydrateIslands({ ... })\`. The whole point of the \`'never'\` strategy is shipping zero client JS — registering pulls the component module into the client bundle graph (the runtime short-circuits never-strategy before the registry lookup, but the bundler still includes the import). Drop this entry; the framework handles never-strategy islands at SSR with no client-side wiring.`, getNodeText(ctx, prop), `// remove the "${keyText}" entry — never-strategy islands need no registry entry`, false);
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
function visitNode(ctx, node) {
|
|
2715
|
+
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) detectForKeying(ctx, node);
|
|
2716
|
+
if (ts.isArrowFunction(node) || ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) {
|
|
2717
|
+
detectPropsDestructured(ctx, node);
|
|
2718
|
+
detectPropsDestructuredBody(ctx, node);
|
|
2719
|
+
detectStaticReturnNullConditional(ctx, node);
|
|
2720
|
+
}
|
|
2721
|
+
if (ts.isBinaryExpression(node)) {
|
|
2722
|
+
detectProcessDevGate(ctx, node);
|
|
2723
|
+
detectDateMathRandomId(ctx, node);
|
|
2724
|
+
}
|
|
2725
|
+
if (ts.isTemplateExpression(node)) detectDateMathRandomId(ctx, node);
|
|
2726
|
+
if (ts.isCallExpression(node)) {
|
|
2727
|
+
detectEmptyTheme(ctx, node);
|
|
2728
|
+
detectRawEventListener(ctx, node);
|
|
2729
|
+
detectSignalWriteAsCall(ctx, node);
|
|
2730
|
+
detectIslandNeverWithRegistry(ctx, node);
|
|
2731
|
+
detectQueryOptionsAsFunction(ctx, node);
|
|
2732
|
+
}
|
|
2733
|
+
if (ts.isJsxAttribute(node)) detectOnClickUndefined(ctx, node);
|
|
2734
|
+
if (ts.isAsExpression(node)) detectAsUnknownAsVNodeChild(ctx, node);
|
|
2735
|
+
}
|
|
2736
|
+
function visit(ctx, node) {
|
|
2083
2737
|
ts.forEachChild(node, (child) => {
|
|
2084
|
-
|
|
2085
|
-
|
|
2738
|
+
visitNode(ctx, child);
|
|
2739
|
+
visit(ctx, child);
|
|
2086
2740
|
});
|
|
2087
2741
|
}
|
|
2088
|
-
function
|
|
2742
|
+
function detectPyreonPatterns(code, filename = "input.tsx") {
|
|
2089
2743
|
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
|
|
2090
2744
|
const ctx = {
|
|
2091
2745
|
sf,
|
|
2092
2746
|
code,
|
|
2093
2747
|
diagnostics: [],
|
|
2094
|
-
|
|
2748
|
+
signalBindings: collectSignalBindings(sf),
|
|
2749
|
+
neverIslandNames: collectNeverIslandNames(sf)
|
|
2095
2750
|
};
|
|
2096
|
-
|
|
2751
|
+
visit(ctx, sf);
|
|
2752
|
+
ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
2097
2753
|
return ctx.diagnostics;
|
|
2098
2754
|
}
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
let
|
|
2102
|
-
if (!specs) {
|
|
2103
|
-
specs = /* @__PURE__ */ new Set();
|
|
2104
|
-
ctx.pyreonImports.set(source, specs);
|
|
2105
|
-
}
|
|
2106
|
-
specs.add(specifier);
|
|
2107
|
-
}
|
|
2108
|
-
function migrateReplace(ctx, node, text) {
|
|
2109
|
-
ctx.replacements.push({
|
|
2110
|
-
start: node.getStart(ctx.sf),
|
|
2111
|
-
end: node.getEnd(),
|
|
2112
|
-
text
|
|
2113
|
-
});
|
|
2755
|
+
/** Fast regex pre-filter — returns true if the code is worth a full AST walk. */
|
|
2756
|
+
function hasPyreonPatterns(code) {
|
|
2757
|
+
return /\bFor\b[^=]*\beach\s*=/.test(code) || /\btypeof\s+process\b/.test(code) || /\.theme\s*\(\s*\{\s*\}\s*\)/.test(code) || /\b(?:add|remove)EventListener\s*\(/.test(code) || /\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code) || /on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) || /=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code) || /\b(?:const|let|var)\s+\{[^}]*\}\s*=\s*[A-Za-z_$]/.test(code) || /\b(?:signal|computed)\s*[<(]/.test(code) || /\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) || /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code) || /\b(?:useQuery|useInfiniteQuery|useQueries|useSuspenseQuery)\s*\(\s*\{/.test(code) || /\bisland\s*\(/.test(code) && /\bhydrate\s*:\s*['"]never['"]/.test(code);
|
|
2114
2758
|
}
|
|
2115
|
-
|
|
2116
|
-
|
|
2759
|
+
|
|
2760
|
+
//#endregion
|
|
2761
|
+
//#region src/reactivity-lens.ts
|
|
2762
|
+
/**
|
|
2763
|
+
* Reactivity Lens — surface the compiler's already-computed reactivity
|
|
2764
|
+
* analysis back to the author at the source.
|
|
2765
|
+
*
|
|
2766
|
+
* Pyreon's #1 silent footgun class: whether code is reactive is invisible at
|
|
2767
|
+
* the moment you write it. `const {x}=props` compiles fine, types fine,
|
|
2768
|
+
* renders once, and is dead. `<div>{x}</div>` where `x` isn't a signal bakes
|
|
2769
|
+
* once. The `@pyreon/compiler` ALREADY decides this per-expression (it has to,
|
|
2770
|
+
* for codegen) and then throws the analysis away. This module pipes it back.
|
|
2771
|
+
*
|
|
2772
|
+
* `analyzeReactivity()` is the single entry point. It returns a sorted list of
|
|
2773
|
+
* {@link ReactivityFinding}s built from TWO faithful sources, neither of which
|
|
2774
|
+
* is a fresh approximation:
|
|
2775
|
+
*
|
|
2776
|
+
* 1. **Compiler structural facts** — `TransformResult.reactivityLens`. Each
|
|
2777
|
+
* span is a *record* of a codegen decision (`_bind`/`_bindText`/`_rp`/
|
|
2778
|
+
* hoist/static-text). The positive "this is live" claim is the codegen
|
|
2779
|
+
* branch itself, so it is correct by construction (drift-gated).
|
|
2780
|
+
* 2. **Footgun negatives** — the existing `detectPyreonPatterns` AST
|
|
2781
|
+
* detectors (`props-destructured`, `signal-write-as-call`, …). Already
|
|
2782
|
+
* shipped, already AST-based; the lens just unifies them under one
|
|
2783
|
+
* editor-facing taxonomy.
|
|
2784
|
+
*
|
|
2785
|
+
* Absence of a finding is "not asserted", NEVER an implicit static claim —
|
|
2786
|
+
* see the asymmetric-precision commitment in `.claude/plans/reactivity-lens.md`.
|
|
2787
|
+
*
|
|
2788
|
+
* JS-backend only (Phase 1). The native Rust binary emits byte-identical
|
|
2789
|
+
* codegen (527 cross-backend equivalence tests), so the JS path is a sound
|
|
2790
|
+
* oracle for the analysis; Rust-path parity is Phase 3.
|
|
2791
|
+
*
|
|
2792
|
+
* @module
|
|
2793
|
+
*/
|
|
2794
|
+
function spanToFinding(s) {
|
|
2795
|
+
return {
|
|
2796
|
+
kind: s.kind,
|
|
2797
|
+
line: s.line,
|
|
2798
|
+
column: s.column,
|
|
2799
|
+
endLine: s.endLine,
|
|
2800
|
+
endColumn: s.endColumn,
|
|
2801
|
+
detail: s.detail
|
|
2802
|
+
};
|
|
2117
2803
|
}
|
|
2118
|
-
|
|
2119
|
-
|
|
2804
|
+
/**
|
|
2805
|
+
* Analyze a source file's reactivity. Pure, side-effect-free, deterministic.
|
|
2806
|
+
*
|
|
2807
|
+
* @param code Source text (`.tsx` / `.jsx` / `.ts`).
|
|
2808
|
+
* @param filename Used only for parse-mode (`tsx` vs `jsx`) detection.
|
|
2809
|
+
* @param options `knownSignals` is forwarded to the compiler so
|
|
2810
|
+
* cross-module imported signals are auto-call-aware.
|
|
2811
|
+
*
|
|
2812
|
+
* @example
|
|
2813
|
+
* const { findings } = analyzeReactivity(
|
|
2814
|
+
* `function C(){ const {x}=props; return <div>{count()}</div> }`,
|
|
2815
|
+
* )
|
|
2816
|
+
* // → footgun(props-destructured) on `{x}`, reactive on `count()`
|
|
2817
|
+
*/
|
|
2818
|
+
function analyzeReactivity(code, filename = "input.tsx", options = {}) {
|
|
2819
|
+
let spans = [];
|
|
2820
|
+
try {
|
|
2821
|
+
spans = transformJSX_JS(code, filename, {
|
|
2822
|
+
reactivityLens: true,
|
|
2823
|
+
...options.knownSignals ? { knownSignals: options.knownSignals } : {}
|
|
2824
|
+
}).reactivityLens ?? [];
|
|
2825
|
+
} catch {
|
|
2826
|
+
spans = [];
|
|
2827
|
+
}
|
|
2828
|
+
const findings = spans.map(spanToFinding);
|
|
2829
|
+
let footguns = [];
|
|
2830
|
+
try {
|
|
2831
|
+
footguns = detectPyreonPatterns(code, filename);
|
|
2832
|
+
} catch {
|
|
2833
|
+
footguns = [];
|
|
2834
|
+
}
|
|
2835
|
+
for (const d of footguns) {
|
|
2836
|
+
const firstLineLen = d.current.split("\n")[0]?.length ?? d.current.length;
|
|
2837
|
+
findings.push({
|
|
2838
|
+
kind: "footgun",
|
|
2839
|
+
line: d.line,
|
|
2840
|
+
column: d.column,
|
|
2841
|
+
endLine: d.line,
|
|
2842
|
+
endColumn: d.column + firstLineLen,
|
|
2843
|
+
detail: d.message,
|
|
2844
|
+
code: d.code,
|
|
2845
|
+
fixable: d.fixable
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
findings.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
2849
|
+
return {
|
|
2850
|
+
findings,
|
|
2851
|
+
spans
|
|
2852
|
+
};
|
|
2120
2853
|
}
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2854
|
+
const KIND_BADGE = {
|
|
2855
|
+
reactive: "◆ live",
|
|
2856
|
+
"reactive-prop": "◆ live prop",
|
|
2857
|
+
"reactive-attr": "◆ live attr",
|
|
2858
|
+
"static-text": "○ baked once",
|
|
2859
|
+
"hoisted-static": "○ hoisted static",
|
|
2860
|
+
footgun: "⚠ footgun"
|
|
2861
|
+
};
|
|
2862
|
+
/**
|
|
2863
|
+
* Render an annotated source view for CLI / debugging — every analyzed line
|
|
2864
|
+
* followed by its reactivity findings. Not the production surface (that's the
|
|
2865
|
+
* LSP inlay hints); this is the spike's "can you see reactivity flow" probe
|
|
2866
|
+
* and a stable diff target for tests.
|
|
2867
|
+
*/
|
|
2868
|
+
function formatReactivityLens(code, result) {
|
|
2869
|
+
const lines = code.split("\n");
|
|
2870
|
+
const byLine = /* @__PURE__ */ new Map();
|
|
2871
|
+
for (const f of result.findings) {
|
|
2872
|
+
const arr = byLine.get(f.line) ?? [];
|
|
2873
|
+
arr.push(f);
|
|
2874
|
+
byLine.set(f.line, arr);
|
|
2875
|
+
}
|
|
2876
|
+
const out = [];
|
|
2877
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2878
|
+
const lineNo = i + 1;
|
|
2879
|
+
out.push(`${String(lineNo).padStart(4)} | ${lines[i]}`);
|
|
2880
|
+
const fs = byLine.get(lineNo);
|
|
2881
|
+
if (fs) for (const f of fs) {
|
|
2882
|
+
const pad = " ".repeat(7 + f.column);
|
|
2883
|
+
const tag = f.code ? ` [${f.code}]` : "";
|
|
2884
|
+
out.push(`${pad}^ ${KIND_BADGE[f.kind]}${tag} — ${f.detail}`);
|
|
2129
2885
|
}
|
|
2130
2886
|
}
|
|
2131
|
-
|
|
2887
|
+
return out.join("\n");
|
|
2132
2888
|
}
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
line: migrateGetLine(ctx, node),
|
|
2150
|
-
description: `useState → signal: ${valueName}`
|
|
2151
|
-
});
|
|
2152
|
-
}
|
|
2153
|
-
}
|
|
2154
|
-
function migrateUseEffect(ctx, node) {
|
|
2155
|
-
const depsArg = node.arguments[1];
|
|
2156
|
-
const callbackArg = node.arguments[0];
|
|
2157
|
-
const hookName = node.expression.text;
|
|
2158
|
-
if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0 && callbackArg) {
|
|
2159
|
-
migrateReplace(ctx, node, `onMount(${migrateGetNodeText(ctx, callbackArg)})`);
|
|
2160
|
-
migrateAddImport(ctx, "@pyreon/core", "onMount");
|
|
2161
|
-
ctx.changes.push({
|
|
2162
|
-
type: "replace",
|
|
2163
|
-
line: migrateGetLine(ctx, node),
|
|
2164
|
-
description: `${hookName}(fn, []) → onMount(fn)`
|
|
2165
|
-
});
|
|
2166
|
-
} else if (callbackArg) {
|
|
2167
|
-
migrateReplace(ctx, node, `effect(${migrateGetNodeText(ctx, callbackArg)})`);
|
|
2168
|
-
migrateAddImport(ctx, "@pyreon/reactivity", "effect");
|
|
2169
|
-
ctx.changes.push({
|
|
2170
|
-
type: "replace",
|
|
2171
|
-
line: migrateGetLine(ctx, node),
|
|
2172
|
-
description: `${hookName} → effect (auto-tracks deps)`
|
|
2173
|
-
});
|
|
2174
|
-
}
|
|
2175
|
-
}
|
|
2176
|
-
function migrateUseMemo(ctx, node) {
|
|
2177
|
-
const computeFn = node.arguments[0];
|
|
2178
|
-
if (computeFn) {
|
|
2179
|
-
migrateReplace(ctx, node, `computed(${migrateGetNodeText(ctx, computeFn)})`);
|
|
2180
|
-
migrateAddImport(ctx, "@pyreon/reactivity", "computed");
|
|
2181
|
-
ctx.changes.push({
|
|
2182
|
-
type: "replace",
|
|
2183
|
-
line: migrateGetLine(ctx, node),
|
|
2184
|
-
description: "useMemo → computed (auto-tracks deps)"
|
|
2185
|
-
});
|
|
2186
|
-
}
|
|
2187
|
-
}
|
|
2188
|
-
function migrateUseCallback(ctx, node) {
|
|
2189
|
-
const callbackFn = node.arguments[0];
|
|
2190
|
-
if (callbackFn) {
|
|
2191
|
-
migrateReplace(ctx, node, migrateGetNodeText(ctx, callbackFn));
|
|
2192
|
-
ctx.changes.push({
|
|
2193
|
-
type: "replace",
|
|
2194
|
-
line: migrateGetLine(ctx, node),
|
|
2195
|
-
description: "useCallback → plain function (not needed in Pyreon)"
|
|
2196
|
-
});
|
|
2197
|
-
}
|
|
2198
|
-
}
|
|
2199
|
-
function migrateUseRef(ctx, node) {
|
|
2200
|
-
const arg = node.arguments[0];
|
|
2201
|
-
if (arg && (arg.kind === ts.SyntaxKind.NullKeyword || ts.isIdentifier(arg) && arg.text === "undefined") || !arg) {
|
|
2202
|
-
migrateReplace(ctx, node, "createRef()");
|
|
2203
|
-
migrateAddImport(ctx, "@pyreon/core", "createRef");
|
|
2204
|
-
ctx.changes.push({
|
|
2205
|
-
type: "replace",
|
|
2206
|
-
line: migrateGetLine(ctx, node),
|
|
2207
|
-
description: "useRef(null) → createRef()"
|
|
2208
|
-
});
|
|
2209
|
-
} else {
|
|
2210
|
-
migrateReplace(ctx, node, `signal(${migrateGetNodeText(ctx, arg)})`);
|
|
2211
|
-
migrateAddImport(ctx, "@pyreon/reactivity", "signal");
|
|
2212
|
-
ctx.changes.push({
|
|
2213
|
-
type: "replace",
|
|
2214
|
-
line: migrateGetLine(ctx, node),
|
|
2215
|
-
description: "useRef(value) → signal(value)"
|
|
2216
|
-
});
|
|
2217
|
-
}
|
|
2218
|
-
}
|
|
2219
|
-
function migrateMemoWrapper(ctx, node) {
|
|
2220
|
-
const callee = node.expression;
|
|
2221
|
-
if ((ts.isIdentifier(callee) && callee.text === "memo" || isCallToReactDot(callee, "memo")) && node.arguments[0]) {
|
|
2222
|
-
migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]));
|
|
2223
|
-
ctx.changes.push({
|
|
2224
|
-
type: "remove",
|
|
2225
|
-
line: migrateGetLine(ctx, node),
|
|
2226
|
-
description: "Removed memo() wrapper (not needed in Pyreon)"
|
|
2227
|
-
});
|
|
2228
|
-
}
|
|
2889
|
+
|
|
2890
|
+
//#endregion
|
|
2891
|
+
//#region src/project-scanner.ts
|
|
2892
|
+
/**
|
|
2893
|
+
* Project scanner — extracts route, component, and island information from source files.
|
|
2894
|
+
*/
|
|
2895
|
+
function generateContext(cwd) {
|
|
2896
|
+
const files = collectSourceFiles(cwd);
|
|
2897
|
+
return {
|
|
2898
|
+
framework: "pyreon",
|
|
2899
|
+
version: readVersion(cwd),
|
|
2900
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2901
|
+
routes: extractRoutes(files, cwd),
|
|
2902
|
+
components: extractComponents(files, cwd),
|
|
2903
|
+
islands: extractIslands(files, cwd)
|
|
2904
|
+
};
|
|
2229
2905
|
}
|
|
2230
|
-
function
|
|
2231
|
-
const
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2906
|
+
function collectSourceFiles(cwd) {
|
|
2907
|
+
const results = [];
|
|
2908
|
+
const extensions = new Set([
|
|
2909
|
+
".tsx",
|
|
2910
|
+
".jsx",
|
|
2911
|
+
".ts",
|
|
2912
|
+
".js"
|
|
2913
|
+
]);
|
|
2914
|
+
const ignoreDirs = new Set([
|
|
2915
|
+
"node_modules",
|
|
2916
|
+
"dist",
|
|
2917
|
+
"lib",
|
|
2918
|
+
".pyreon",
|
|
2919
|
+
".git",
|
|
2920
|
+
"build"
|
|
2921
|
+
]);
|
|
2922
|
+
function walk(dir) {
|
|
2923
|
+
let entries;
|
|
2924
|
+
try {
|
|
2925
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2926
|
+
} catch {
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2929
|
+
for (const entry of entries) {
|
|
2930
|
+
if (entry.name.startsWith(".") && entry.isDirectory()) continue;
|
|
2931
|
+
if (ignoreDirs.has(entry.name) && entry.isDirectory()) continue;
|
|
2932
|
+
const fullPath = path.join(dir, entry.name);
|
|
2933
|
+
if (entry.isDirectory()) walk(fullPath);
|
|
2934
|
+
else if (entry.isFile() && extensions.has(path.extname(entry.name))) results.push(fullPath);
|
|
2935
|
+
}
|
|
2239
2936
|
}
|
|
2937
|
+
walk(cwd);
|
|
2938
|
+
return results;
|
|
2240
2939
|
}
|
|
2241
|
-
function
|
|
2242
|
-
const
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
}
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
type: "replace",
|
|
2268
|
-
line: migrateGetLine(ctx, node),
|
|
2269
|
-
description: `onChange on <${tagName}> → onInput (native DOM events)`
|
|
2940
|
+
function extractRoutes(files, _cwd) {
|
|
2941
|
+
const routes = [];
|
|
2942
|
+
for (const file of files) {
|
|
2943
|
+
let code;
|
|
2944
|
+
try {
|
|
2945
|
+
code = fs.readFileSync(file, "utf-8");
|
|
2946
|
+
} catch {
|
|
2947
|
+
continue;
|
|
2948
|
+
}
|
|
2949
|
+
const routeArrayRe = /(?:createRouter\s*\(\s*\[|(?:const|let)\s+routes\s*(?::\s*RouteRecord\[\])?\s*=\s*\[)([\s\S]*?)\]/g;
|
|
2950
|
+
let match;
|
|
2951
|
+
for (match = routeArrayRe.exec(code); match; match = routeArrayRe.exec(code)) {
|
|
2952
|
+
const block = match[1] ?? "";
|
|
2953
|
+
const routeObjRe = /path\s*:\s*["']([^"']+)["']/g;
|
|
2954
|
+
let routeMatch;
|
|
2955
|
+
for (routeMatch = routeObjRe.exec(block); routeMatch; routeMatch = routeObjRe.exec(block)) {
|
|
2956
|
+
const routePath = routeMatch[1] ?? "";
|
|
2957
|
+
const surroundingStart = Math.max(0, routeMatch.index - 50);
|
|
2958
|
+
const surroundingEnd = Math.min(block.length, routeMatch.index + 200);
|
|
2959
|
+
const surrounding = block.slice(surroundingStart, surroundingEnd);
|
|
2960
|
+
routes.push({
|
|
2961
|
+
path: routePath,
|
|
2962
|
+
name: surrounding.match(/name\s*:\s*["']([^"']+)["']/)?.[1],
|
|
2963
|
+
hasLoader: /loader\s*:/.test(surrounding),
|
|
2964
|
+
hasGuard: /beforeEnter\s*:|beforeLeave\s*:/.test(surrounding),
|
|
2965
|
+
params: extractParams(routePath)
|
|
2270
2966
|
});
|
|
2271
2967
|
}
|
|
2272
2968
|
}
|
|
2273
2969
|
}
|
|
2274
|
-
|
|
2275
|
-
}
|
|
2276
|
-
function migrateDangerouslySetInnerHTML(ctx, node) {
|
|
2277
|
-
if (!node.initializer || !ts.isJsxExpression(node.initializer) || !node.initializer.expression) return;
|
|
2278
|
-
const expr = node.initializer.expression;
|
|
2279
|
-
if (!ts.isObjectLiteralExpression(expr)) return;
|
|
2280
|
-
const htmlProp = expr.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "__html");
|
|
2281
|
-
if (htmlProp) {
|
|
2282
|
-
migrateReplace(ctx, node, `innerHTML={${migrateGetNodeText(ctx, htmlProp.initializer)}}`);
|
|
2283
|
-
ctx.changes.push({
|
|
2284
|
-
type: "replace",
|
|
2285
|
-
line: migrateGetLine(ctx, node),
|
|
2286
|
-
description: "dangerouslySetInnerHTML → innerHTML"
|
|
2287
|
-
});
|
|
2288
|
-
}
|
|
2970
|
+
return routes;
|
|
2289
2971
|
}
|
|
2290
|
-
function
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
}
|
|
2297
|
-
|
|
2298
|
-
type: "remove",
|
|
2299
|
-
line: ctx.sf.getLineAndCharacterOfPosition(imp.getStart(ctx.sf)).line + 1,
|
|
2300
|
-
description: "Removed React import"
|
|
2301
|
-
});
|
|
2302
|
-
}
|
|
2303
|
-
ctx.replacements.sort((a, b) => b.start - a.start);
|
|
2304
|
-
const applied = /* @__PURE__ */ new Set();
|
|
2305
|
-
const deduped = [];
|
|
2306
|
-
for (const r of ctx.replacements) {
|
|
2307
|
-
const key = `${r.start}:${r.end}`;
|
|
2308
|
-
let overlaps = false;
|
|
2309
|
-
for (const d of deduped) if (r.start < d.end && r.end > d.start) {
|
|
2310
|
-
overlaps = true;
|
|
2311
|
-
break;
|
|
2972
|
+
function extractComponents(files, cwd) {
|
|
2973
|
+
const components = [];
|
|
2974
|
+
for (const file of files) {
|
|
2975
|
+
let code;
|
|
2976
|
+
try {
|
|
2977
|
+
code = fs.readFileSync(file, "utf-8");
|
|
2978
|
+
} catch {
|
|
2979
|
+
continue;
|
|
2312
2980
|
}
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2981
|
+
const componentRe = /(?:export\s+)?(?:const|function)\s+([A-Z]\w*)\s*(?::\s*ComponentFn<[^>]+>\s*)?=?\s*\(?(?:\s*\{?\s*([^)]*?)\s*\}?\s*)?\)?\s*(?:=>|{)/g;
|
|
2982
|
+
let match;
|
|
2983
|
+
for (match = componentRe.exec(code); match; match = componentRe.exec(code)) {
|
|
2984
|
+
const name = match[1] ?? "Unknown";
|
|
2985
|
+
const props = (match[2] ?? "").split(/[,;]/).map((p) => p.trim().replace(/[{}]/g, "").trim().split(":")[0]?.split("=")[0]?.trim() ?? "").filter((p) => p && p !== "props");
|
|
2986
|
+
const bodyStart = match.index + match[0].length;
|
|
2987
|
+
const body = code.slice(bodyStart, Math.min(code.length, bodyStart + 2e3));
|
|
2988
|
+
const signalNames = [];
|
|
2989
|
+
const signalRe = /(?:const|let)\s+(\w+)\s*=\s*signal\s*[<(]/g;
|
|
2990
|
+
let sigMatch;
|
|
2991
|
+
for (sigMatch = signalRe.exec(body); sigMatch; sigMatch = signalRe.exec(body)) if (sigMatch[1]) signalNames.push(sigMatch[1]);
|
|
2992
|
+
components.push({
|
|
2993
|
+
name,
|
|
2994
|
+
file: path.relative(cwd, file),
|
|
2995
|
+
hasSignals: signalNames.length > 0,
|
|
2996
|
+
signalNames,
|
|
2997
|
+
props
|
|
2998
|
+
});
|
|
2316
2999
|
}
|
|
2317
3000
|
}
|
|
2318
|
-
|
|
2319
|
-
const parts = [];
|
|
2320
|
-
let lastPos = 0;
|
|
2321
|
-
for (const r of deduped) {
|
|
2322
|
-
parts.push(code.slice(lastPos, r.start));
|
|
2323
|
-
parts.push(r.text);
|
|
2324
|
-
lastPos = r.end;
|
|
2325
|
-
}
|
|
2326
|
-
parts.push(code.slice(lastPos));
|
|
2327
|
-
return parts.join("");
|
|
2328
|
-
}
|
|
2329
|
-
function insertPyreonImports(code, pyreonImports) {
|
|
2330
|
-
if (pyreonImports.size === 0) return code;
|
|
2331
|
-
const importLines = [];
|
|
2332
|
-
const sorted = [...pyreonImports.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
2333
|
-
for (const [source, specs] of sorted) {
|
|
2334
|
-
const specList = [...specs].sort().join(", ");
|
|
2335
|
-
importLines.push(`import { ${specList} } from "${source}"`);
|
|
2336
|
-
}
|
|
2337
|
-
const importBlock = importLines.join("\n");
|
|
2338
|
-
const lastImportEnd = findLastImportEnd(code);
|
|
2339
|
-
if (lastImportEnd > 0) return `${code.slice(0, lastImportEnd)}\n${importBlock}${code.slice(lastImportEnd)}`;
|
|
2340
|
-
return `${importBlock}\n\n${code}`;
|
|
2341
|
-
}
|
|
2342
|
-
function migrateVisitNode(ctx, node) {
|
|
2343
|
-
if (ts.isImportDeclaration(node)) migrateImportDeclaration(ctx, node);
|
|
2344
|
-
if (isCallToHook(node, "useState")) migrateUseState(ctx, node);
|
|
2345
|
-
if (isCallToEffectHook(node)) migrateUseEffect(ctx, node);
|
|
2346
|
-
if (isCallToHook(node, "useMemo")) migrateUseMemo(ctx, node);
|
|
2347
|
-
if (isCallToHook(node, "useCallback")) migrateUseCallback(ctx, node);
|
|
2348
|
-
if (isCallToHook(node, "useRef")) migrateUseRef(ctx, node);
|
|
2349
|
-
if (ts.isCallExpression(node)) migrateMemoWrapper(ctx, node);
|
|
2350
|
-
if (ts.isCallExpression(node)) migrateForwardRef(ctx, node);
|
|
2351
|
-
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) migrateJsxAttributes(ctx, node);
|
|
2352
|
-
}
|
|
2353
|
-
function migrateVisit(ctx, node) {
|
|
2354
|
-
ts.forEachChild(node, (child) => {
|
|
2355
|
-
migrateVisitNode(ctx, child);
|
|
2356
|
-
migrateVisit(ctx, child);
|
|
2357
|
-
});
|
|
2358
|
-
}
|
|
2359
|
-
function migrateReactCode(code, filename = "input.tsx") {
|
|
2360
|
-
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
|
|
2361
|
-
const diagnostics = detectReactPatterns(code, filename);
|
|
2362
|
-
const ctx = {
|
|
2363
|
-
sf,
|
|
2364
|
-
code,
|
|
2365
|
-
replacements: [],
|
|
2366
|
-
changes: [],
|
|
2367
|
-
pyreonImports: /* @__PURE__ */ new Map(),
|
|
2368
|
-
importsToRemove: /* @__PURE__ */ new Set(),
|
|
2369
|
-
specifierRewrites: /* @__PURE__ */ new Map()
|
|
2370
|
-
};
|
|
2371
|
-
migrateVisit(ctx, sf);
|
|
2372
|
-
let result = applyReplacements(code, ctx);
|
|
2373
|
-
result = insertPyreonImports(result, ctx.pyreonImports);
|
|
2374
|
-
result = result.replace(/\n{3,}/g, "\n\n");
|
|
2375
|
-
return {
|
|
2376
|
-
code: result,
|
|
2377
|
-
diagnostics,
|
|
2378
|
-
changes: ctx.changes
|
|
2379
|
-
};
|
|
3001
|
+
return components;
|
|
2380
3002
|
}
|
|
2381
|
-
function
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
3003
|
+
function extractIslands(files, cwd) {
|
|
3004
|
+
const islands = [];
|
|
3005
|
+
for (const file of files) {
|
|
3006
|
+
let code;
|
|
3007
|
+
try {
|
|
3008
|
+
code = fs.readFileSync(file, "utf-8");
|
|
3009
|
+
} catch {
|
|
3010
|
+
continue;
|
|
3011
|
+
}
|
|
3012
|
+
const islandRe = /island\s*\(\s*\(\)\s*=>\s*import\(.+?\)\s*,\s*\{[^}]*name\s*:\s*["']([^"']+)["'][^}]*?(?:hydrate\s*:\s*["']([^"']+)["'])?[^}]*\}/g;
|
|
3013
|
+
let match;
|
|
3014
|
+
for (match = islandRe.exec(code); match; match = islandRe.exec(code)) if (match[1]) islands.push({
|
|
3015
|
+
name: match[1],
|
|
3016
|
+
file: path.relative(cwd, file),
|
|
3017
|
+
hydrate: match[2] ?? "load"
|
|
3018
|
+
});
|
|
2388
3019
|
}
|
|
2389
|
-
return
|
|
2390
|
-
}
|
|
2391
|
-
function getJsxTagName$1(node) {
|
|
2392
|
-
const tagName = node.tagName;
|
|
2393
|
-
if (ts.isIdentifier(tagName)) return tagName.text;
|
|
2394
|
-
return "";
|
|
3020
|
+
return islands;
|
|
2395
3021
|
}
|
|
2396
|
-
function
|
|
2397
|
-
const
|
|
2398
|
-
|
|
3022
|
+
function extractParams(routePath) {
|
|
3023
|
+
const params = [];
|
|
3024
|
+
const paramRe = /:(\w+)\??/g;
|
|
2399
3025
|
let match;
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
if (!match) break;
|
|
2403
|
-
lastEnd = match.index + match[0].length;
|
|
2404
|
-
}
|
|
2405
|
-
return lastEnd;
|
|
3026
|
+
for (match = paramRe.exec(routePath); match; match = paramRe.exec(routePath)) if (match[1]) params.push(match[1]);
|
|
3027
|
+
return params;
|
|
2406
3028
|
}
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
3029
|
+
function readVersion(cwd) {
|
|
3030
|
+
try {
|
|
3031
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"));
|
|
3032
|
+
const deps = {
|
|
3033
|
+
...pkg.dependencies,
|
|
3034
|
+
...pkg.devDependencies
|
|
3035
|
+
};
|
|
3036
|
+
for (const [name, ver] of Object.entries(deps)) if (name.startsWith("@pyreon/") && typeof ver === "string") return ver.replace(/^[\^~]/, "");
|
|
3037
|
+
return pkg.version || "unknown";
|
|
3038
|
+
} catch {
|
|
3039
|
+
return "unknown";
|
|
3040
|
+
}
|
|
2410
3041
|
}
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
3042
|
+
|
|
3043
|
+
//#endregion
|
|
3044
|
+
//#region src/react-intercept.ts
|
|
3045
|
+
/**
|
|
3046
|
+
* React Pattern Interceptor — detects React/Vue patterns in code and provides
|
|
3047
|
+
* structured diagnostics with exact fix suggestions for AI-assisted migration.
|
|
3048
|
+
*
|
|
3049
|
+
* Two modes:
|
|
3050
|
+
* - `detectReactPatterns(code)` — returns diagnostics only (non-destructive)
|
|
3051
|
+
* - `migrateReactCode(code)` — applies auto-fixes and returns transformed code
|
|
3052
|
+
*
|
|
3053
|
+
* Designed for three consumers:
|
|
3054
|
+
* 1. Compiler pre-pass (warnings during build)
|
|
3055
|
+
* 2. CLI `pyreon doctor` (project-wide scanning)
|
|
3056
|
+
* 3. MCP server `migrate_react` / `validate` tools (AI agent integration)
|
|
3057
|
+
*/
|
|
3058
|
+
/** React import sources → Pyreon equivalents */
|
|
3059
|
+
const IMPORT_REWRITES = {
|
|
3060
|
+
react: "@pyreon/core",
|
|
3061
|
+
"react-dom": "@pyreon/runtime-dom",
|
|
3062
|
+
"react-dom/client": "@pyreon/runtime-dom",
|
|
3063
|
+
"react-dom/server": "@pyreon/runtime-server",
|
|
3064
|
+
"react-router": "@pyreon/router",
|
|
3065
|
+
"react-router-dom": "@pyreon/router"
|
|
3066
|
+
};
|
|
3067
|
+
/** React specifiers that map to specific Pyreon imports */
|
|
3068
|
+
const SPECIFIER_REWRITES = {
|
|
3069
|
+
useState: {
|
|
3070
|
+
name: "signal",
|
|
3071
|
+
from: "@pyreon/reactivity"
|
|
3072
|
+
},
|
|
3073
|
+
useEffect: {
|
|
3074
|
+
name: "effect",
|
|
3075
|
+
from: "@pyreon/reactivity"
|
|
3076
|
+
},
|
|
3077
|
+
useLayoutEffect: {
|
|
3078
|
+
name: "effect",
|
|
3079
|
+
from: "@pyreon/reactivity"
|
|
3080
|
+
},
|
|
3081
|
+
useMemo: {
|
|
3082
|
+
name: "computed",
|
|
3083
|
+
from: "@pyreon/reactivity"
|
|
3084
|
+
},
|
|
3085
|
+
useReducer: {
|
|
3086
|
+
name: "signal",
|
|
3087
|
+
from: "@pyreon/reactivity"
|
|
3088
|
+
},
|
|
3089
|
+
useRef: {
|
|
3090
|
+
name: "signal",
|
|
3091
|
+
from: "@pyreon/reactivity"
|
|
3092
|
+
},
|
|
3093
|
+
createContext: {
|
|
3094
|
+
name: "createContext",
|
|
3095
|
+
from: "@pyreon/core"
|
|
3096
|
+
},
|
|
3097
|
+
useContext: {
|
|
3098
|
+
name: "useContext",
|
|
3099
|
+
from: "@pyreon/core"
|
|
3100
|
+
},
|
|
3101
|
+
Fragment: {
|
|
3102
|
+
name: "Fragment",
|
|
3103
|
+
from: "@pyreon/core"
|
|
2419
3104
|
},
|
|
2420
|
-
{
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
cause: `'${m[1]}' is not callable. If this is a signal, you need to call it: ${m[1]}()`,
|
|
2424
|
-
fix: "Pyreon signals are callable functions. Read: signal(), Write: signal.set(value)",
|
|
2425
|
-
fixCode: `// Read value:\nconst value = ${m[1]}()\n// Set value:\n${m[1]}.set(newValue)`
|
|
2426
|
-
})
|
|
3105
|
+
Suspense: {
|
|
3106
|
+
name: "Suspense",
|
|
3107
|
+
from: "@pyreon/core"
|
|
2427
3108
|
},
|
|
2428
|
-
{
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
cause: `Package ${m[1]} is not installed.`,
|
|
2432
|
-
fix: `Run: bun add ${m[1]}`,
|
|
2433
|
-
fixCode: `bun add ${m[1]}`
|
|
2434
|
-
})
|
|
3109
|
+
lazy: {
|
|
3110
|
+
name: "lazy",
|
|
3111
|
+
from: "@pyreon/core"
|
|
2435
3112
|
},
|
|
2436
|
-
{
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
cause: "Importing from 'react' in a Pyreon project.",
|
|
2440
|
-
fix: "Replace React imports with Pyreon equivalents.",
|
|
2441
|
-
fixCode: "// Instead of:\nimport { useState } from \"react\"\n// Use:\nimport { signal } from \"@pyreon/reactivity\""
|
|
2442
|
-
})
|
|
3113
|
+
memo: {
|
|
3114
|
+
name: "",
|
|
3115
|
+
from: ""
|
|
2443
3116
|
},
|
|
2444
|
-
{
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
cause: `Accessing .${m[1]} on a signal. Pyreon signals don't have a .${m[1]} property.`,
|
|
2448
|
-
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.`,
|
|
2449
|
-
fixCode: m[1] === "value" ? "// Read: mySignal()\n// Write: mySignal.set(newValue)" : void 0
|
|
2450
|
-
})
|
|
3117
|
+
forwardRef: {
|
|
3118
|
+
name: "",
|
|
3119
|
+
from: ""
|
|
2451
3120
|
},
|
|
2452
|
-
{
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
cause: `Component returned ${m[1]} instead of VNode. Components must return JSX, null, or a string.`,
|
|
2456
|
-
fix: "Make sure your component returns a JSX element, null, or a string.",
|
|
2457
|
-
fixCode: "const MyComponent = (props) => {\n return <div>{props.children}</div>\n}"
|
|
2458
|
-
})
|
|
3121
|
+
createRoot: {
|
|
3122
|
+
name: "mount",
|
|
3123
|
+
from: "@pyreon/runtime-dom"
|
|
2459
3124
|
},
|
|
2460
|
-
{
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
cause: "onMount expects a callback that optionally returns a CleanupFn.",
|
|
2464
|
-
fix: "Return a cleanup function, or return nothing.",
|
|
2465
|
-
fixCode: "onMount(() => {\n // setup code\n})"
|
|
2466
|
-
})
|
|
3125
|
+
hydrateRoot: {
|
|
3126
|
+
name: "hydrateRoot",
|
|
3127
|
+
from: "@pyreon/runtime-dom"
|
|
2467
3128
|
},
|
|
2468
|
-
{
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
cause: "<For> requires a 'by' prop for efficient keyed reconciliation.",
|
|
2472
|
-
fix: "Add a by prop that returns a unique key for each item.",
|
|
2473
|
-
fixCode: "<For each={items()} by={item => item.id}>\n {item => <li>{item.name}</li>}\n</For>"
|
|
2474
|
-
})
|
|
3129
|
+
useNavigate: {
|
|
3130
|
+
name: "useRouter",
|
|
3131
|
+
from: "@pyreon/router"
|
|
2475
3132
|
},
|
|
2476
|
-
{
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
cause: "Hook called outside a component function. Pyreon hooks must be called during component setup.",
|
|
2480
|
-
fix: "Move the hook call inside a component function body."
|
|
2481
|
-
})
|
|
3133
|
+
useParams: {
|
|
3134
|
+
name: "useRoute",
|
|
3135
|
+
from: "@pyreon/router"
|
|
2482
3136
|
},
|
|
2483
|
-
{
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
}
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
3137
|
+
useLocation: {
|
|
3138
|
+
name: "useRoute",
|
|
3139
|
+
from: "@pyreon/router"
|
|
3140
|
+
},
|
|
3141
|
+
Link: {
|
|
3142
|
+
name: "RouterLink",
|
|
3143
|
+
from: "@pyreon/router"
|
|
3144
|
+
},
|
|
3145
|
+
NavLink: {
|
|
3146
|
+
name: "RouterLink",
|
|
3147
|
+
from: "@pyreon/router"
|
|
3148
|
+
},
|
|
3149
|
+
Outlet: {
|
|
3150
|
+
name: "RouterView",
|
|
3151
|
+
from: "@pyreon/router"
|
|
3152
|
+
},
|
|
3153
|
+
useSearchParams: {
|
|
3154
|
+
name: "useSearchParams",
|
|
3155
|
+
from: "@pyreon/router"
|
|
2497
3156
|
}
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
3157
|
+
};
|
|
3158
|
+
/** JSX attribute rewrites (React → standard HTML) */
|
|
3159
|
+
const JSX_ATTR_REWRITES = {
|
|
3160
|
+
className: "class",
|
|
3161
|
+
htmlFor: "for"
|
|
3162
|
+
};
|
|
2503
3163
|
/**
|
|
2504
|
-
*
|
|
2505
|
-
*
|
|
2506
|
-
*
|
|
2507
|
-
*
|
|
2508
|
-
*
|
|
2509
|
-
*
|
|
2510
|
-
* Catalog of detected patterns (grounded in `.claude/rules/anti-patterns.md`):
|
|
2511
|
-
*
|
|
2512
|
-
* - `for-missing-by` — `<For each={...}>` without a `by` prop
|
|
2513
|
-
* - `for-with-key` — `<For key={...}>` (JSX reserves `key`; the keying
|
|
2514
|
-
* prop is `by` in Pyreon)
|
|
2515
|
-
* - `props-destructured` — `({ foo }: Props) => <JSX />` destructures at
|
|
2516
|
-
* the component signature; reading is captured once
|
|
2517
|
-
* and loses reactivity. Access `props.foo` instead
|
|
2518
|
-
* or use `splitProps(props, [...])`.
|
|
2519
|
-
* - `process-dev-gate` — `typeof process !== 'undefined' &&
|
|
2520
|
-
* process.env.NODE_ENV !== 'production'` is dead
|
|
2521
|
-
* code in real Vite browser bundles. Use
|
|
2522
|
-
* `import.meta.env?.DEV` instead.
|
|
2523
|
-
* - `empty-theme` — `.theme({})` chain is a no-op; remove it.
|
|
2524
|
-
* - `raw-add-event-listener` — raw `addEventListener(...)` in a component
|
|
2525
|
-
* or hook body. Use `useEventListener(...)` from
|
|
2526
|
-
* `@pyreon/hooks` for auto-cleanup.
|
|
2527
|
-
* - `raw-remove-event-listener` — same, for removeEventListener.
|
|
2528
|
-
* - `date-math-random-id` — `Date.now() + Math.random()` / template-concat
|
|
2529
|
-
* variants. Under rapid operations (paste, clone)
|
|
2530
|
-
* collision probability is non-trivial. Use a
|
|
2531
|
-
* monotonic counter.
|
|
2532
|
-
* - `on-click-undefined` — `onClick={undefined}` explicitly; the runtime
|
|
2533
|
-
* used to crash on this pattern. Omit the prop.
|
|
2534
|
-
* - `signal-write-as-call` — `sig(value)` is a no-op read that ignores
|
|
2535
|
-
* its argument; the runtime warns in dev. Static
|
|
2536
|
-
* detector spots it pre-runtime when `sig` was
|
|
2537
|
-
* declared as `const sig = signal(...)` /
|
|
2538
|
-
* `computed(...)` and called with ≥1 argument.
|
|
2539
|
-
* - `static-return-null-conditional` — `if (cond) return null` at the
|
|
2540
|
-
* top of a component body runs ONCE; signal changes
|
|
2541
|
-
* in `cond` never re-evaluate the early-return.
|
|
2542
|
-
* Wrap in a returned reactive accessor.
|
|
2543
|
-
* - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
|
|
2544
|
-
* cast on JSX returns is unnecessary (`JSX.Element`
|
|
2545
|
-
* is already assignable to `VNodeChild`).
|
|
2546
|
-
* - `island-never-with-registry-entry` — an `island()` declared with
|
|
2547
|
-
* `hydrate: 'never'` is also registered in the same
|
|
2548
|
-
* file's `hydrateIslands({ ... })` call. The whole
|
|
2549
|
-
* point of `'never'` is shipping zero client JS;
|
|
2550
|
-
* registering pulls the component module into the
|
|
2551
|
-
* client bundle graph (the runtime short-circuits
|
|
2552
|
-
* and never calls the loader, but the bundler still
|
|
2553
|
-
* includes the import). Drop the registry entry.
|
|
2554
|
-
*
|
|
2555
|
-
* Two-mode surface mirrors `react-intercept.ts`:
|
|
2556
|
-
* - `detectPyreonPatterns(code)` — diagnostics only
|
|
2557
|
-
* - `hasPyreonPatterns(code)` — fast regex pre-filter
|
|
2558
|
-
*
|
|
2559
|
-
* ## fixable: false (invariant)
|
|
2560
|
-
*
|
|
2561
|
-
* Every Pyreon diagnostic reports `fixable: false` — no exceptions.
|
|
2562
|
-
* The `migrate_react` MCP tool only knows React mappings, so claiming
|
|
2563
|
-
* a Pyreon code is auto-fixable would mislead a consumer who wires
|
|
2564
|
-
* their UX off the flag and finds nothing applies the fix. Flip to
|
|
2565
|
-
* `true` ONLY when a companion `migrate_pyreon` tool ships in a
|
|
2566
|
-
* subsequent PR. The invariant is locked in
|
|
2567
|
-
* `tests/pyreon-intercept.test.ts` under "fixable contract".
|
|
2568
|
-
*
|
|
2569
|
-
* Designed for three consumers:
|
|
2570
|
-
* 1. Compiler pre-pass warnings during build
|
|
2571
|
-
* 2. CLI `pyreon doctor`
|
|
2572
|
-
* 3. MCP server `validate` tool
|
|
3164
|
+
* Collects every identifier bound to a signal factory call. Mirrors
|
|
3165
|
+
* `pyreon-intercept.ts:collectSignalBindings` but also recognises the
|
|
3166
|
+
* `useSignal` / `createSignal` aliases (Solid / hook-style) so the React
|
|
3167
|
+
* detector — which runs on cross-framework migration input — doesn't miss a
|
|
3168
|
+
* genuine `mySignal.value = x` written by someone coming from Solid/Vue.
|
|
2573
3169
|
*/
|
|
2574
|
-
function
|
|
3170
|
+
function collectDetectSignalBindings(sf) {
|
|
3171
|
+
const names = /* @__PURE__ */ new Set();
|
|
3172
|
+
function isSignalFactoryCall(init) {
|
|
3173
|
+
if (!init || !ts.isCallExpression(init)) return false;
|
|
3174
|
+
const callee = init.expression;
|
|
3175
|
+
if (!ts.isIdentifier(callee)) return false;
|
|
3176
|
+
return callee.text === "signal" || callee.text === "computed" || callee.text === "useSignal" || callee.text === "createSignal";
|
|
3177
|
+
}
|
|
3178
|
+
function walk(node) {
|
|
3179
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
3180
|
+
const list = node.parent;
|
|
3181
|
+
if (ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && isSignalFactoryCall(node.initializer)) names.add(node.name.text);
|
|
3182
|
+
}
|
|
3183
|
+
ts.forEachChild(node, walk);
|
|
3184
|
+
}
|
|
3185
|
+
walk(sf);
|
|
3186
|
+
return names;
|
|
3187
|
+
}
|
|
3188
|
+
function detectGetNodeText(ctx, node) {
|
|
2575
3189
|
return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
|
|
2576
3190
|
}
|
|
2577
|
-
function
|
|
2578
|
-
const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf));
|
|
2579
|
-
ctx.diagnostics.push({
|
|
2580
|
-
code,
|
|
2581
|
-
message,
|
|
2582
|
-
line: line + 1,
|
|
2583
|
-
column: character,
|
|
2584
|
-
current: current.trim(),
|
|
2585
|
-
suggested: suggested.trim(),
|
|
2586
|
-
fixable
|
|
2587
|
-
});
|
|
3191
|
+
function detectDiag(ctx, node, diagCode, message, current, suggested, fixable) {
|
|
3192
|
+
const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf));
|
|
3193
|
+
ctx.diagnostics.push({
|
|
3194
|
+
code: diagCode,
|
|
3195
|
+
message,
|
|
3196
|
+
line: line + 1,
|
|
3197
|
+
column: character,
|
|
3198
|
+
current: current.trim(),
|
|
3199
|
+
suggested: suggested.trim(),
|
|
3200
|
+
fixable
|
|
3201
|
+
});
|
|
3202
|
+
}
|
|
3203
|
+
function detectImportDeclaration(ctx, node) {
|
|
3204
|
+
if (!node.moduleSpecifier) return;
|
|
3205
|
+
const source = node.moduleSpecifier.text;
|
|
3206
|
+
const pyreonSource = IMPORT_REWRITES[source];
|
|
3207
|
+
if (pyreonSource !== void 0) {
|
|
3208
|
+
if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) for (const spec of node.importClause.namedBindings.elements) ctx.reactImportedHooks.add(spec.name.text);
|
|
3209
|
+
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);
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
function detectUseState(ctx, node) {
|
|
3213
|
+
const parent = node.parent;
|
|
3214
|
+
if (ts.isVariableDeclaration(parent) && parent.name && ts.isArrayBindingPattern(parent.name) && parent.name.elements.length >= 1) {
|
|
3215
|
+
const firstEl = parent.name.elements[0];
|
|
3216
|
+
const valueName = firstEl && ts.isBindingElement(firstEl) ? firstEl.name.text : "value";
|
|
3217
|
+
const initArg = node.arguments[0] ? detectGetNodeText(ctx, node.arguments[0]) : "undefined";
|
|
3218
|
+
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);
|
|
3219
|
+
} else detectDiag(ctx, node, "use-state", "useState is a React API. In Pyreon, use signal().", detectGetNodeText(ctx, node), "signal(initialValue)", true);
|
|
3220
|
+
}
|
|
3221
|
+
function callbackHasCleanup(callbackArg) {
|
|
3222
|
+
if (!ts.isArrowFunction(callbackArg) && !ts.isFunctionExpression(callbackArg)) return false;
|
|
3223
|
+
const body = callbackArg.body;
|
|
3224
|
+
if (!ts.isBlock(body)) return false;
|
|
3225
|
+
for (const stmt of body.statements) if (ts.isReturnStatement(stmt) && stmt.expression) return true;
|
|
3226
|
+
return false;
|
|
3227
|
+
}
|
|
3228
|
+
function detectUseEffect(ctx, node) {
|
|
3229
|
+
const hookName = node.expression.text;
|
|
3230
|
+
const depsArg = node.arguments[1];
|
|
3231
|
+
const callbackArg = node.arguments[0];
|
|
3232
|
+
if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0) {
|
|
3233
|
+
const hasCleanup = callbackArg ? callbackHasCleanup(callbackArg) : false;
|
|
3234
|
+
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})", true);
|
|
3235
|
+
} 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);
|
|
3236
|
+
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);
|
|
3237
|
+
}
|
|
3238
|
+
function detectUseMemo(ctx, node) {
|
|
3239
|
+
const computeFn = node.arguments[0];
|
|
3240
|
+
const computeText = computeFn ? detectGetNodeText(ctx, computeFn) : "() => value";
|
|
3241
|
+
detectDiag(ctx, node, "use-memo", "useMemo is a React API. In Pyreon, use computed() — dependencies auto-tracked.", detectGetNodeText(ctx, node), `computed(${computeText})`, true);
|
|
2588
3242
|
}
|
|
2589
|
-
function
|
|
2590
|
-
const
|
|
2591
|
-
|
|
2592
|
-
|
|
3243
|
+
function detectUseCallback(ctx, node) {
|
|
3244
|
+
const callbackFn = node.arguments[0];
|
|
3245
|
+
const callbackText = callbackFn ? detectGetNodeText(ctx, callbackFn) : "() => {}";
|
|
3246
|
+
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);
|
|
2593
3247
|
}
|
|
2594
|
-
function
|
|
2595
|
-
|
|
3248
|
+
function detectUseRef(ctx, node) {
|
|
3249
|
+
const arg = node.arguments[0];
|
|
3250
|
+
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);
|
|
3251
|
+
else {
|
|
3252
|
+
const initText = arg ? detectGetNodeText(ctx, arg) : "undefined";
|
|
3253
|
+
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);
|
|
3254
|
+
}
|
|
2596
3255
|
}
|
|
2597
|
-
function
|
|
2598
|
-
|
|
2599
|
-
const keyAttr = findJsxAttribute(node, "key");
|
|
2600
|
-
if (keyAttr) pushDiag(ctx, keyAttr, "for-with-key", "`key` on <For> is reserved by JSX for VNode reconciliation and is extracted before the prop reaches the runtime. In Pyreon, use `by` for list identity.", getNodeText(ctx, keyAttr), getNodeText(ctx, keyAttr).replace(/^key\b/, "by"), false);
|
|
2601
|
-
const eachAttr = findJsxAttribute(node, "each");
|
|
2602
|
-
const byAttr = findJsxAttribute(node, "by");
|
|
2603
|
-
if (eachAttr && !byAttr && !keyAttr) pushDiag(ctx, node, "for-missing-by", "<For each={...}> requires a `by` prop so the keyed reconciler can preserve item identity across reorders. Without `by`, every update remounts the full list.", getNodeText(ctx, node), "<For each={items} by={(item) => item.id}>", false);
|
|
3256
|
+
function detectUseReducer(ctx, node) {
|
|
3257
|
+
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);
|
|
2604
3258
|
}
|
|
2605
|
-
function
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
3259
|
+
function isCallToReactDot(callee, methodName) {
|
|
3260
|
+
return ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === "React" && callee.name.text === methodName;
|
|
3261
|
+
}
|
|
3262
|
+
function detectMemoWrapper(ctx, node) {
|
|
3263
|
+
const callee = node.expression;
|
|
3264
|
+
if (ts.isIdentifier(callee) && callee.text === "memo" || isCallToReactDot(callee, "memo")) {
|
|
3265
|
+
const inner = node.arguments[0];
|
|
3266
|
+
const innerText = inner ? detectGetNodeText(ctx, inner) : "Component";
|
|
3267
|
+
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);
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
function detectForwardRef(ctx, node) {
|
|
3271
|
+
const callee = node.expression;
|
|
3272
|
+
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);
|
|
3273
|
+
}
|
|
3274
|
+
function detectJsxAttributes(ctx, node) {
|
|
3275
|
+
const attrName = node.name.text;
|
|
3276
|
+
if (attrName in JSX_ATTR_REWRITES) {
|
|
3277
|
+
const htmlAttr = JSX_ATTR_REWRITES[attrName];
|
|
3278
|
+
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);
|
|
3279
|
+
}
|
|
3280
|
+
if (attrName === "onChange") {
|
|
3281
|
+
const jsxElement = findParentJsxElement(node);
|
|
3282
|
+
if (jsxElement) {
|
|
3283
|
+
const tagName = getJsxTagName(jsxElement);
|
|
3284
|
+
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);
|
|
2612
3285
|
}
|
|
2613
|
-
ts.forEachChild(n, walk);
|
|
2614
3286
|
}
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
3287
|
+
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);
|
|
3288
|
+
}
|
|
3289
|
+
function detectDotValueSignal(ctx, node) {
|
|
3290
|
+
const varName = node.expression.text;
|
|
3291
|
+
if (!ctx.signalBindings.has(varName)) return;
|
|
3292
|
+
const parent = node.parent;
|
|
3293
|
+
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);
|
|
3294
|
+
}
|
|
3295
|
+
function detectArrayMapJsx(ctx, node) {
|
|
3296
|
+
const parent = node.parent;
|
|
3297
|
+
if (ts.isJsxExpression(parent)) {
|
|
3298
|
+
const arrayExpr = detectGetNodeText(ctx, node.expression.expression);
|
|
3299
|
+
const mapCallback = node.arguments[0];
|
|
3300
|
+
const mapCallbackText = mapCallback ? detectGetNodeText(ctx, mapCallback) : "item => <li>{item}</li>";
|
|
3301
|
+
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);
|
|
2618
3302
|
}
|
|
2619
|
-
return found;
|
|
2620
3303
|
}
|
|
2621
|
-
function
|
|
2622
|
-
|
|
2623
|
-
const first = node.parameters[0];
|
|
2624
|
-
if (!first || !ts.isObjectBindingPattern(first.name)) return;
|
|
2625
|
-
if (first.name.elements.length === 0) return;
|
|
2626
|
-
if (!containsJsx(node)) return;
|
|
2627
|
-
pushDiag(ctx, first, "props-destructured", "Destructuring props at the component signature captures the values ONCE during setup — subsequent signal writes in the parent do not update the destructured locals. Access `props.x` directly, or use `splitProps(props, [...])` to carve out a group while preserving reactivity.", getNodeText(ctx, first), "(props: Props) => /* read props.x directly */", false);
|
|
3304
|
+
function isCallToHook(node, hookName) {
|
|
3305
|
+
return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === hookName;
|
|
2628
3306
|
}
|
|
2629
|
-
function
|
|
2630
|
-
|
|
2631
|
-
if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false;
|
|
2632
|
-
if (!ts.isTypeOfExpression(node.left)) return false;
|
|
2633
|
-
if (!ts.isIdentifier(node.left.expression) || node.left.expression.text !== "process") return false;
|
|
2634
|
-
return ts.isStringLiteral(node.right) && node.right.text === "undefined";
|
|
3307
|
+
function isCallToEffectHook(node) {
|
|
3308
|
+
return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (node.expression.text === "useEffect" || node.expression.text === "useLayoutEffect");
|
|
2635
3309
|
}
|
|
2636
|
-
function
|
|
2637
|
-
|
|
2638
|
-
if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false;
|
|
2639
|
-
const left = node.left;
|
|
2640
|
-
if (!ts.isPropertyAccessExpression(left)) return false;
|
|
2641
|
-
if (!ts.isIdentifier(left.name) || left.name.text !== "NODE_ENV") return false;
|
|
2642
|
-
if (!ts.isPropertyAccessExpression(left.expression)) return false;
|
|
2643
|
-
if (!ts.isIdentifier(left.expression.name) || left.expression.name.text !== "env") return false;
|
|
2644
|
-
if (!ts.isIdentifier(left.expression.expression)) return false;
|
|
2645
|
-
if (left.expression.expression.text !== "process") return false;
|
|
2646
|
-
return ts.isStringLiteral(node.right) && node.right.text === "production";
|
|
3310
|
+
function isMapCallExpression(node) {
|
|
3311
|
+
return ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name) && node.expression.name.text === "map";
|
|
2647
3312
|
}
|
|
2648
|
-
function
|
|
2649
|
-
|
|
2650
|
-
if (!(isTypeofProcess(node.left) && isProcessNodeEnvProdGuard(node.right) || isTypeofProcess(node.right) && isProcessNodeEnvProdGuard(node.left))) return;
|
|
2651
|
-
pushDiag(ctx, node, "process-dev-gate", "The `typeof process !== \"undefined\" && process.env.NODE_ENV !== \"production\"` gate is DEAD CODE in real Vite browser bundles — Vite does not polyfill `process`. Unit tests pass (vitest has `process`) but the warning never fires in production. Use `import.meta.env?.DEV` instead, which Vite literal-replaces at build time.", getNodeText(ctx, node), "import.meta.env?.DEV === true", false);
|
|
3313
|
+
function isDotValueAccess(node) {
|
|
3314
|
+
return ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name) && node.name.text === "value" && ts.isIdentifier(node.expression);
|
|
2652
3315
|
}
|
|
2653
|
-
function
|
|
2654
|
-
|
|
2655
|
-
if (
|
|
2656
|
-
if (
|
|
2657
|
-
if (node
|
|
2658
|
-
|
|
2659
|
-
if (
|
|
2660
|
-
if (
|
|
2661
|
-
|
|
3316
|
+
function detectVisitNode(ctx, node) {
|
|
3317
|
+
if (ts.isImportDeclaration(node)) detectImportDeclaration(ctx, node);
|
|
3318
|
+
if (isCallToHook(node, "useState")) detectUseState(ctx, node);
|
|
3319
|
+
if (isCallToEffectHook(node)) detectUseEffect(ctx, node);
|
|
3320
|
+
if (isCallToHook(node, "useMemo")) detectUseMemo(ctx, node);
|
|
3321
|
+
if (isCallToHook(node, "useCallback")) detectUseCallback(ctx, node);
|
|
3322
|
+
if (isCallToHook(node, "useRef")) detectUseRef(ctx, node);
|
|
3323
|
+
if (isCallToHook(node, "useReducer")) detectUseReducer(ctx, node);
|
|
3324
|
+
if (ts.isCallExpression(node)) detectMemoWrapper(ctx, node);
|
|
3325
|
+
if (ts.isCallExpression(node)) detectForwardRef(ctx, node);
|
|
3326
|
+
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) detectJsxAttributes(ctx, node);
|
|
3327
|
+
if (isDotValueAccess(node)) detectDotValueSignal(ctx, node);
|
|
3328
|
+
if (isMapCallExpression(node)) detectArrayMapJsx(ctx, node);
|
|
2662
3329
|
}
|
|
2663
|
-
function
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
if (method !== "addEventListener" && method !== "removeEventListener") return;
|
|
2669
|
-
const target = callee.expression;
|
|
2670
|
-
const targetName = ts.isIdentifier(target) ? target.text : ts.isPropertyAccessExpression(target) && ts.isIdentifier(target.name) ? target.name.text : "";
|
|
2671
|
-
if (!new Set([
|
|
2672
|
-
"window",
|
|
2673
|
-
"document",
|
|
2674
|
-
"body",
|
|
2675
|
-
"el",
|
|
2676
|
-
"element",
|
|
2677
|
-
"node",
|
|
2678
|
-
"target"
|
|
2679
|
-
]).has(targetName)) return;
|
|
2680
|
-
if (method === "addEventListener") pushDiag(ctx, node, "raw-add-event-listener", "Raw `addEventListener` in a component / hook body bypasses Pyreon's lifecycle cleanup — listeners leak on unmount. Use `useEventListener` from `@pyreon/hooks` for auto-cleanup.", getNodeText(ctx, node), "useEventListener(target, event, handler)", false);
|
|
2681
|
-
else pushDiag(ctx, node, "raw-remove-event-listener", "Raw `removeEventListener` is the symptom of manual listener management. Replace the paired `addEventListener` with `useEventListener` from `@pyreon/hooks` — it registers the cleanup automatically.", getNodeText(ctx, node), "useEventListener(target, event, handler) // cleanup is automatic", false);
|
|
3330
|
+
function detectVisit(ctx, node) {
|
|
3331
|
+
ts.forEachChild(node, (child) => {
|
|
3332
|
+
detectVisitNode(ctx, child);
|
|
3333
|
+
detectVisit(ctx, child);
|
|
3334
|
+
});
|
|
2682
3335
|
}
|
|
2683
|
-
function
|
|
2684
|
-
|
|
3336
|
+
function detectReactPatterns(code, filename = "input.tsx") {
|
|
3337
|
+
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
|
|
3338
|
+
const ctx = {
|
|
3339
|
+
sf,
|
|
3340
|
+
code,
|
|
3341
|
+
diagnostics: [],
|
|
3342
|
+
reactImportedHooks: /* @__PURE__ */ new Set(),
|
|
3343
|
+
signalBindings: collectDetectSignalBindings(sf)
|
|
3344
|
+
};
|
|
3345
|
+
detectVisit(ctx, sf);
|
|
3346
|
+
return ctx.diagnostics;
|
|
2685
3347
|
}
|
|
2686
|
-
function
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
3348
|
+
function migrateAddImport(ctx, source, specifier) {
|
|
3349
|
+
if (!source || !specifier) return;
|
|
3350
|
+
let specs = ctx.pyreonImports.get(source);
|
|
3351
|
+
if (!specs) {
|
|
3352
|
+
specs = /* @__PURE__ */ new Set();
|
|
3353
|
+
ctx.pyreonImports.set(source, specs);
|
|
3354
|
+
}
|
|
3355
|
+
specs.add(specifier);
|
|
3356
|
+
}
|
|
3357
|
+
function migrateReplace(ctx, node, text) {
|
|
3358
|
+
ctx.replacements.push({
|
|
3359
|
+
start: node.getStart(ctx.sf),
|
|
3360
|
+
end: node.getEnd(),
|
|
3361
|
+
text
|
|
3362
|
+
});
|
|
3363
|
+
}
|
|
3364
|
+
function migrateGetNodeText(ctx, node) {
|
|
3365
|
+
return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
|
|
3366
|
+
}
|
|
3367
|
+
function migrateGetLine(ctx, node) {
|
|
3368
|
+
return ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf)).line + 1;
|
|
3369
|
+
}
|
|
3370
|
+
function migrateImportDeclaration(ctx, node) {
|
|
3371
|
+
if (!node.moduleSpecifier) return;
|
|
3372
|
+
if (!(node.moduleSpecifier.text in IMPORT_REWRITES)) return;
|
|
3373
|
+
if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) for (const spec of node.importClause.namedBindings.elements) {
|
|
3374
|
+
const rewrite = SPECIFIER_REWRITES[spec.name.text];
|
|
3375
|
+
if (rewrite) {
|
|
3376
|
+
if (rewrite.name) migrateAddImport(ctx, rewrite.from, rewrite.name);
|
|
3377
|
+
ctx.specifierRewrites.set(spec, rewrite);
|
|
2693
3378
|
}
|
|
2694
|
-
ts.forEachChild(n, walk);
|
|
2695
3379
|
}
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
if (
|
|
2701
|
-
|
|
2702
|
-
|
|
3380
|
+
ctx.importsToRemove.add(node);
|
|
3381
|
+
}
|
|
3382
|
+
function migrateUseState(ctx, node) {
|
|
3383
|
+
const parent = node.parent;
|
|
3384
|
+
if (ts.isVariableDeclaration(parent) && parent.name && ts.isArrayBindingPattern(parent.name) && parent.name.elements.length >= 1) {
|
|
3385
|
+
const firstEl = parent.name.elements[0];
|
|
3386
|
+
const valueName = firstEl && ts.isBindingElement(firstEl) ? firstEl.name.text : "value";
|
|
3387
|
+
const initArg = node.arguments[0] ? migrateGetNodeText(ctx, node.arguments[0]) : "undefined";
|
|
3388
|
+
const declStart = parent.getStart(ctx.sf);
|
|
3389
|
+
const declEnd = parent.getEnd();
|
|
3390
|
+
ctx.replacements.push({
|
|
3391
|
+
start: declStart,
|
|
3392
|
+
end: declEnd,
|
|
3393
|
+
text: `${valueName} = signal(${initArg})`
|
|
3394
|
+
});
|
|
3395
|
+
migrateAddImport(ctx, "@pyreon/reactivity", "signal");
|
|
3396
|
+
ctx.changes.push({
|
|
3397
|
+
type: "replace",
|
|
3398
|
+
line: migrateGetLine(ctx, node),
|
|
3399
|
+
description: `useState → signal: ${valueName}`
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
function migrateUseEffect(ctx, node) {
|
|
3404
|
+
const depsArg = node.arguments[1];
|
|
3405
|
+
const callbackArg = node.arguments[0];
|
|
3406
|
+
const hookName = node.expression.text;
|
|
3407
|
+
if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0 && callbackArg) {
|
|
3408
|
+
migrateReplace(ctx, node, `onMount(${migrateGetNodeText(ctx, callbackArg)})`);
|
|
3409
|
+
migrateAddImport(ctx, "@pyreon/core", "onMount");
|
|
3410
|
+
ctx.changes.push({
|
|
3411
|
+
type: "replace",
|
|
3412
|
+
line: migrateGetLine(ctx, node),
|
|
3413
|
+
description: `${hookName}(fn, []) → onMount(fn)`
|
|
3414
|
+
});
|
|
3415
|
+
} else if (callbackArg) {
|
|
3416
|
+
migrateReplace(ctx, node, `effect(${migrateGetNodeText(ctx, callbackArg)})`);
|
|
3417
|
+
migrateAddImport(ctx, "@pyreon/reactivity", "effect");
|
|
3418
|
+
ctx.changes.push({
|
|
3419
|
+
type: "replace",
|
|
3420
|
+
line: migrateGetLine(ctx, node),
|
|
3421
|
+
description: `${hookName} → effect (auto-tracks deps)`
|
|
3422
|
+
});
|
|
3423
|
+
}
|
|
2703
3424
|
}
|
|
2704
|
-
function
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
3425
|
+
function migrateUseMemo(ctx, node) {
|
|
3426
|
+
const computeFn = node.arguments[0];
|
|
3427
|
+
if (computeFn) {
|
|
3428
|
+
migrateReplace(ctx, node, `computed(${migrateGetNodeText(ctx, computeFn)})`);
|
|
3429
|
+
migrateAddImport(ctx, "@pyreon/reactivity", "computed");
|
|
3430
|
+
ctx.changes.push({
|
|
3431
|
+
type: "replace",
|
|
3432
|
+
line: migrateGetLine(ctx, node),
|
|
3433
|
+
description: "useMemo → computed (auto-tracks deps)"
|
|
3434
|
+
});
|
|
3435
|
+
}
|
|
2713
3436
|
}
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
* (1) shadowing a signal name with a non-signal is itself unusual and
|
|
2724
|
-
* (2) the detector message points at exactly the wrong-shape call so a
|
|
2725
|
-
* human reviewer can dismiss the rare false positive in seconds.
|
|
2726
|
-
*/
|
|
2727
|
-
function collectSignalBindings(sf) {
|
|
2728
|
-
const names = /* @__PURE__ */ new Set();
|
|
2729
|
-
function isSignalFactoryCall(init) {
|
|
2730
|
-
if (!init || !ts.isCallExpression(init)) return false;
|
|
2731
|
-
const callee = init.expression;
|
|
2732
|
-
if (!ts.isIdentifier(callee)) return false;
|
|
2733
|
-
return callee.text === "signal" || callee.text === "computed";
|
|
3437
|
+
function migrateUseCallback(ctx, node) {
|
|
3438
|
+
const callbackFn = node.arguments[0];
|
|
3439
|
+
if (callbackFn) {
|
|
3440
|
+
migrateReplace(ctx, node, migrateGetNodeText(ctx, callbackFn));
|
|
3441
|
+
ctx.changes.push({
|
|
3442
|
+
type: "replace",
|
|
3443
|
+
line: migrateGetLine(ctx, node),
|
|
3444
|
+
description: "useCallback → plain function (not needed in Pyreon)"
|
|
3445
|
+
});
|
|
2734
3446
|
}
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
3447
|
+
}
|
|
3448
|
+
function migrateUseRef(ctx, node) {
|
|
3449
|
+
const arg = node.arguments[0];
|
|
3450
|
+
if (arg && (arg.kind === ts.SyntaxKind.NullKeyword || ts.isIdentifier(arg) && arg.text === "undefined") || !arg) {
|
|
3451
|
+
migrateReplace(ctx, node, "createRef()");
|
|
3452
|
+
migrateAddImport(ctx, "@pyreon/core", "createRef");
|
|
3453
|
+
ctx.changes.push({
|
|
3454
|
+
type: "replace",
|
|
3455
|
+
line: migrateGetLine(ctx, node),
|
|
3456
|
+
description: "useRef(null) → createRef()"
|
|
3457
|
+
});
|
|
3458
|
+
} else {
|
|
3459
|
+
migrateReplace(ctx, node, `signal(${migrateGetNodeText(ctx, arg)})`);
|
|
3460
|
+
migrateAddImport(ctx, "@pyreon/reactivity", "signal");
|
|
3461
|
+
ctx.changes.push({
|
|
3462
|
+
type: "replace",
|
|
3463
|
+
line: migrateGetLine(ctx, node),
|
|
3464
|
+
description: "useRef(value) → signal(value)"
|
|
3465
|
+
});
|
|
2741
3466
|
}
|
|
2742
|
-
walk(sf);
|
|
2743
|
-
return names;
|
|
2744
3467
|
}
|
|
2745
|
-
function
|
|
2746
|
-
if (ctx.signalBindings.size === 0) return;
|
|
3468
|
+
function migrateMemoWrapper(ctx, node) {
|
|
2747
3469
|
const callee = node.expression;
|
|
2748
|
-
if (
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
* components mount and never re-execute their function bodies. A signal
|
|
2756
|
-
* change inside `cond` therefore never re-evaluates the condition; the
|
|
2757
|
-
* component is permanently stuck on whichever branch the first run picked.
|
|
2758
|
-
*
|
|
2759
|
-
* The fix is to wrap the conditional in a returned reactive accessor:
|
|
2760
|
-
* return (() => { if (!cond()) return null; return <div /> })
|
|
2761
|
-
*
|
|
2762
|
-
* Detection:
|
|
2763
|
-
* - The function contains JSX (i.e. it's a component)
|
|
2764
|
-
* - The function body has an `IfStatement` whose `thenStatement` is
|
|
2765
|
-
* `return null` (either bare `return null` or `{ return null }`)
|
|
2766
|
-
* - The `if` is at the function body's top level, NOT inside a returned
|
|
2767
|
-
* arrow / IIFE (those are reactive scopes — flagging them would be a
|
|
2768
|
-
* false positive)
|
|
2769
|
-
*/
|
|
2770
|
-
function returnsNullStatement(stmt) {
|
|
2771
|
-
if (ts.isReturnStatement(stmt)) {
|
|
2772
|
-
const expr = stmt.expression;
|
|
2773
|
-
return !!expr && expr.kind === ts.SyntaxKind.NullKeyword;
|
|
3470
|
+
if ((ts.isIdentifier(callee) && callee.text === "memo" || isCallToReactDot(callee, "memo")) && node.arguments[0]) {
|
|
3471
|
+
migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]));
|
|
3472
|
+
ctx.changes.push({
|
|
3473
|
+
type: "remove",
|
|
3474
|
+
line: migrateGetLine(ctx, node),
|
|
3475
|
+
description: "Removed memo() wrapper (not needed in Pyreon)"
|
|
3476
|
+
});
|
|
2774
3477
|
}
|
|
2775
|
-
if (ts.isBlock(stmt)) return stmt.statements.length === 1 && returnsNullStatement(stmt.statements[0]);
|
|
2776
|
-
return false;
|
|
2777
|
-
}
|
|
2778
|
-
/**
|
|
2779
|
-
* Returns true if the function looks like a top-level component:
|
|
2780
|
-
* - `function PascalName(...) { ... }` (FunctionDeclaration with PascalCase id), OR
|
|
2781
|
-
* - `const PascalName = (...) => { ... }` (arrow inside a VariableDeclaration whose name is PascalCase).
|
|
2782
|
-
*
|
|
2783
|
-
* Anonymous nested arrows — most importantly the reactive accessor
|
|
2784
|
-
* `return (() => { if (!cond()) return null; return <div /> })` — are
|
|
2785
|
-
* NOT considered components here, even when they contain JSX. Without
|
|
2786
|
-
* this filter the detector would fire on the very pattern the
|
|
2787
|
-
* diagnostic recommends as the fix.
|
|
2788
|
-
*/
|
|
2789
|
-
function isComponentShapedFunction(node) {
|
|
2790
|
-
if (ts.isFunctionDeclaration(node)) return !!node.name && /^[A-Z]/.test(node.name.text);
|
|
2791
|
-
const parent = node.parent;
|
|
2792
|
-
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return /^[A-Z]/.test(parent.name.text);
|
|
2793
|
-
return false;
|
|
2794
3478
|
}
|
|
2795
|
-
function
|
|
2796
|
-
|
|
2797
|
-
if (
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
return;
|
|
3479
|
+
function migrateForwardRef(ctx, node) {
|
|
3480
|
+
const callee = node.expression;
|
|
3481
|
+
if ((ts.isIdentifier(callee) && callee.text === "forwardRef" || isCallToReactDot(callee, "forwardRef")) && node.arguments[0]) {
|
|
3482
|
+
migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]));
|
|
3483
|
+
ctx.changes.push({
|
|
3484
|
+
type: "remove",
|
|
3485
|
+
line: migrateGetLine(ctx, node),
|
|
3486
|
+
description: "Removed forwardRef wrapper (pass ref as normal prop in Pyreon)"
|
|
3487
|
+
});
|
|
2805
3488
|
}
|
|
2806
3489
|
}
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
* - Name is a variable / template / spread, not a string literal
|
|
2838
|
-
* - Options come from a spread (`island(loader, opts)`)
|
|
2839
|
-
*
|
|
2840
|
-
* The same rules apply on the registry side (`detectIslandNeverWithRegistry`):
|
|
2841
|
-
* unrecognized keys won't match. Both halves are syntactic — a semantic
|
|
2842
|
-
* cross-package audit lives in `pyreon doctor --check-islands` (separate PR).
|
|
2843
|
-
*/
|
|
2844
|
-
function collectNeverIslandNames(sf) {
|
|
2845
|
-
const names = /* @__PURE__ */ new Set();
|
|
2846
|
-
function walk(node) {
|
|
2847
|
-
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "island" && node.arguments.length >= 2) {
|
|
2848
|
-
const opts = node.arguments[1];
|
|
2849
|
-
if (opts && ts.isObjectLiteralExpression(opts)) {
|
|
2850
|
-
let nameVal;
|
|
2851
|
-
let hydrateVal;
|
|
2852
|
-
for (const prop of opts.properties) {
|
|
2853
|
-
if (!ts.isPropertyAssignment(prop)) continue;
|
|
2854
|
-
const key = prop.name;
|
|
2855
|
-
const keyText = ts.isIdentifier(key) ? key.text : ts.isStringLiteral(key) ? key.text : "";
|
|
2856
|
-
if (keyText === "name" && ts.isStringLiteral(prop.initializer)) nameVal = prop.initializer.text;
|
|
2857
|
-
else if (keyText === "hydrate" && ts.isStringLiteral(prop.initializer)) hydrateVal = prop.initializer.text;
|
|
2858
|
-
}
|
|
2859
|
-
if (nameVal && hydrateVal === "never") names.add(nameVal);
|
|
3490
|
+
function migrateJsxAttributes(ctx, node) {
|
|
3491
|
+
const attrName = node.name.text;
|
|
3492
|
+
if (attrName in JSX_ATTR_REWRITES) {
|
|
3493
|
+
const htmlAttr = JSX_ATTR_REWRITES[attrName];
|
|
3494
|
+
ctx.replacements.push({
|
|
3495
|
+
start: node.name.getStart(ctx.sf),
|
|
3496
|
+
end: node.name.getEnd(),
|
|
3497
|
+
text: htmlAttr
|
|
3498
|
+
});
|
|
3499
|
+
ctx.changes.push({
|
|
3500
|
+
type: "replace",
|
|
3501
|
+
line: migrateGetLine(ctx, node),
|
|
3502
|
+
description: `${attrName} → ${htmlAttr}`
|
|
3503
|
+
});
|
|
3504
|
+
}
|
|
3505
|
+
if (attrName === "onChange") {
|
|
3506
|
+
const jsxElement = findParentJsxElement(node);
|
|
3507
|
+
if (jsxElement) {
|
|
3508
|
+
const tagName = getJsxTagName(jsxElement);
|
|
3509
|
+
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
|
|
3510
|
+
ctx.replacements.push({
|
|
3511
|
+
start: node.name.getStart(ctx.sf),
|
|
3512
|
+
end: node.name.getEnd(),
|
|
3513
|
+
text: "onInput"
|
|
3514
|
+
});
|
|
3515
|
+
ctx.changes.push({
|
|
3516
|
+
type: "replace",
|
|
3517
|
+
line: migrateGetLine(ctx, node),
|
|
3518
|
+
description: `onChange on <${tagName}> → onInput (native DOM events)`
|
|
3519
|
+
});
|
|
2860
3520
|
}
|
|
2861
3521
|
}
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
walk(sf);
|
|
2865
|
-
return names;
|
|
3522
|
+
}
|
|
3523
|
+
if (attrName === "dangerouslySetInnerHTML") migrateDangerouslySetInnerHTML(ctx, node);
|
|
2866
3524
|
}
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
for (const prop of arg.properties) {
|
|
2880
|
-
if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue;
|
|
2881
|
-
const key = prop.name;
|
|
2882
|
-
const keyText = ts.isIdentifier(key) ? key.text : ts.isStringLiteral(key) ? key.text : "";
|
|
2883
|
-
if (!keyText || !ctx.neverIslandNames.has(keyText)) continue;
|
|
2884
|
-
pushDiag(ctx, prop, "island-never-with-registry-entry", `island "${keyText}" was declared with \`hydrate: 'never'\` and MUST NOT be registered in \`hydrateIslands({ ... })\`. The whole point of the \`'never'\` strategy is shipping zero client JS — registering pulls the component module into the client bundle graph (the runtime short-circuits never-strategy before the registry lookup, but the bundler still includes the import). Drop this entry; the framework handles never-strategy islands at SSR with no client-side wiring.`, getNodeText(ctx, prop), `// remove the "${keyText}" entry — never-strategy islands need no registry entry`, false);
|
|
3525
|
+
function migrateDangerouslySetInnerHTML(ctx, node) {
|
|
3526
|
+
if (!node.initializer || !ts.isJsxExpression(node.initializer) || !node.initializer.expression) return;
|
|
3527
|
+
const expr = node.initializer.expression;
|
|
3528
|
+
if (!ts.isObjectLiteralExpression(expr)) return;
|
|
3529
|
+
const htmlProp = expr.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "__html");
|
|
3530
|
+
if (htmlProp) {
|
|
3531
|
+
migrateReplace(ctx, node, `innerHTML={${migrateGetNodeText(ctx, htmlProp.initializer)}}`);
|
|
3532
|
+
ctx.changes.push({
|
|
3533
|
+
type: "replace",
|
|
3534
|
+
line: migrateGetLine(ctx, node),
|
|
3535
|
+
description: "dangerouslySetInnerHTML → innerHTML"
|
|
3536
|
+
});
|
|
2885
3537
|
}
|
|
2886
3538
|
}
|
|
2887
|
-
function
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
3539
|
+
function applyReplacements(code, ctx) {
|
|
3540
|
+
for (const imp of ctx.importsToRemove) {
|
|
3541
|
+
ctx.replacements.push({
|
|
3542
|
+
start: imp.getStart(ctx.sf),
|
|
3543
|
+
end: imp.getEnd(),
|
|
3544
|
+
text: ""
|
|
3545
|
+
});
|
|
3546
|
+
ctx.changes.push({
|
|
3547
|
+
type: "remove",
|
|
3548
|
+
line: ctx.sf.getLineAndCharacterOfPosition(imp.getStart(ctx.sf)).line + 1,
|
|
3549
|
+
description: "Removed React import"
|
|
3550
|
+
});
|
|
2892
3551
|
}
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
3552
|
+
ctx.replacements.sort((a, b) => b.start - a.start);
|
|
3553
|
+
const applied = /* @__PURE__ */ new Set();
|
|
3554
|
+
const deduped = [];
|
|
3555
|
+
for (const r of ctx.replacements) {
|
|
3556
|
+
const key = `${r.start}:${r.end}`;
|
|
3557
|
+
let overlaps = false;
|
|
3558
|
+
for (const d of deduped) if (r.start < d.end && r.end > d.start) {
|
|
3559
|
+
overlaps = true;
|
|
3560
|
+
break;
|
|
3561
|
+
}
|
|
3562
|
+
if (!overlaps && !applied.has(key)) {
|
|
3563
|
+
applied.add(key);
|
|
3564
|
+
deduped.push(r);
|
|
3565
|
+
}
|
|
2896
3566
|
}
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
3567
|
+
deduped.sort((a, b) => a.start - b.start);
|
|
3568
|
+
const parts = [];
|
|
3569
|
+
let lastPos = 0;
|
|
3570
|
+
for (const r of deduped) {
|
|
3571
|
+
parts.push(code.slice(lastPos, r.start));
|
|
3572
|
+
parts.push(r.text);
|
|
3573
|
+
lastPos = r.end;
|
|
2903
3574
|
}
|
|
2904
|
-
|
|
2905
|
-
|
|
3575
|
+
parts.push(code.slice(lastPos));
|
|
3576
|
+
return parts.join("");
|
|
2906
3577
|
}
|
|
2907
|
-
function
|
|
3578
|
+
function insertPyreonImports(code, pyreonImports) {
|
|
3579
|
+
if (pyreonImports.size === 0) return code;
|
|
3580
|
+
const importLines = [];
|
|
3581
|
+
const sorted = [...pyreonImports.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
3582
|
+
for (const [source, specs] of sorted) {
|
|
3583
|
+
const specList = [...specs].sort().join(", ");
|
|
3584
|
+
importLines.push(`import { ${specList} } from "${source}"`);
|
|
3585
|
+
}
|
|
3586
|
+
const importBlock = importLines.join("\n");
|
|
3587
|
+
const lastImportEnd = findLastImportEnd(code);
|
|
3588
|
+
if (lastImportEnd > 0) return `${code.slice(0, lastImportEnd)}\n${importBlock}${code.slice(lastImportEnd)}`;
|
|
3589
|
+
return `${importBlock}\n\n${code}`;
|
|
3590
|
+
}
|
|
3591
|
+
function migrateVisitNode(ctx, node) {
|
|
3592
|
+
if (ts.isImportDeclaration(node)) migrateImportDeclaration(ctx, node);
|
|
3593
|
+
if (isCallToHook(node, "useState")) migrateUseState(ctx, node);
|
|
3594
|
+
if (isCallToEffectHook(node)) migrateUseEffect(ctx, node);
|
|
3595
|
+
if (isCallToHook(node, "useMemo")) migrateUseMemo(ctx, node);
|
|
3596
|
+
if (isCallToHook(node, "useCallback")) migrateUseCallback(ctx, node);
|
|
3597
|
+
if (isCallToHook(node, "useRef")) migrateUseRef(ctx, node);
|
|
3598
|
+
if (ts.isCallExpression(node)) migrateMemoWrapper(ctx, node);
|
|
3599
|
+
if (ts.isCallExpression(node)) migrateForwardRef(ctx, node);
|
|
3600
|
+
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) migrateJsxAttributes(ctx, node);
|
|
3601
|
+
}
|
|
3602
|
+
function migrateVisit(ctx, node) {
|
|
2908
3603
|
ts.forEachChild(node, (child) => {
|
|
2909
|
-
|
|
2910
|
-
|
|
3604
|
+
migrateVisitNode(ctx, child);
|
|
3605
|
+
migrateVisit(ctx, child);
|
|
2911
3606
|
});
|
|
2912
3607
|
}
|
|
2913
|
-
function
|
|
3608
|
+
function migrateReactCode(code, filename = "input.tsx") {
|
|
2914
3609
|
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
|
|
3610
|
+
const diagnostics = detectReactPatterns(code, filename);
|
|
2915
3611
|
const ctx = {
|
|
2916
3612
|
sf,
|
|
2917
3613
|
code,
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
3614
|
+
replacements: [],
|
|
3615
|
+
changes: [],
|
|
3616
|
+
pyreonImports: /* @__PURE__ */ new Map(),
|
|
3617
|
+
importsToRemove: /* @__PURE__ */ new Set(),
|
|
3618
|
+
specifierRewrites: /* @__PURE__ */ new Map()
|
|
3619
|
+
};
|
|
3620
|
+
migrateVisit(ctx, sf);
|
|
3621
|
+
let result = applyReplacements(code, ctx);
|
|
3622
|
+
result = insertPyreonImports(result, ctx.pyreonImports);
|
|
3623
|
+
result = result.replace(/\n{3,}/g, "\n\n");
|
|
3624
|
+
return {
|
|
3625
|
+
code: result,
|
|
3626
|
+
diagnostics,
|
|
3627
|
+
changes: ctx.changes
|
|
2921
3628
|
};
|
|
2922
|
-
visit(ctx, sf);
|
|
2923
|
-
ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
2924
|
-
return ctx.diagnostics;
|
|
2925
3629
|
}
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
3630
|
+
function findParentJsxElement(node) {
|
|
3631
|
+
let current = node.parent;
|
|
3632
|
+
while (current) {
|
|
3633
|
+
if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current)) return current;
|
|
3634
|
+
if (ts.isJsxElement(current)) return current.openingElement;
|
|
3635
|
+
if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) return null;
|
|
3636
|
+
current = current.parent;
|
|
3637
|
+
}
|
|
3638
|
+
return null;
|
|
3639
|
+
}
|
|
3640
|
+
function getJsxTagName(node) {
|
|
3641
|
+
const tagName = node.tagName;
|
|
3642
|
+
if (ts.isIdentifier(tagName)) return tagName.text;
|
|
3643
|
+
return "";
|
|
3644
|
+
}
|
|
3645
|
+
function findLastImportEnd(code) {
|
|
3646
|
+
const importRe = /^import\s.+$/gm;
|
|
3647
|
+
let lastEnd = 0;
|
|
3648
|
+
let match;
|
|
3649
|
+
while (true) {
|
|
3650
|
+
match = importRe.exec(code);
|
|
3651
|
+
if (!match) break;
|
|
3652
|
+
lastEnd = match.index + match[0].length;
|
|
3653
|
+
}
|
|
3654
|
+
return lastEnd;
|
|
3655
|
+
}
|
|
3656
|
+
/** Fast regex check — returns true if code likely contains React patterns worth analyzing */
|
|
3657
|
+
function hasReactPatterns(code) {
|
|
3658
|
+
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);
|
|
3659
|
+
}
|
|
3660
|
+
const ERROR_PATTERNS = [
|
|
3661
|
+
{
|
|
3662
|
+
pattern: /Cannot read properties of undefined \(reading '(set|update|peek|subscribe)'\)/,
|
|
3663
|
+
diagnose: (m) => ({
|
|
3664
|
+
cause: `Calling .${m[1]}() on undefined. The signal variable is likely out of scope, misspelled, or not yet initialized.`,
|
|
3665
|
+
fix: "Check that the signal is defined and in scope. Signals must be created with signal() before use.",
|
|
3666
|
+
fixCode: `const mySignal = signal(initialValue)\nmySignal.${m[1]}(newValue)`
|
|
3667
|
+
})
|
|
3668
|
+
},
|
|
3669
|
+
{
|
|
3670
|
+
pattern: /(\w+) is not a function/,
|
|
3671
|
+
diagnose: (m) => ({
|
|
3672
|
+
cause: `'${m[1]}' is not callable. If this is a signal, you need to call it: ${m[1]}()`,
|
|
3673
|
+
fix: "Pyreon signals are callable functions. Read: signal(), Write: signal.set(value)",
|
|
3674
|
+
fixCode: `// Read value:\nconst value = ${m[1]}()\n// Set value:\n${m[1]}.set(newValue)`
|
|
3675
|
+
})
|
|
3676
|
+
},
|
|
3677
|
+
{
|
|
3678
|
+
pattern: /Cannot find module '(@pyreon\/\w[\w-]*)'/,
|
|
3679
|
+
diagnose: (m) => ({
|
|
3680
|
+
cause: `Package ${m[1]} is not installed.`,
|
|
3681
|
+
fix: `Run: bun add ${m[1]}`,
|
|
3682
|
+
fixCode: `bun add ${m[1]}`
|
|
3683
|
+
})
|
|
3684
|
+
},
|
|
3685
|
+
{
|
|
3686
|
+
pattern: /Cannot find module 'react'/,
|
|
3687
|
+
diagnose: () => ({
|
|
3688
|
+
cause: "Importing from 'react' in a Pyreon project.",
|
|
3689
|
+
fix: "Replace React imports with Pyreon equivalents.",
|
|
3690
|
+
fixCode: "// Instead of:\nimport { useState } from \"react\"\n// Use:\nimport { signal } from \"@pyreon/reactivity\""
|
|
3691
|
+
})
|
|
3692
|
+
},
|
|
3693
|
+
{
|
|
3694
|
+
pattern: /Property '(\w+)' does not exist on type 'Signal<\w+>'/,
|
|
3695
|
+
diagnose: (m) => ({
|
|
3696
|
+
cause: `Accessing .${m[1]} on a signal. Pyreon signals don't have a .${m[1]} property.`,
|
|
3697
|
+
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.`,
|
|
3698
|
+
fixCode: m[1] === "value" ? "// Read: mySignal()\n// Write: mySignal.set(newValue)" : void 0
|
|
3699
|
+
})
|
|
3700
|
+
},
|
|
3701
|
+
{
|
|
3702
|
+
pattern: /Type '(\w+)' is not assignable to type 'VNode'/,
|
|
3703
|
+
diagnose: (m) => ({
|
|
3704
|
+
cause: `Component returned ${m[1]} instead of VNode. Components must return JSX, null, or a string.`,
|
|
3705
|
+
fix: "Make sure your component returns a JSX element, null, or a string.",
|
|
3706
|
+
fixCode: "const MyComponent = (props) => {\n return <div>{props.children}</div>\n}"
|
|
3707
|
+
})
|
|
3708
|
+
},
|
|
3709
|
+
{
|
|
3710
|
+
pattern: /onMount callback must return/,
|
|
3711
|
+
diagnose: () => ({
|
|
3712
|
+
cause: "onMount expects a callback that optionally returns a CleanupFn.",
|
|
3713
|
+
fix: "Return a cleanup function, or return nothing.",
|
|
3714
|
+
fixCode: "onMount(() => {\n // setup code\n})"
|
|
3715
|
+
})
|
|
3716
|
+
},
|
|
3717
|
+
{
|
|
3718
|
+
pattern: /Expected 'by' prop on <For>/,
|
|
3719
|
+
diagnose: () => ({
|
|
3720
|
+
cause: "<For> requires a 'by' prop for efficient keyed reconciliation.",
|
|
3721
|
+
fix: "Add a by prop that returns a unique key for each item.",
|
|
3722
|
+
fixCode: "<For each={items()} by={item => item.id}>\n {item => <li>{item.name}</li>}\n</For>"
|
|
3723
|
+
})
|
|
3724
|
+
},
|
|
3725
|
+
{
|
|
3726
|
+
pattern: /useHook.*outside.*component/i,
|
|
3727
|
+
diagnose: () => ({
|
|
3728
|
+
cause: "Hook called outside a component function. Pyreon hooks must be called during component setup.",
|
|
3729
|
+
fix: "Move the hook call inside a component function body."
|
|
3730
|
+
})
|
|
3731
|
+
},
|
|
3732
|
+
{
|
|
3733
|
+
pattern: /Hydration mismatch/,
|
|
3734
|
+
diagnose: () => ({
|
|
3735
|
+
cause: "Server-rendered HTML doesn't match client-rendered output.",
|
|
3736
|
+
fix: "Ensure SSR and client render the same initial content. Check for browser-only APIs (window, document) in SSR code.",
|
|
3737
|
+
related: "Use typeof window !== 'undefined' checks or onMount() for client-only code."
|
|
3738
|
+
})
|
|
3739
|
+
}
|
|
3740
|
+
];
|
|
3741
|
+
/** Diagnose an error message and return structured fix information */
|
|
3742
|
+
function diagnoseError(error) {
|
|
3743
|
+
for (const { pattern, diagnose } of ERROR_PATTERNS) {
|
|
3744
|
+
const match = error.match(pattern);
|
|
3745
|
+
if (match) return diagnose(match);
|
|
3746
|
+
}
|
|
3747
|
+
return null;
|
|
2929
3748
|
}
|
|
2930
3749
|
|
|
2931
3750
|
//#endregion
|
|
@@ -4019,5 +4838,5 @@ function formatSsgAudit(result, _options = {}) {
|
|
|
4019
4838
|
}
|
|
4020
4839
|
|
|
4021
4840
|
//#endregion
|
|
4022
|
-
export { auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformDeferInline, transformJSX, transformJSX_JS };
|
|
4841
|
+
export { analyzeReactivity, auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatReactivityLens, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, rocketstyleCollapseKey, scanCollapsibleSites, transformDeferInline, transformJSX, transformJSX_JS };
|
|
4023
4842
|
//# sourceMappingURL=index.js.map
|