@pyreon/compiler 0.16.0 → 0.19.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 +1919 -1152
- package/lib/types/index.d.ts +257 -93
- package/package.json +13 -12
- package/src/defer-inline.ts +686 -0
- package/src/index.ts +14 -1
- package/src/jsx.ts +164 -6
- 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/defer-inline.test.ts +387 -0
- package/src/tests/detector-tag-consistency.test.ts +2 -0
- package/src/tests/jsx.test.ts +23 -3
- package/src/tests/manifest-snapshot.test.ts +55 -0
- package/src/tests/native-equivalence.test.ts +104 -3
- package/src/tests/pyreon-intercept.test.ts +189 -0
- package/src/tests/react-intercept.test.ts +50 -2
- package/src/tests/reactivity-lens.test.ts +170 -0
package/lib/index.js
CHANGED
|
@@ -3,10 +3,455 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { dirname, join, relative, resolve } from "node:path";
|
|
6
|
+
import ts from "typescript";
|
|
6
7
|
import * as fs from "node:fs";
|
|
7
8
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
8
|
-
import ts from "typescript";
|
|
9
9
|
|
|
10
|
+
//#region src/defer-inline.ts
|
|
11
|
+
/**
|
|
12
|
+
* Inline-children transform for `<Defer>`.
|
|
13
|
+
*
|
|
14
|
+
* Rewrites:
|
|
15
|
+
*
|
|
16
|
+
* import { Modal } from './Modal'
|
|
17
|
+
* <Defer when={open()}><Modal title="hi" /></Defer>
|
|
18
|
+
*
|
|
19
|
+
* into:
|
|
20
|
+
*
|
|
21
|
+
* <Defer when={open()} chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}>
|
|
22
|
+
* {(__C) => <__C title="hi" />}
|
|
23
|
+
* </Defer>
|
|
24
|
+
*
|
|
25
|
+
* The static `import { Modal } from './Modal'` is removed when `Modal` is
|
|
26
|
+
* referenced ONLY inside the Defer subtree — otherwise Rolldown would
|
|
27
|
+
* bundle the module statically and the dynamic import becomes a no-op.
|
|
28
|
+
*
|
|
29
|
+
* Scope (v2 — post #587 + this PR):
|
|
30
|
+
* - Multiple Defer elements per file: each rewritten independently.
|
|
31
|
+
* - Children: exactly ONE JSXElement, capitalised name (component
|
|
32
|
+
* reference). Self-closing OR with children. **Props ARE allowed**
|
|
33
|
+
* (post-v2) and pass through unchanged into the render-prop body —
|
|
34
|
+
* closure capture works naturally because the render-prop arrow
|
|
35
|
+
* captures the surrounding lexical scope.
|
|
36
|
+
* - Multiple non-whitespace children → bail with a warning.
|
|
37
|
+
* User must use the explicit `chunk` form with a render-prop.
|
|
38
|
+
* - Imports: default, named, **renamed** (`{ X as Y }`). Namespace
|
|
39
|
+
* imports (`* as M` + `<M.X />`) NOT supported — bail with a warning.
|
|
40
|
+
* - **Multi-specifier imports** (`{ A, B } from './x'`): only the
|
|
41
|
+
* binding used in Defer is removed; siblings stay intact.
|
|
42
|
+
* - Triggers (`when={...}`, `on="visible"`, `on="idle"`) pass through.
|
|
43
|
+
* - Other props on `<Defer>` (e.g. `fallback`) pass through.
|
|
44
|
+
*
|
|
45
|
+
* The transform is intentionally conservative — anything unusual leaves
|
|
46
|
+
* the source unchanged + emits a warning. Pipeline: runs BEFORE
|
|
47
|
+
* `transformJSX()` in the vite plugin. The output is still JSX —
|
|
48
|
+
* `transformJSX` then converts to runtime calls as usual.
|
|
49
|
+
*/
|
|
50
|
+
function getLang$1(filename) {
|
|
51
|
+
if (filename.endsWith(".tsx")) return "tsx";
|
|
52
|
+
if (filename.endsWith(".jsx")) return "jsx";
|
|
53
|
+
if (filename.endsWith(".ts")) return "ts";
|
|
54
|
+
return "js";
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Returns the JSX tag name as a string when the opening element's name
|
|
58
|
+
* is a simple identifier (`<Modal />`). Member-expression names
|
|
59
|
+
* (`<M.Modal />`) and namespaced names (`<svg:rect />`) return null —
|
|
60
|
+
* use `getJsxMemberName()` for the namespace-import case.
|
|
61
|
+
*/
|
|
62
|
+
function getJsxName(node) {
|
|
63
|
+
const open = node.openingElement;
|
|
64
|
+
if (!open) return null;
|
|
65
|
+
const name = open.name;
|
|
66
|
+
if (!name || name.type !== "JSXIdentifier") return null;
|
|
67
|
+
return name.name;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* For `<M.Modal />` — returns `{ object: 'M', property: 'Modal' }` when
|
|
71
|
+
* the JSX name is a depth-1 JSXMemberExpression. Returns null for any
|
|
72
|
+
* other shape (deeper nesting like `<M.Sub.X />`, JSXNamespacedName,
|
|
73
|
+
* non-identifier). The depth-1 restriction keeps the rewrite simple:
|
|
74
|
+
* `M.Modal` is replaced with `__C` in the source.
|
|
75
|
+
*/
|
|
76
|
+
function getJsxMemberName(node) {
|
|
77
|
+
const open = node.openingElement;
|
|
78
|
+
if (!open) return null;
|
|
79
|
+
const name = open.name;
|
|
80
|
+
if (!name || name.type !== "JSXMemberExpression") return null;
|
|
81
|
+
const obj = name.object;
|
|
82
|
+
const prop = name.property;
|
|
83
|
+
if (!obj || obj.type !== "JSXIdentifier") return null;
|
|
84
|
+
if (!prop || prop.type !== "JSXIdentifier") return null;
|
|
85
|
+
return {
|
|
86
|
+
object: obj.name,
|
|
87
|
+
property: prop.name
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Verify a JSX child node is a single component-element we can rewrite.
|
|
92
|
+
* Handles two shapes:
|
|
93
|
+
* 1. `<Modal />` — identifier name, capitalised (component, not HTML).
|
|
94
|
+
* 2. `<M.Modal />` — depth-1 member expression with capitalised
|
|
95
|
+
* property name. The object (`M`) is the local binding to look up;
|
|
96
|
+
* the property (`Modal`) is the actual export to extract.
|
|
97
|
+
*
|
|
98
|
+
* Both shapes allow props (post-v2). Deeper nesting (`<M.Sub.X />`),
|
|
99
|
+
* JSXNamespacedName (`<svg:rect />`), and non-component lowercase names
|
|
100
|
+
* return null.
|
|
101
|
+
*/
|
|
102
|
+
function analyzeChildElement(node) {
|
|
103
|
+
if (node.type !== "JSXElement") return null;
|
|
104
|
+
const openName = node.openingElement.name;
|
|
105
|
+
const close = node.closingElement;
|
|
106
|
+
const identName = getJsxName(node);
|
|
107
|
+
if (identName) {
|
|
108
|
+
if (!/^[A-Z]/.test(identName)) return null;
|
|
109
|
+
return {
|
|
110
|
+
kind: "identifier",
|
|
111
|
+
lookupName: identName,
|
|
112
|
+
propertyName: "",
|
|
113
|
+
openNameRange: {
|
|
114
|
+
start: openName.start,
|
|
115
|
+
end: openName.end
|
|
116
|
+
},
|
|
117
|
+
closeNameRange: close ? {
|
|
118
|
+
start: close.name.start,
|
|
119
|
+
end: close.name.end
|
|
120
|
+
} : null
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const memberName = getJsxMemberName(node);
|
|
124
|
+
if (memberName) {
|
|
125
|
+
if (!/^[A-Z]/.test(memberName.property)) return null;
|
|
126
|
+
return {
|
|
127
|
+
kind: "member",
|
|
128
|
+
lookupName: memberName.object,
|
|
129
|
+
propertyName: memberName.property,
|
|
130
|
+
openNameRange: {
|
|
131
|
+
start: openName.start,
|
|
132
|
+
end: openName.end
|
|
133
|
+
},
|
|
134
|
+
closeNameRange: close ? {
|
|
135
|
+
start: close.name.start,
|
|
136
|
+
end: close.name.end
|
|
137
|
+
} : null
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
/** Filter whitespace-only JSXText nodes (formatting noise between JSX elements). */
|
|
143
|
+
function nonWhitespaceChildren(node) {
|
|
144
|
+
return (node.children ?? []).filter((c) => !(c.type === "JSXText" && /^\s*$/.test(c.value)));
|
|
145
|
+
}
|
|
146
|
+
function findDeferMatches(program, warnings, code) {
|
|
147
|
+
const matches = [];
|
|
148
|
+
const walk = (node) => {
|
|
149
|
+
if (!node || typeof node !== "object") return;
|
|
150
|
+
if (node.type === "JSXElement" && getJsxName(node) === "Defer") {
|
|
151
|
+
const open = node.openingElement;
|
|
152
|
+
if (!(open.attributes ?? []).some((a) => a.type === "JSXAttribute" && a.name?.type === "JSXIdentifier" && a.name.name === "chunk")) {
|
|
153
|
+
const live = nonWhitespaceChildren(node);
|
|
154
|
+
if (live.length > 1) {
|
|
155
|
+
const loc = getLoc(code, node.start ?? 0);
|
|
156
|
+
warnings.push({
|
|
157
|
+
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.`,
|
|
158
|
+
line: loc.line,
|
|
159
|
+
column: loc.column,
|
|
160
|
+
code: "defer-inline/multiple-children"
|
|
161
|
+
});
|
|
162
|
+
} else if (live.length === 1) {
|
|
163
|
+
const analysis = analyzeChildElement(live[0]);
|
|
164
|
+
if (analysis) {
|
|
165
|
+
const close = node.closingElement;
|
|
166
|
+
matches.push({
|
|
167
|
+
node,
|
|
168
|
+
child: live[0],
|
|
169
|
+
childAnalysis: analysis,
|
|
170
|
+
insertChunkAt: open.end - 1,
|
|
171
|
+
childrenRange: {
|
|
172
|
+
start: open.end,
|
|
173
|
+
end: close?.start ?? node.end
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
} else {
|
|
177
|
+
const loc = getLoc(code, live[0].start ?? 0);
|
|
178
|
+
warnings.push({
|
|
179
|
+
message: `<Defer> inline form requires a single component-element child (capitalised JSX identifier). Use the explicit \`chunk\` prop for any other shape.`,
|
|
180
|
+
line: loc.line,
|
|
181
|
+
column: loc.column,
|
|
182
|
+
code: "defer-inline/non-component-child"
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const key in node) {
|
|
189
|
+
if (key === "parent") continue;
|
|
190
|
+
const v = node[key];
|
|
191
|
+
if (Array.isArray(v)) for (const item of v) walk(item);
|
|
192
|
+
else if (v && typeof v === "object" && typeof v.type === "string") walk(v);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
walk(program);
|
|
196
|
+
return matches;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Locate the import declaration that binds `localName`.
|
|
200
|
+
*
|
|
201
|
+
* For namespace imports (`import * as M`), `localName` is the namespace
|
|
202
|
+
* identifier (`M`). The caller's `propertyName` provides the actual
|
|
203
|
+
* export name to extract — `findImportFor` returns `importedName: ''`
|
|
204
|
+
* for the namespace case and the caller substitutes its own property.
|
|
205
|
+
*/
|
|
206
|
+
function findImportFor(program, localName) {
|
|
207
|
+
const body = program.body ?? [];
|
|
208
|
+
for (const stmt of body) {
|
|
209
|
+
if (stmt.type !== "ImportDeclaration") continue;
|
|
210
|
+
const specifiers = stmt.specifiers ?? [];
|
|
211
|
+
for (const spec of specifiers) if (spec.type === "ImportDefaultSpecifier") {
|
|
212
|
+
if (spec.local.name === localName) return {
|
|
213
|
+
declaration: stmt,
|
|
214
|
+
specifier: spec,
|
|
215
|
+
source: stmt.source.value,
|
|
216
|
+
kind: "default",
|
|
217
|
+
importedName: "default"
|
|
218
|
+
};
|
|
219
|
+
} else if (spec.type === "ImportSpecifier") {
|
|
220
|
+
const lname = spec.local.name;
|
|
221
|
+
const iname = spec.imported?.name;
|
|
222
|
+
if (lname === localName && iname !== void 0) return {
|
|
223
|
+
declaration: stmt,
|
|
224
|
+
specifier: spec,
|
|
225
|
+
source: stmt.source.value,
|
|
226
|
+
kind: "named",
|
|
227
|
+
importedName: iname
|
|
228
|
+
};
|
|
229
|
+
} else if (spec.type === "ImportNamespaceSpecifier") {
|
|
230
|
+
if (spec.local.name === localName) return {
|
|
231
|
+
declaration: stmt,
|
|
232
|
+
specifier: spec,
|
|
233
|
+
source: stmt.source.value,
|
|
234
|
+
kind: "namespace",
|
|
235
|
+
importedName: ""
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Count references to `localName` outside the given Defer subtree, AND
|
|
243
|
+
* outside the import declaration that defines it. The static import can
|
|
244
|
+
* only be safely removed when the local binding is used EXCLUSIVELY
|
|
245
|
+
* inside that Defer subtree — otherwise removing the import would break
|
|
246
|
+
* the other usage AND the dynamic import would be a no-op (Rolldown
|
|
247
|
+
* static-bundles the module on shared usage).
|
|
248
|
+
*/
|
|
249
|
+
function countReferencesOutside(program, localName, skipSubtree, skipDeclaration) {
|
|
250
|
+
let count = 0;
|
|
251
|
+
const skipStart = skipSubtree.start;
|
|
252
|
+
const skipEnd = skipSubtree.end;
|
|
253
|
+
const declStart = skipDeclaration.start;
|
|
254
|
+
const declEnd = skipDeclaration.end;
|
|
255
|
+
const inSkip = (s, e) => s >= skipStart && e <= skipEnd || s >= declStart && e <= declEnd;
|
|
256
|
+
const visit = (node) => {
|
|
257
|
+
if (!node || typeof node !== "object") return;
|
|
258
|
+
const ns = node.start;
|
|
259
|
+
const ne = node.end;
|
|
260
|
+
if (typeof ns === "number" && typeof ne === "number" && inSkip(ns, ne)) return;
|
|
261
|
+
if (node.type === "Identifier" && node.name === localName) count++;
|
|
262
|
+
if (node.type === "JSXIdentifier" && node.name === localName) count++;
|
|
263
|
+
for (const key in node) {
|
|
264
|
+
if (key === "parent") continue;
|
|
265
|
+
const v = node[key];
|
|
266
|
+
if (Array.isArray(v)) for (const item of v) visit(item);
|
|
267
|
+
else if (v && typeof v === "object" && typeof v.type === "string") visit(v);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const body = program.body ?? [];
|
|
271
|
+
for (const stmt of body) visit(stmt);
|
|
272
|
+
return count;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Build the chunk={...} attribute string.
|
|
276
|
+
*
|
|
277
|
+
* - `default` → `chunk={() => import('./x')}`. The default export IS the
|
|
278
|
+
* component; no re-wrapping needed.
|
|
279
|
+
* - `named` / `namespace` → `chunk={() => import('./x').then((__m) => ({
|
|
280
|
+
* default: __m.X }))}`. The `default` slot points at the named export
|
|
281
|
+
* (for `named`) or the member-expression property (for `namespace`).
|
|
282
|
+
*
|
|
283
|
+
* The caller picks `exportName` — for `named`, it's `info.importedName`;
|
|
284
|
+
* for `namespace`, it's the JSX member-expression property.
|
|
285
|
+
*/
|
|
286
|
+
function buildChunkAttr(source, kind, exportName) {
|
|
287
|
+
if (kind === "default") return ` chunk={() => import('${source}')}`;
|
|
288
|
+
return ` chunk={() => import('${source}').then((__m) => ({ default: __m.${exportName} }))}`;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Build the render-prop body from the original child JSX. Replaces the
|
|
292
|
+
* component name (in both opening and closing tags) with `__C` — every
|
|
293
|
+
* other character of the original source survives verbatim. Props /
|
|
294
|
+
* nested children / event handlers / closure captures all flow through
|
|
295
|
+
* unchanged. The render-prop arrow's lexical scope captures whatever
|
|
296
|
+
* was in scope at the call site.
|
|
297
|
+
*/
|
|
298
|
+
function buildRenderPropBody(code, analysis, childRange) {
|
|
299
|
+
const start = childRange.start;
|
|
300
|
+
const end = childRange.end;
|
|
301
|
+
let body = code.slice(start, end);
|
|
302
|
+
if (analysis.closeNameRange) {
|
|
303
|
+
const r = analysis.closeNameRange;
|
|
304
|
+
body = body.slice(0, r.start - start) + "__C" + body.slice(r.end - start);
|
|
305
|
+
}
|
|
306
|
+
body = body.slice(0, analysis.openNameRange.start - start) + "__C" + body.slice(analysis.openNameRange.end - start);
|
|
307
|
+
return `{(__C) => ${body}}`;
|
|
308
|
+
}
|
|
309
|
+
function applyEdits(source, edits) {
|
|
310
|
+
const sorted = [...edits].sort((a, b) => b.start - a.start);
|
|
311
|
+
let out = source;
|
|
312
|
+
for (const e of sorted) out = out.slice(0, e.start) + e.replacement + out.slice(e.end);
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Compute the edit that removes the import binding for the given match.
|
|
317
|
+
* Handles three shapes:
|
|
318
|
+
* 1. Single-specifier declaration → remove the entire ImportDeclaration
|
|
319
|
+
* (including its trailing newline).
|
|
320
|
+
* 2. Multi-specifier declaration where this is the FIRST specifier →
|
|
321
|
+
* remove the specifier + the comma + whitespace that follows it.
|
|
322
|
+
* 3. Multi-specifier declaration where this is a LATER specifier →
|
|
323
|
+
* remove the preceding comma + whitespace + the specifier.
|
|
324
|
+
*
|
|
325
|
+
* Case (1) is the simple v1 path; cases (2) and (3) are the v2
|
|
326
|
+
* multi-specifier handling.
|
|
327
|
+
*/
|
|
328
|
+
function buildImportRemovalEdit(code, info) {
|
|
329
|
+
const specifiers = info.declaration.specifiers ?? [];
|
|
330
|
+
if (specifiers.length === 1) {
|
|
331
|
+
const start = info.declaration.start;
|
|
332
|
+
let end = info.declaration.end;
|
|
333
|
+
if (code[end] === "\n") end += 1;
|
|
334
|
+
return {
|
|
335
|
+
start,
|
|
336
|
+
end,
|
|
337
|
+
replacement: ""
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const idx = specifiers.indexOf(info.specifier);
|
|
341
|
+
const specStart = info.specifier.start;
|
|
342
|
+
const specEnd = info.specifier.end;
|
|
343
|
+
if (idx === 0) return {
|
|
344
|
+
start: specStart,
|
|
345
|
+
end: specifiers[1].start,
|
|
346
|
+
replacement: ""
|
|
347
|
+
};
|
|
348
|
+
return {
|
|
349
|
+
start: specifiers[idx - 1].end,
|
|
350
|
+
end: specEnd,
|
|
351
|
+
replacement: ""
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function transformDeferInline(code, filename = "input.tsx") {
|
|
355
|
+
const warnings = [];
|
|
356
|
+
if (!code.includes("Defer")) return {
|
|
357
|
+
code,
|
|
358
|
+
changed: false,
|
|
359
|
+
warnings
|
|
360
|
+
};
|
|
361
|
+
let program;
|
|
362
|
+
try {
|
|
363
|
+
program = parseSync(filename, code, {
|
|
364
|
+
sourceType: "module",
|
|
365
|
+
lang: getLang$1(filename)
|
|
366
|
+
}).program;
|
|
367
|
+
} catch {
|
|
368
|
+
return {
|
|
369
|
+
code,
|
|
370
|
+
changed: false,
|
|
371
|
+
warnings
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const matches = findDeferMatches(program, warnings, code);
|
|
375
|
+
if (matches.length === 0) return {
|
|
376
|
+
code,
|
|
377
|
+
changed: false,
|
|
378
|
+
warnings
|
|
379
|
+
};
|
|
380
|
+
const edits = [];
|
|
381
|
+
let changed = false;
|
|
382
|
+
for (const m of matches) {
|
|
383
|
+
const displayName = m.childAnalysis.kind === "member" ? `${m.childAnalysis.lookupName}.${m.childAnalysis.propertyName}` : m.childAnalysis.lookupName;
|
|
384
|
+
const importInfo = findImportFor(program, m.childAnalysis.lookupName);
|
|
385
|
+
if (!importInfo) {
|
|
386
|
+
const loc = getLoc(code, m.child.start ?? 0);
|
|
387
|
+
warnings.push({
|
|
388
|
+
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.`,
|
|
389
|
+
line: loc.line,
|
|
390
|
+
column: loc.column,
|
|
391
|
+
code: "defer-inline/import-not-found"
|
|
392
|
+
});
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (m.childAnalysis.kind === "member" && importInfo.kind !== "namespace") {
|
|
396
|
+
const loc = getLoc(code, m.child.start ?? 0);
|
|
397
|
+
warnings.push({
|
|
398
|
+
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.`,
|
|
399
|
+
line: loc.line,
|
|
400
|
+
column: loc.column,
|
|
401
|
+
code: "defer-inline/unsupported-import-shape"
|
|
402
|
+
});
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (m.childAnalysis.kind === "identifier" && importInfo.kind === "namespace") continue;
|
|
406
|
+
if (countReferencesOutside(program, m.childAnalysis.lookupName, m.node, importInfo.declaration) > 0) {
|
|
407
|
+
const loc = getLoc(code, m.node.start ?? 0);
|
|
408
|
+
warnings.push({
|
|
409
|
+
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.`,
|
|
410
|
+
line: loc.line,
|
|
411
|
+
column: loc.column,
|
|
412
|
+
code: "defer-inline/import-used-elsewhere"
|
|
413
|
+
});
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const exportName = importInfo.kind === "namespace" ? m.childAnalysis.propertyName : importInfo.importedName;
|
|
417
|
+
edits.push({
|
|
418
|
+
start: m.insertChunkAt,
|
|
419
|
+
end: m.insertChunkAt,
|
|
420
|
+
replacement: buildChunkAttr(importInfo.source, importInfo.kind, exportName)
|
|
421
|
+
});
|
|
422
|
+
edits.push({
|
|
423
|
+
start: m.childrenRange.start,
|
|
424
|
+
end: m.childrenRange.end,
|
|
425
|
+
replacement: buildRenderPropBody(code, m.childAnalysis, m.childrenRange)
|
|
426
|
+
});
|
|
427
|
+
edits.push(buildImportRemovalEdit(code, importInfo));
|
|
428
|
+
changed = true;
|
|
429
|
+
}
|
|
430
|
+
if (!changed) return {
|
|
431
|
+
code,
|
|
432
|
+
changed: false,
|
|
433
|
+
warnings
|
|
434
|
+
};
|
|
435
|
+
return {
|
|
436
|
+
code: applyEdits(code, edits),
|
|
437
|
+
changed: true,
|
|
438
|
+
warnings
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function getLoc(code, offset) {
|
|
442
|
+
let line = 1;
|
|
443
|
+
let lastNl = -1;
|
|
444
|
+
for (let i = 0; i < offset && i < code.length; i++) if (code.charCodeAt(i) === 10) {
|
|
445
|
+
line++;
|
|
446
|
+
lastNl = i;
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
line,
|
|
450
|
+
column: offset - lastNl - 1
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
//#endregion
|
|
10
455
|
//#region src/event-names.ts
|
|
11
456
|
/**
|
|
12
457
|
* React-style → DOM event-name remap.
|
|
@@ -266,7 +711,7 @@ function jsxChildren(node) {
|
|
|
266
711
|
}
|
|
267
712
|
function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
268
713
|
if (nativeTransformJsx) try {
|
|
269
|
-
return nativeTransformJsx(code, filename, options.ssr === true, options.knownSignals ?? null);
|
|
714
|
+
return nativeTransformJsx(code, filename, options.ssr === true, options.knownSignals ?? null, options.reactivityLens === true);
|
|
270
715
|
} catch {}
|
|
271
716
|
return transformJSX_JS(code, filename, options);
|
|
272
717
|
}
|
|
@@ -297,6 +742,23 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
297
742
|
code: warnCode
|
|
298
743
|
});
|
|
299
744
|
}
|
|
745
|
+
const collectLens = options.reactivityLens === true;
|
|
746
|
+
const reactivityLens = [];
|
|
747
|
+
function lens(start, end, kind, detail) {
|
|
748
|
+
if (!collectLens) return;
|
|
749
|
+
const a = locate(start);
|
|
750
|
+
const b = locate(end);
|
|
751
|
+
reactivityLens.push({
|
|
752
|
+
start,
|
|
753
|
+
end,
|
|
754
|
+
line: a.line,
|
|
755
|
+
column: a.column,
|
|
756
|
+
endLine: b.line,
|
|
757
|
+
endColumn: b.column,
|
|
758
|
+
kind,
|
|
759
|
+
detail
|
|
760
|
+
});
|
|
761
|
+
}
|
|
300
762
|
const parentMap = /* @__PURE__ */ new WeakMap();
|
|
301
763
|
const childrenMap = /* @__PURE__ */ new WeakMap();
|
|
302
764
|
/** Build parent pointers + cached children arrays for the entire AST. */
|
|
@@ -333,6 +795,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
333
795
|
let hoistIdx = 0;
|
|
334
796
|
let needsTplImport = false;
|
|
335
797
|
let needsRpImport = false;
|
|
798
|
+
let needsWrapSpreadImport = false;
|
|
336
799
|
let needsBindTextImportGlobal = false;
|
|
337
800
|
let needsBindDirectImportGlobal = false;
|
|
338
801
|
let needsBindImportGlobal = false;
|
|
@@ -346,6 +809,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
346
809
|
name,
|
|
347
810
|
text
|
|
348
811
|
});
|
|
812
|
+
lens(node.start, node.end, "hoisted-static", "static — hoisted once to module scope, never re-evaluated");
|
|
349
813
|
return name;
|
|
350
814
|
}
|
|
351
815
|
return null;
|
|
@@ -360,6 +824,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
360
824
|
end,
|
|
361
825
|
text
|
|
362
826
|
});
|
|
827
|
+
lens(start, end, "reactive", "live — re-evaluates whenever its signals change");
|
|
363
828
|
}
|
|
364
829
|
function hoistOrWrap(expr) {
|
|
365
830
|
const hoistName = maybeHoist(expr);
|
|
@@ -392,6 +857,41 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
392
857
|
if (jsxTagName(node) !== "For") return;
|
|
393
858
|
if (!jsxAttrs(node).some((p) => p.type === "JSXAttribute" && p.name?.type === "JSXIdentifier" && p.name.name === "by")) warn(node.openingElement?.name ?? node, `<For> without a "by" prop will use index-based diffing, which is slower and may cause bugs with stateful children. Add by={(item) => item.id} for efficient keyed reconciliation.`, "missing-key-on-for");
|
|
394
859
|
}
|
|
860
|
+
/**
|
|
861
|
+
* Wrap component-JSX spread arguments with `_wrapSpread(...)` so
|
|
862
|
+
* getter-shaped reactive props survive esbuild's JS-level spread emit.
|
|
863
|
+
*
|
|
864
|
+
* esbuild compiles `<Comp {...source}>` to `jsx(Comp, { ...source })`.
|
|
865
|
+
* The JS spread fires every getter on `source` and stores the resolved
|
|
866
|
+
* values — collapsing compiler-emitted reactive props (`_rp` thunks
|
|
867
|
+
* later converted to getters by `makeReactiveProps`) to static values
|
|
868
|
+
* before the receiving component sees them.
|
|
869
|
+
*
|
|
870
|
+
* `_wrapSpread` replaces getter descriptors with `_rp`-branded thunks,
|
|
871
|
+
* so the JS-level spread carries function values instead. The runtime
|
|
872
|
+
* `makeReactiveProps` step converts them back to getters on the
|
|
873
|
+
* component's props object — preserving the live signal subscription.
|
|
874
|
+
*
|
|
875
|
+
* Lowercase tags (DOM elements) go through the template path's
|
|
876
|
+
* `_applyProps` which already handles spread reactively — no need to
|
|
877
|
+
* wrap there.
|
|
878
|
+
*/
|
|
879
|
+
function handleJsxSpreadAttribute(attr, parentElement) {
|
|
880
|
+
const tagName = jsxTagName(parentElement);
|
|
881
|
+
if (!(tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase())) return;
|
|
882
|
+
const arg = attr.argument;
|
|
883
|
+
if (!arg) return;
|
|
884
|
+
if (arg.type === "CallExpression" && arg.callee?.type === "Identifier" && arg.callee.name === "_wrapSpread") return;
|
|
885
|
+
const start = arg.start;
|
|
886
|
+
const end = arg.end;
|
|
887
|
+
const sliced = sliceExpr(arg);
|
|
888
|
+
replacements.push({
|
|
889
|
+
start,
|
|
890
|
+
end,
|
|
891
|
+
text: `_wrapSpread(${sliced})`
|
|
892
|
+
});
|
|
893
|
+
needsWrapSpreadImport = true;
|
|
894
|
+
}
|
|
395
895
|
function handleJsxAttribute(node, parentElement) {
|
|
396
896
|
const name = node.name?.type === "JSXIdentifier" ? node.name.name : "";
|
|
397
897
|
if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return;
|
|
@@ -421,6 +921,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
421
921
|
text: `_rp(() => ${inner})`
|
|
422
922
|
});
|
|
423
923
|
needsRpImport = true;
|
|
924
|
+
lens(start, end, "reactive-prop", "live prop — signal reads here are tracked into the component");
|
|
424
925
|
}
|
|
425
926
|
} else hoistOrWrap(expr);
|
|
426
927
|
}
|
|
@@ -677,6 +1178,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
677
1178
|
if (!isSelfClosing(node) && tryTemplateEmit(node)) return;
|
|
678
1179
|
checkForWarnings(node);
|
|
679
1180
|
for (const attr of jsxAttrs(node)) if (attr.type === "JSXAttribute") handleJsxAttribute(attr, node);
|
|
1181
|
+
else if (attr.type === "JSXSpreadAttribute") handleJsxSpreadAttribute(attr, node);
|
|
680
1182
|
for (const child of jsxChildren(node)) if (child.type === "JSXExpressionContainer") handleJsxExpression(child);
|
|
681
1183
|
else walkNode(child);
|
|
682
1184
|
return;
|
|
@@ -693,7 +1195,11 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
693
1195
|
if (scopeShadows) for (const name of scopeShadows) shadowedSignals.delete(name);
|
|
694
1196
|
}
|
|
695
1197
|
walkNode(program);
|
|
696
|
-
if (replacements.length === 0 && hoists.length === 0) return {
|
|
1198
|
+
if (replacements.length === 0 && hoists.length === 0) return collectLens ? {
|
|
1199
|
+
code,
|
|
1200
|
+
warnings,
|
|
1201
|
+
reactivityLens
|
|
1202
|
+
} : {
|
|
697
1203
|
code,
|
|
698
1204
|
warnings
|
|
699
1205
|
};
|
|
@@ -717,8 +1223,18 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
717
1223
|
const reactivityImports = needsBindImportGlobal ? `\nimport { _bind } from "@pyreon/reactivity";` : "";
|
|
718
1224
|
output = `import { ${runtimeDomImports.join(", ")} } from "@pyreon/runtime-dom";${reactivityImports}\n` + output;
|
|
719
1225
|
}
|
|
720
|
-
if (needsRpImport
|
|
721
|
-
|
|
1226
|
+
if (needsRpImport || needsWrapSpreadImport) {
|
|
1227
|
+
const coreImports = [];
|
|
1228
|
+
if (needsRpImport) coreImports.push("_rp");
|
|
1229
|
+
if (needsWrapSpreadImport) coreImports.push("_wrapSpread");
|
|
1230
|
+
output = `import { ${coreImports.join(", ")} } from "@pyreon/core";\n` + output;
|
|
1231
|
+
}
|
|
1232
|
+
return collectLens ? {
|
|
1233
|
+
code: output,
|
|
1234
|
+
usesTemplates: needsTplImport,
|
|
1235
|
+
warnings,
|
|
1236
|
+
reactivityLens
|
|
1237
|
+
} : {
|
|
722
1238
|
code: output,
|
|
723
1239
|
usesTemplates: needsTplImport,
|
|
724
1240
|
warnings
|
|
@@ -855,6 +1371,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
855
1371
|
bindLines.push(attrSetter(htmlAttrName, varName, expr));
|
|
856
1372
|
return;
|
|
857
1373
|
}
|
|
1374
|
+
lens(exprNode.start, exprNode.end, "reactive-attr", `live attribute — \`${htmlAttrName}\` re-applies whenever its signals change`);
|
|
858
1375
|
const directRef = tryDirectSignalRef(exprNode);
|
|
859
1376
|
if (directRef) {
|
|
860
1377
|
needsBindDirectImport = true;
|
|
@@ -1015,7 +1532,12 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
1015
1532
|
bindLines.push(`const ${d} = _mountSlot(${expr}, ${parentRef}, ${placeholder})`);
|
|
1016
1533
|
return "<!>";
|
|
1017
1534
|
}
|
|
1018
|
-
|
|
1535
|
+
const cx = child.expression;
|
|
1536
|
+
if (isReactive) {
|
|
1537
|
+
lens(cx.start, cx.end, "reactive", "live — this text re-renders whenever its signals change");
|
|
1538
|
+
return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder);
|
|
1539
|
+
}
|
|
1540
|
+
lens(cx.start, cx.end, "static-text", "baked once into the DOM — never re-renders (no signal read here)");
|
|
1019
1541
|
return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder);
|
|
1020
1542
|
}
|
|
1021
1543
|
function processChildren(el, varName, accessor) {
|
|
@@ -1318,1266 +1840,1511 @@ function isPureStaticCall(node) {
|
|
|
1318
1840
|
}
|
|
1319
1841
|
|
|
1320
1842
|
//#endregion
|
|
1321
|
-
//#region src/
|
|
1843
|
+
//#region src/pyreon-intercept.ts
|
|
1322
1844
|
/**
|
|
1323
|
-
*
|
|
1845
|
+
* Pyreon Pattern Interceptor — detects Pyreon-specific anti-patterns in
|
|
1846
|
+
* code that has ALREADY committed to the framework (imports are Pyreon,
|
|
1847
|
+
* not React). Complements `react-intercept.ts` — the React detector
|
|
1848
|
+
* catches "coming from React" mistakes; this one catches "using Pyreon
|
|
1849
|
+
* wrong" mistakes.
|
|
1850
|
+
*
|
|
1851
|
+
* Catalog of detected patterns (grounded in `.claude/rules/anti-patterns.md`):
|
|
1852
|
+
*
|
|
1853
|
+
* - `for-missing-by` — `<For each={...}>` without a `by` prop
|
|
1854
|
+
* - `for-with-key` — `<For key={...}>` (JSX reserves `key`; the keying
|
|
1855
|
+
* prop is `by` in Pyreon)
|
|
1856
|
+
* - `props-destructured` — `({ foo }: Props) => <JSX />` destructures at
|
|
1857
|
+
* the component signature; reading is captured once
|
|
1858
|
+
* and loses reactivity. Access `props.foo` instead
|
|
1859
|
+
* or use `splitProps(props, [...])`.
|
|
1860
|
+
* - `props-destructured-body` — `const { foo } = props` written
|
|
1861
|
+
* SYNCHRONOUSLY in a component body — the body-scope
|
|
1862
|
+
* companion to `props-destructured`. Same capture-
|
|
1863
|
+
* once death; nested-function destructures (handler
|
|
1864
|
+
* / effect / returned accessor) are NOT flagged
|
|
1865
|
+
* (they re-read `props` per invocation).
|
|
1866
|
+
* - `process-dev-gate` — `typeof process !== 'undefined' &&
|
|
1867
|
+
* process.env.NODE_ENV !== 'production'` is dead
|
|
1868
|
+
* code in real Vite browser bundles. Use
|
|
1869
|
+
* `import.meta.env?.DEV` instead.
|
|
1870
|
+
* - `empty-theme` — `.theme({})` chain is a no-op; remove it.
|
|
1871
|
+
* - `raw-add-event-listener` — raw `addEventListener(...)` in a component
|
|
1872
|
+
* or hook body. Use `useEventListener(...)` from
|
|
1873
|
+
* `@pyreon/hooks` for auto-cleanup.
|
|
1874
|
+
* - `raw-remove-event-listener` — same, for removeEventListener.
|
|
1875
|
+
* - `date-math-random-id` — `Date.now() + Math.random()` / template-concat
|
|
1876
|
+
* variants. Under rapid operations (paste, clone)
|
|
1877
|
+
* collision probability is non-trivial. Use a
|
|
1878
|
+
* monotonic counter.
|
|
1879
|
+
* - `on-click-undefined` — `onClick={undefined}` explicitly; the runtime
|
|
1880
|
+
* used to crash on this pattern. Omit the prop.
|
|
1881
|
+
* - `signal-write-as-call` — `sig(value)` is a no-op read that ignores
|
|
1882
|
+
* its argument; the runtime warns in dev. Static
|
|
1883
|
+
* detector spots it pre-runtime when `sig` was
|
|
1884
|
+
* declared as `const sig = signal(...)` /
|
|
1885
|
+
* `computed(...)` and called with ≥1 argument.
|
|
1886
|
+
* - `static-return-null-conditional` — `if (cond) return null` at the
|
|
1887
|
+
* top of a component body runs ONCE; signal changes
|
|
1888
|
+
* in `cond` never re-evaluate the early-return.
|
|
1889
|
+
* Wrap in a returned reactive accessor.
|
|
1890
|
+
* - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
|
|
1891
|
+
* cast on JSX returns is unnecessary (`JSX.Element`
|
|
1892
|
+
* is already assignable to `VNodeChild`).
|
|
1893
|
+
* - `island-never-with-registry-entry` — an `island()` declared with
|
|
1894
|
+
* `hydrate: 'never'` is also registered in the same
|
|
1895
|
+
* file's `hydrateIslands({ ... })` call. The whole
|
|
1896
|
+
* point of `'never'` is shipping zero client JS;
|
|
1897
|
+
* registering pulls the component module into the
|
|
1898
|
+
* client bundle graph (the runtime short-circuits
|
|
1899
|
+
* and never calls the loader, but the bundler still
|
|
1900
|
+
* includes the import). Drop the registry entry.
|
|
1901
|
+
*
|
|
1902
|
+
* Two-mode surface mirrors `react-intercept.ts`:
|
|
1903
|
+
* - `detectPyreonPatterns(code)` — diagnostics only
|
|
1904
|
+
* - `hasPyreonPatterns(code)` — fast regex pre-filter
|
|
1905
|
+
*
|
|
1906
|
+
* ## fixable: false (invariant)
|
|
1907
|
+
*
|
|
1908
|
+
* Every Pyreon diagnostic reports `fixable: false` — no exceptions.
|
|
1909
|
+
* The `migrate_react` MCP tool only knows React mappings, so claiming
|
|
1910
|
+
* a Pyreon code is auto-fixable would mislead a consumer who wires
|
|
1911
|
+
* their UX off the flag and finds nothing applies the fix. Flip to
|
|
1912
|
+
* `true` ONLY when a companion `migrate_pyreon` tool ships in a
|
|
1913
|
+
* subsequent PR. The invariant is locked in
|
|
1914
|
+
* `tests/pyreon-intercept.test.ts` under "fixable contract".
|
|
1915
|
+
*
|
|
1916
|
+
* Designed for three consumers:
|
|
1917
|
+
* 1. Compiler pre-pass warnings during build
|
|
1918
|
+
* 2. CLI `pyreon doctor`
|
|
1919
|
+
* 3. MCP server `validate` tool
|
|
1324
1920
|
*/
|
|
1325
|
-
function
|
|
1326
|
-
|
|
1327
|
-
return {
|
|
1328
|
-
framework: "pyreon",
|
|
1329
|
-
version: readVersion(cwd),
|
|
1330
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1331
|
-
routes: extractRoutes(files, cwd),
|
|
1332
|
-
components: extractComponents(files, cwd),
|
|
1333
|
-
islands: extractIslands(files, cwd)
|
|
1334
|
-
};
|
|
1921
|
+
function getNodeText(ctx, node) {
|
|
1922
|
+
return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
|
|
1335
1923
|
}
|
|
1336
|
-
function
|
|
1337
|
-
const
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
"lib",
|
|
1348
|
-
".pyreon",
|
|
1349
|
-
".git",
|
|
1350
|
-
"build"
|
|
1351
|
-
]);
|
|
1352
|
-
function walk(dir) {
|
|
1353
|
-
let entries;
|
|
1354
|
-
try {
|
|
1355
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1356
|
-
} catch {
|
|
1357
|
-
return;
|
|
1358
|
-
}
|
|
1359
|
-
for (const entry of entries) {
|
|
1360
|
-
if (entry.name.startsWith(".") && entry.isDirectory()) continue;
|
|
1361
|
-
if (ignoreDirs.has(entry.name) && entry.isDirectory()) continue;
|
|
1362
|
-
const fullPath = path.join(dir, entry.name);
|
|
1363
|
-
if (entry.isDirectory()) walk(fullPath);
|
|
1364
|
-
else if (entry.isFile() && extensions.has(path.extname(entry.name))) results.push(fullPath);
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
walk(cwd);
|
|
1368
|
-
return results;
|
|
1924
|
+
function pushDiag(ctx, node, code, message, current, suggested, fixable) {
|
|
1925
|
+
const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf));
|
|
1926
|
+
ctx.diagnostics.push({
|
|
1927
|
+
code,
|
|
1928
|
+
message,
|
|
1929
|
+
line: line + 1,
|
|
1930
|
+
column: character,
|
|
1931
|
+
current: current.trim(),
|
|
1932
|
+
suggested: suggested.trim(),
|
|
1933
|
+
fixable
|
|
1934
|
+
});
|
|
1369
1935
|
}
|
|
1370
|
-
function
|
|
1371
|
-
const
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
hasLoader: /loader\s*:/.test(surrounding),
|
|
1394
|
-
hasGuard: /beforeEnter\s*:|beforeLeave\s*:/.test(surrounding),
|
|
1395
|
-
params: extractParams(routePath)
|
|
1396
|
-
});
|
|
1397
|
-
}
|
|
1936
|
+
function getJsxTagName$1(node) {
|
|
1937
|
+
const t = node.tagName;
|
|
1938
|
+
if (ts.isIdentifier(t)) return t.text;
|
|
1939
|
+
return "";
|
|
1940
|
+
}
|
|
1941
|
+
function findJsxAttribute(node, name) {
|
|
1942
|
+
for (const attr of node.attributes.properties) if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === name) return attr;
|
|
1943
|
+
}
|
|
1944
|
+
function detectForKeying(ctx, node) {
|
|
1945
|
+
if (getJsxTagName$1(node) !== "For") return;
|
|
1946
|
+
const keyAttr = findJsxAttribute(node, "key");
|
|
1947
|
+
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);
|
|
1948
|
+
const eachAttr = findJsxAttribute(node, "each");
|
|
1949
|
+
const byAttr = findJsxAttribute(node, "by");
|
|
1950
|
+
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);
|
|
1951
|
+
}
|
|
1952
|
+
function containsJsx(node) {
|
|
1953
|
+
let found = false;
|
|
1954
|
+
function walk(n) {
|
|
1955
|
+
if (found) return;
|
|
1956
|
+
if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n) || ts.isJsxOpeningElement(n)) {
|
|
1957
|
+
found = true;
|
|
1958
|
+
return;
|
|
1398
1959
|
}
|
|
1960
|
+
ts.forEachChild(n, walk);
|
|
1399
1961
|
}
|
|
1400
|
-
|
|
1962
|
+
ts.forEachChild(node, walk);
|
|
1963
|
+
if (!found) {
|
|
1964
|
+
if (ts.isArrowFunction(node) && !ts.isBlock(node.body) && (ts.isJsxElement(node.body) || ts.isJsxSelfClosingElement(node.body) || ts.isJsxFragment(node.body))) found = true;
|
|
1965
|
+
}
|
|
1966
|
+
return found;
|
|
1401
1967
|
}
|
|
1402
|
-
function
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
file: path.relative(cwd, file),
|
|
1425
|
-
hasSignals: signalNames.length > 0,
|
|
1426
|
-
signalNames,
|
|
1427
|
-
props
|
|
1428
|
-
});
|
|
1429
|
-
}
|
|
1968
|
+
function detectPropsDestructured(ctx, node) {
|
|
1969
|
+
if (!node.parameters.length) return;
|
|
1970
|
+
const first = node.parameters[0];
|
|
1971
|
+
if (!first || !ts.isObjectBindingPattern(first.name)) return;
|
|
1972
|
+
if (first.name.elements.length === 0) return;
|
|
1973
|
+
if (!containsJsx(node)) return;
|
|
1974
|
+
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);
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Strip the wrappers that can sit between `=` and the props identifier
|
|
1978
|
+
* (`const { x } = (props as Props)!`) so we can compare the base
|
|
1979
|
+
* expression's identity to the component's first-parameter name.
|
|
1980
|
+
*/
|
|
1981
|
+
function unwrapInitializer(expr) {
|
|
1982
|
+
let cur = expr;
|
|
1983
|
+
let prev;
|
|
1984
|
+
while (cur !== prev) {
|
|
1985
|
+
prev = cur;
|
|
1986
|
+
if (ts.isParenthesizedExpression(cur)) cur = cur.expression;
|
|
1987
|
+
else if (ts.isAsExpression(cur)) cur = cur.expression;
|
|
1988
|
+
else if (ts.isSatisfiesExpression(cur)) cur = cur.expression;
|
|
1989
|
+
else if (ts.isNonNullExpression(cur)) cur = cur.expression;
|
|
1430
1990
|
}
|
|
1431
|
-
return
|
|
1991
|
+
return cur;
|
|
1432
1992
|
}
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1993
|
+
/**
|
|
1994
|
+
* Body-scope companion to {@link detectPropsDestructured}. Flags
|
|
1995
|
+
* `const { x } = props` (also `let` / `var`, aliases, defaults, rest,
|
|
1996
|
+
* nested patterns) written SYNCHRONOUSLY in a component's body.
|
|
1997
|
+
*
|
|
1998
|
+
* Why this is the footgun: the compiler emits `<C prop={sig()} />` as a
|
|
1999
|
+
* getter-shaped reactive prop. `const { x } = props` fires that getter
|
|
2000
|
+
* exactly ONCE at setup — `x` is a dead snapshot, never re-reads when
|
|
2001
|
+
* the signal changes. `props.x` (live member access inside a tracking
|
|
2002
|
+
* scope) or `splitProps(props, ['x'])` preserve the subscription.
|
|
2003
|
+
*
|
|
2004
|
+
* Precision (zero false positives is the priority — a missed body-scope
|
|
2005
|
+
* destructure is acceptable, a wrong one is not):
|
|
2006
|
+
* - Only PascalCase, JSX-rendering functions (`isComponentShapedFunction`
|
|
2007
|
+
* + `containsJsx`) — a plain helper that happens to destructure an
|
|
2008
|
+
* options bag named `props` is NOT a component and is left alone.
|
|
2009
|
+
* - The initializer must be the bare first-parameter identifier
|
|
2010
|
+
* (`= props`), unwrapped through paren / `as` / `satisfies` / `!`.
|
|
2011
|
+
* `const { x } = props.nested` and `= someOtherObject` are NOT
|
|
2012
|
+
* flagged (rarer shapes; out of the canonical scope).
|
|
2013
|
+
* - The destructure must be at the component-body top scope. A nested
|
|
2014
|
+
* function boundary (`onClick` handler, `effect(() => …)`, a returned
|
|
2015
|
+
* reactive accessor) re-reads `props` on each invocation, so those
|
|
2016
|
+
* destructures are reactivity-correct — the walk does NOT descend
|
|
2017
|
+
* into nested functions.
|
|
2018
|
+
* - The first parameter must itself be a plain identifier; the
|
|
2019
|
+
* parameter-destructure shape (`({ x }) => …`) is the existing
|
|
2020
|
+
* `detectPropsDestructured`'s job, not this one.
|
|
2021
|
+
*/
|
|
2022
|
+
function detectPropsDestructuredBody(ctx, node) {
|
|
2023
|
+
if (!isComponentShapedFunction(node)) return;
|
|
2024
|
+
if (!containsJsx(node)) return;
|
|
2025
|
+
if (!node.parameters.length) return;
|
|
2026
|
+
const first = node.parameters[0];
|
|
2027
|
+
if (!first || !ts.isIdentifier(first.name)) return;
|
|
2028
|
+
const paramName = first.name.text;
|
|
2029
|
+
const body = node.body;
|
|
2030
|
+
if (!body || !ts.isBlock(body)) return;
|
|
2031
|
+
function walk(n) {
|
|
2032
|
+
if (ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n) || ts.isMethodDeclaration(n) || ts.isGetAccessorDeclaration(n) || ts.isSetAccessorDeclaration(n)) return;
|
|
2033
|
+
if (ts.isVariableDeclaration(n) && ts.isObjectBindingPattern(n.name) && n.name.elements.length > 0 && n.initializer) {
|
|
2034
|
+
const base = unwrapInitializer(n.initializer);
|
|
2035
|
+
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);
|
|
1441
2036
|
}
|
|
1442
|
-
|
|
1443
|
-
let match;
|
|
1444
|
-
for (match = islandRe.exec(code); match; match = islandRe.exec(code)) if (match[1]) islands.push({
|
|
1445
|
-
name: match[1],
|
|
1446
|
-
file: path.relative(cwd, file),
|
|
1447
|
-
hydrate: match[2] ?? "load"
|
|
1448
|
-
});
|
|
2037
|
+
ts.forEachChild(n, walk);
|
|
1449
2038
|
}
|
|
1450
|
-
|
|
2039
|
+
for (const stmt of body.statements) walk(stmt);
|
|
1451
2040
|
}
|
|
1452
|
-
function
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
return
|
|
2041
|
+
function isTypeofProcess(node) {
|
|
2042
|
+
if (!ts.isBinaryExpression(node)) return false;
|
|
2043
|
+
if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false;
|
|
2044
|
+
if (!ts.isTypeOfExpression(node.left)) return false;
|
|
2045
|
+
if (!ts.isIdentifier(node.left.expression) || node.left.expression.text !== "process") return false;
|
|
2046
|
+
return ts.isStringLiteral(node.right) && node.right.text === "undefined";
|
|
1458
2047
|
}
|
|
1459
|
-
function
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
2048
|
+
function isProcessNodeEnvProdGuard(node) {
|
|
2049
|
+
if (!ts.isBinaryExpression(node)) return false;
|
|
2050
|
+
if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false;
|
|
2051
|
+
const left = node.left;
|
|
2052
|
+
if (!ts.isPropertyAccessExpression(left)) return false;
|
|
2053
|
+
if (!ts.isIdentifier(left.name) || left.name.text !== "NODE_ENV") return false;
|
|
2054
|
+
if (!ts.isPropertyAccessExpression(left.expression)) return false;
|
|
2055
|
+
if (!ts.isIdentifier(left.expression.name) || left.expression.name.text !== "env") return false;
|
|
2056
|
+
if (!ts.isIdentifier(left.expression.expression)) return false;
|
|
2057
|
+
if (left.expression.expression.text !== "process") return false;
|
|
2058
|
+
return ts.isStringLiteral(node.right) && node.right.text === "production";
|
|
2059
|
+
}
|
|
2060
|
+
function detectProcessDevGate(ctx, node) {
|
|
2061
|
+
if (node.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) return;
|
|
2062
|
+
if (!(isTypeofProcess(node.left) && isProcessNodeEnvProdGuard(node.right) || isTypeofProcess(node.right) && isProcessNodeEnvProdGuard(node.left))) return;
|
|
2063
|
+
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);
|
|
2064
|
+
}
|
|
2065
|
+
function detectEmptyTheme(ctx, node) {
|
|
2066
|
+
const callee = node.expression;
|
|
2067
|
+
if (!ts.isPropertyAccessExpression(callee)) return;
|
|
2068
|
+
if (!ts.isIdentifier(callee.name) || callee.name.text !== "theme") return;
|
|
2069
|
+
if (node.arguments.length !== 1) return;
|
|
2070
|
+
const arg = node.arguments[0];
|
|
2071
|
+
if (!arg || !ts.isObjectLiteralExpression(arg)) return;
|
|
2072
|
+
if (arg.properties.length !== 0) return;
|
|
2073
|
+
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);
|
|
2074
|
+
}
|
|
2075
|
+
const QUERY_OPTS_HOOKS = new Set([
|
|
2076
|
+
"useQuery",
|
|
2077
|
+
"useInfiniteQuery",
|
|
2078
|
+
"useQueries",
|
|
2079
|
+
"useSuspenseQuery"
|
|
2080
|
+
]);
|
|
2081
|
+
function detectQueryOptionsAsFunction(ctx, node) {
|
|
2082
|
+
if (!ts.isIdentifier(node.expression)) return;
|
|
2083
|
+
const hook = node.expression.text;
|
|
2084
|
+
if (!QUERY_OPTS_HOOKS.has(hook)) return;
|
|
2085
|
+
const arg0 = node.arguments[0];
|
|
2086
|
+
if (!arg0 || !ts.isObjectLiteralExpression(arg0)) return;
|
|
2087
|
+
const objText = getNodeText(ctx, arg0);
|
|
2088
|
+
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);
|
|
2089
|
+
}
|
|
2090
|
+
function detectRawEventListener(ctx, node) {
|
|
2091
|
+
const callee = node.expression;
|
|
2092
|
+
if (!ts.isPropertyAccessExpression(callee)) return;
|
|
2093
|
+
if (!ts.isIdentifier(callee.name)) return;
|
|
2094
|
+
const method = callee.name.text;
|
|
2095
|
+
if (method !== "addEventListener" && method !== "removeEventListener") return;
|
|
2096
|
+
const target = callee.expression;
|
|
2097
|
+
const targetName = ts.isIdentifier(target) ? target.text : ts.isPropertyAccessExpression(target) && ts.isIdentifier(target.name) ? target.name.text : "";
|
|
2098
|
+
if (!new Set([
|
|
2099
|
+
"window",
|
|
2100
|
+
"document",
|
|
2101
|
+
"body",
|
|
2102
|
+
"el",
|
|
2103
|
+
"element",
|
|
2104
|
+
"node",
|
|
2105
|
+
"target"
|
|
2106
|
+
]).has(targetName)) return;
|
|
2107
|
+
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);
|
|
2108
|
+
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);
|
|
2109
|
+
}
|
|
2110
|
+
function isCallTo(node, object, method) {
|
|
2111
|
+
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;
|
|
2112
|
+
}
|
|
2113
|
+
function subtreeHas(node, predicate) {
|
|
2114
|
+
let found = false;
|
|
2115
|
+
function walk(n) {
|
|
2116
|
+
if (found) return;
|
|
2117
|
+
if (predicate(n)) {
|
|
2118
|
+
found = true;
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
ts.forEachChild(n, walk);
|
|
1470
2122
|
}
|
|
2123
|
+
walk(node);
|
|
2124
|
+
return found;
|
|
2125
|
+
}
|
|
2126
|
+
function detectDateMathRandomId(ctx, node) {
|
|
2127
|
+
if (!subtreeHas(node, (n) => isCallTo(n, "Date", "now"))) return;
|
|
2128
|
+
if (!subtreeHas(node, (n) => isCallTo(n, "Math", "random"))) return;
|
|
2129
|
+
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);
|
|
2130
|
+
}
|
|
2131
|
+
function detectOnClickUndefined(ctx, node) {
|
|
2132
|
+
if (!ts.isIdentifier(node.name)) return;
|
|
2133
|
+
const attrName = node.name.text;
|
|
2134
|
+
if (!attrName.startsWith("on") || attrName.length < 3) return;
|
|
2135
|
+
if (!node.initializer || !ts.isJsxExpression(node.initializer)) return;
|
|
2136
|
+
const expr = node.initializer.expression;
|
|
2137
|
+
if (!expr) return;
|
|
2138
|
+
if (!(ts.isIdentifier(expr) && expr.text === "undefined" || expr.kind === ts.SyntaxKind.VoidExpression)) return;
|
|
2139
|
+
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);
|
|
1471
2140
|
}
|
|
1472
|
-
|
|
1473
|
-
//#endregion
|
|
1474
|
-
//#region src/react-intercept.ts
|
|
1475
2141
|
/**
|
|
1476
|
-
*
|
|
1477
|
-
*
|
|
1478
|
-
*
|
|
1479
|
-
*
|
|
1480
|
-
* - `detectReactPatterns(code)` — returns diagnostics only (non-destructive)
|
|
1481
|
-
* - `migrateReactCode(code)` — applies auto-fixes and returns transformed code
|
|
2142
|
+
* Walks the file and collects every identifier bound to a `signal(...)` or
|
|
2143
|
+
* `computed(...)` call. Only `const` declarations are tracked — `let`/`var`
|
|
2144
|
+
* may be reassigned to non-signal values, so a use-site call wouldn't be a
|
|
2145
|
+
* reliable signal-write.
|
|
1482
2146
|
*
|
|
1483
|
-
*
|
|
1484
|
-
*
|
|
1485
|
-
*
|
|
1486
|
-
*
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
/** React specifiers that map to specific Pyreon imports */
|
|
1498
|
-
const SPECIFIER_REWRITES = {
|
|
1499
|
-
useState: {
|
|
1500
|
-
name: "signal",
|
|
1501
|
-
from: "@pyreon/reactivity"
|
|
1502
|
-
},
|
|
1503
|
-
useEffect: {
|
|
1504
|
-
name: "effect",
|
|
1505
|
-
from: "@pyreon/reactivity"
|
|
1506
|
-
},
|
|
1507
|
-
useLayoutEffect: {
|
|
1508
|
-
name: "effect",
|
|
1509
|
-
from: "@pyreon/reactivity"
|
|
1510
|
-
},
|
|
1511
|
-
useMemo: {
|
|
1512
|
-
name: "computed",
|
|
1513
|
-
from: "@pyreon/reactivity"
|
|
1514
|
-
},
|
|
1515
|
-
useReducer: {
|
|
1516
|
-
name: "signal",
|
|
1517
|
-
from: "@pyreon/reactivity"
|
|
1518
|
-
},
|
|
1519
|
-
useRef: {
|
|
1520
|
-
name: "signal",
|
|
1521
|
-
from: "@pyreon/reactivity"
|
|
1522
|
-
},
|
|
1523
|
-
createContext: {
|
|
1524
|
-
name: "createContext",
|
|
1525
|
-
from: "@pyreon/core"
|
|
1526
|
-
},
|
|
1527
|
-
useContext: {
|
|
1528
|
-
name: "useContext",
|
|
1529
|
-
from: "@pyreon/core"
|
|
1530
|
-
},
|
|
1531
|
-
Fragment: {
|
|
1532
|
-
name: "Fragment",
|
|
1533
|
-
from: "@pyreon/core"
|
|
1534
|
-
},
|
|
1535
|
-
Suspense: {
|
|
1536
|
-
name: "Suspense",
|
|
1537
|
-
from: "@pyreon/core"
|
|
1538
|
-
},
|
|
1539
|
-
lazy: {
|
|
1540
|
-
name: "lazy",
|
|
1541
|
-
from: "@pyreon/core"
|
|
1542
|
-
},
|
|
1543
|
-
memo: {
|
|
1544
|
-
name: "",
|
|
1545
|
-
from: ""
|
|
1546
|
-
},
|
|
1547
|
-
forwardRef: {
|
|
1548
|
-
name: "",
|
|
1549
|
-
from: ""
|
|
1550
|
-
},
|
|
1551
|
-
createRoot: {
|
|
1552
|
-
name: "mount",
|
|
1553
|
-
from: "@pyreon/runtime-dom"
|
|
1554
|
-
},
|
|
1555
|
-
hydrateRoot: {
|
|
1556
|
-
name: "hydrateRoot",
|
|
1557
|
-
from: "@pyreon/runtime-dom"
|
|
1558
|
-
},
|
|
1559
|
-
useNavigate: {
|
|
1560
|
-
name: "useRouter",
|
|
1561
|
-
from: "@pyreon/router"
|
|
1562
|
-
},
|
|
1563
|
-
useParams: {
|
|
1564
|
-
name: "useRoute",
|
|
1565
|
-
from: "@pyreon/router"
|
|
1566
|
-
},
|
|
1567
|
-
useLocation: {
|
|
1568
|
-
name: "useRoute",
|
|
1569
|
-
from: "@pyreon/router"
|
|
1570
|
-
},
|
|
1571
|
-
Link: {
|
|
1572
|
-
name: "RouterLink",
|
|
1573
|
-
from: "@pyreon/router"
|
|
1574
|
-
},
|
|
1575
|
-
NavLink: {
|
|
1576
|
-
name: "RouterLink",
|
|
1577
|
-
from: "@pyreon/router"
|
|
1578
|
-
},
|
|
1579
|
-
Outlet: {
|
|
1580
|
-
name: "RouterView",
|
|
1581
|
-
from: "@pyreon/router"
|
|
1582
|
-
},
|
|
1583
|
-
useSearchParams: {
|
|
1584
|
-
name: "useSearchParams",
|
|
1585
|
-
from: "@pyreon/router"
|
|
1586
|
-
}
|
|
1587
|
-
};
|
|
1588
|
-
/** JSX attribute rewrites (React → standard HTML) */
|
|
1589
|
-
const JSX_ATTR_REWRITES = {
|
|
1590
|
-
className: "class",
|
|
1591
|
-
htmlFor: "for"
|
|
1592
|
-
};
|
|
1593
|
-
function detectGetNodeText(ctx, node) {
|
|
1594
|
-
return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
|
|
1595
|
-
}
|
|
1596
|
-
function detectDiag(ctx, node, diagCode, message, current, suggested, fixable) {
|
|
1597
|
-
const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf));
|
|
1598
|
-
ctx.diagnostics.push({
|
|
1599
|
-
code: diagCode,
|
|
1600
|
-
message,
|
|
1601
|
-
line: line + 1,
|
|
1602
|
-
column: character,
|
|
1603
|
-
current: current.trim(),
|
|
1604
|
-
suggested: suggested.trim(),
|
|
1605
|
-
fixable
|
|
1606
|
-
});
|
|
1607
|
-
}
|
|
1608
|
-
function detectImportDeclaration(ctx, node) {
|
|
1609
|
-
if (!node.moduleSpecifier) return;
|
|
1610
|
-
const source = node.moduleSpecifier.text;
|
|
1611
|
-
const pyreonSource = IMPORT_REWRITES[source];
|
|
1612
|
-
if (pyreonSource !== void 0) {
|
|
1613
|
-
if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) for (const spec of node.importClause.namedBindings.elements) ctx.reactImportedHooks.add(spec.name.text);
|
|
1614
|
-
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);
|
|
1615
|
-
}
|
|
1616
|
-
}
|
|
1617
|
-
function detectUseState(ctx, node) {
|
|
1618
|
-
const parent = node.parent;
|
|
1619
|
-
if (ts.isVariableDeclaration(parent) && parent.name && ts.isArrayBindingPattern(parent.name) && parent.name.elements.length >= 1) {
|
|
1620
|
-
const firstEl = parent.name.elements[0];
|
|
1621
|
-
const valueName = firstEl && ts.isBindingElement(firstEl) ? firstEl.name.text : "value";
|
|
1622
|
-
const initArg = node.arguments[0] ? detectGetNodeText(ctx, node.arguments[0]) : "undefined";
|
|
1623
|
-
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);
|
|
1624
|
-
} else detectDiag(ctx, node, "use-state", "useState is a React API. In Pyreon, use signal().", detectGetNodeText(ctx, node), "signal(initialValue)", true);
|
|
1625
|
-
}
|
|
1626
|
-
function callbackHasCleanup(callbackArg) {
|
|
1627
|
-
if (!ts.isArrowFunction(callbackArg) && !ts.isFunctionExpression(callbackArg)) return false;
|
|
1628
|
-
const body = callbackArg.body;
|
|
1629
|
-
if (!ts.isBlock(body)) return false;
|
|
1630
|
-
for (const stmt of body.statements) if (ts.isReturnStatement(stmt) && stmt.expression) return true;
|
|
1631
|
-
return false;
|
|
1632
|
-
}
|
|
1633
|
-
function detectUseEffect(ctx, node) {
|
|
1634
|
-
const hookName = node.expression.text;
|
|
1635
|
-
const depsArg = node.arguments[1];
|
|
1636
|
-
const callbackArg = node.arguments[0];
|
|
1637
|
-
if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0) {
|
|
1638
|
-
const hasCleanup = callbackArg ? callbackHasCleanup(callbackArg) : false;
|
|
1639
|
-
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);
|
|
1640
|
-
} 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);
|
|
1641
|
-
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);
|
|
1642
|
-
}
|
|
1643
|
-
function detectUseMemo(ctx, node) {
|
|
1644
|
-
const computeFn = node.arguments[0];
|
|
1645
|
-
const computeText = computeFn ? detectGetNodeText(ctx, computeFn) : "() => value";
|
|
1646
|
-
detectDiag(ctx, node, "use-memo", "useMemo is a React API. In Pyreon, use computed() — dependencies auto-tracked.", detectGetNodeText(ctx, node), `computed(${computeText})`, true);
|
|
1647
|
-
}
|
|
1648
|
-
function detectUseCallback(ctx, node) {
|
|
1649
|
-
const callbackFn = node.arguments[0];
|
|
1650
|
-
const callbackText = callbackFn ? detectGetNodeText(ctx, callbackFn) : "() => {}";
|
|
1651
|
-
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);
|
|
1652
|
-
}
|
|
1653
|
-
function detectUseRef(ctx, node) {
|
|
1654
|
-
const arg = node.arguments[0];
|
|
1655
|
-
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);
|
|
1656
|
-
else {
|
|
1657
|
-
const initText = arg ? detectGetNodeText(ctx, arg) : "undefined";
|
|
1658
|
-
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);
|
|
2147
|
+
* The collection is intentionally scope-blind: a name shadowed in a nested
|
|
2148
|
+
* scope (`const x = signal(0); function f() { const x = 5; x(7) }`) would
|
|
2149
|
+
* produce a false positive on `x(7)`. That tradeoff is acceptable because
|
|
2150
|
+
* (1) shadowing a signal name with a non-signal is itself unusual and
|
|
2151
|
+
* (2) the detector message points at exactly the wrong-shape call so a
|
|
2152
|
+
* human reviewer can dismiss the rare false positive in seconds.
|
|
2153
|
+
*/
|
|
2154
|
+
function collectSignalBindings(sf) {
|
|
2155
|
+
const names = /* @__PURE__ */ new Set();
|
|
2156
|
+
function isSignalFactoryCall(init) {
|
|
2157
|
+
if (!init || !ts.isCallExpression(init)) return false;
|
|
2158
|
+
const callee = init.expression;
|
|
2159
|
+
if (!ts.isIdentifier(callee)) return false;
|
|
2160
|
+
return callee.text === "signal" || callee.text === "computed";
|
|
1659
2161
|
}
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
}
|
|
1667
|
-
function detectMemoWrapper(ctx, node) {
|
|
1668
|
-
const callee = node.expression;
|
|
1669
|
-
if (ts.isIdentifier(callee) && callee.text === "memo" || isCallToReactDot(callee, "memo")) {
|
|
1670
|
-
const inner = node.arguments[0];
|
|
1671
|
-
const innerText = inner ? detectGetNodeText(ctx, inner) : "Component";
|
|
1672
|
-
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);
|
|
2162
|
+
function walk(node) {
|
|
2163
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
2164
|
+
const list = node.parent;
|
|
2165
|
+
if (ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && isSignalFactoryCall(node.initializer)) names.add(node.name.text);
|
|
2166
|
+
}
|
|
2167
|
+
ts.forEachChild(node, walk);
|
|
1673
2168
|
}
|
|
2169
|
+
walk(sf);
|
|
2170
|
+
return names;
|
|
1674
2171
|
}
|
|
1675
|
-
function
|
|
2172
|
+
function detectSignalWriteAsCall(ctx, node) {
|
|
2173
|
+
if (ctx.signalBindings.size === 0) return;
|
|
1676
2174
|
const callee = node.expression;
|
|
1677
|
-
if (ts.isIdentifier(callee)
|
|
2175
|
+
if (!ts.isIdentifier(callee)) return;
|
|
2176
|
+
if (!ctx.signalBindings.has(callee.text)) return;
|
|
2177
|
+
if (node.arguments.length === 0) return;
|
|
2178
|
+
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);
|
|
1678
2179
|
}
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
2180
|
+
/**
|
|
2181
|
+
* `if (cond) return null` at the top of a component body runs ONCE — Pyreon
|
|
2182
|
+
* components mount and never re-execute their function bodies. A signal
|
|
2183
|
+
* change inside `cond` therefore never re-evaluates the condition; the
|
|
2184
|
+
* component is permanently stuck on whichever branch the first run picked.
|
|
2185
|
+
*
|
|
2186
|
+
* The fix is to wrap the conditional in a returned reactive accessor:
|
|
2187
|
+
* return (() => { if (!cond()) return null; return <div /> })
|
|
2188
|
+
*
|
|
2189
|
+
* Detection:
|
|
2190
|
+
* - The function contains JSX (i.e. it's a component)
|
|
2191
|
+
* - The function body has an `IfStatement` whose `thenStatement` is
|
|
2192
|
+
* `return null` (either bare `return null` or `{ return null }`)
|
|
2193
|
+
* - The `if` is at the function body's top level, NOT inside a returned
|
|
2194
|
+
* arrow / IIFE (those are reactive scopes — flagging them would be a
|
|
2195
|
+
* false positive)
|
|
2196
|
+
*/
|
|
2197
|
+
function returnsNullStatement(stmt) {
|
|
2198
|
+
if (ts.isReturnStatement(stmt)) {
|
|
2199
|
+
const expr = stmt.expression;
|
|
2200
|
+
return !!expr && expr.kind === ts.SyntaxKind.NullKeyword;
|
|
1691
2201
|
}
|
|
1692
|
-
if (
|
|
1693
|
-
|
|
1694
|
-
function detectDotValueSignal(ctx, node) {
|
|
1695
|
-
const varName = node.expression.text;
|
|
1696
|
-
const parent = node.parent;
|
|
1697
|
-
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);
|
|
2202
|
+
if (ts.isBlock(stmt)) return stmt.statements.length === 1 && returnsNullStatement(stmt.statements[0]);
|
|
2203
|
+
return false;
|
|
1698
2204
|
}
|
|
1699
|
-
|
|
2205
|
+
/**
|
|
2206
|
+
* Returns true if the function looks like a top-level component:
|
|
2207
|
+
* - `function PascalName(...) { ... }` (FunctionDeclaration with PascalCase id), OR
|
|
2208
|
+
* - `const PascalName = (...) => { ... }` (arrow inside a VariableDeclaration whose name is PascalCase).
|
|
2209
|
+
*
|
|
2210
|
+
* Anonymous nested arrows — most importantly the reactive accessor
|
|
2211
|
+
* `return (() => { if (!cond()) return null; return <div /> })` — are
|
|
2212
|
+
* NOT considered components here, even when they contain JSX. Without
|
|
2213
|
+
* this filter the detector would fire on the very pattern the
|
|
2214
|
+
* diagnostic recommends as the fix.
|
|
2215
|
+
*/
|
|
2216
|
+
function isComponentShapedFunction(node) {
|
|
2217
|
+
if (ts.isFunctionDeclaration(node)) return !!node.name && /^[A-Z]/.test(node.name.text);
|
|
1700
2218
|
const parent = node.parent;
|
|
1701
|
-
if (ts.
|
|
1702
|
-
|
|
1703
|
-
const mapCallback = node.arguments[0];
|
|
1704
|
-
const mapCallbackText = mapCallback ? detectGetNodeText(ctx, mapCallback) : "item => <li>{item}</li>";
|
|
1705
|
-
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);
|
|
1706
|
-
}
|
|
1707
|
-
}
|
|
1708
|
-
function isCallToHook(node, hookName) {
|
|
1709
|
-
return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === hookName;
|
|
1710
|
-
}
|
|
1711
|
-
function isCallToEffectHook(node) {
|
|
1712
|
-
return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (node.expression.text === "useEffect" || node.expression.text === "useLayoutEffect");
|
|
1713
|
-
}
|
|
1714
|
-
function isMapCallExpression(node) {
|
|
1715
|
-
return ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name) && node.expression.name.text === "map";
|
|
1716
|
-
}
|
|
1717
|
-
function isDotValueAccess(node) {
|
|
1718
|
-
return ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name) && node.name.text === "value" && ts.isIdentifier(node.expression);
|
|
1719
|
-
}
|
|
1720
|
-
function detectVisitNode(ctx, node) {
|
|
1721
|
-
if (ts.isImportDeclaration(node)) detectImportDeclaration(ctx, node);
|
|
1722
|
-
if (isCallToHook(node, "useState")) detectUseState(ctx, node);
|
|
1723
|
-
if (isCallToEffectHook(node)) detectUseEffect(ctx, node);
|
|
1724
|
-
if (isCallToHook(node, "useMemo")) detectUseMemo(ctx, node);
|
|
1725
|
-
if (isCallToHook(node, "useCallback")) detectUseCallback(ctx, node);
|
|
1726
|
-
if (isCallToHook(node, "useRef")) detectUseRef(ctx, node);
|
|
1727
|
-
if (isCallToHook(node, "useReducer")) detectUseReducer(ctx, node);
|
|
1728
|
-
if (ts.isCallExpression(node)) detectMemoWrapper(ctx, node);
|
|
1729
|
-
if (ts.isCallExpression(node)) detectForwardRef(ctx, node);
|
|
1730
|
-
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) detectJsxAttributes(ctx, node);
|
|
1731
|
-
if (isDotValueAccess(node)) detectDotValueSignal(ctx, node);
|
|
1732
|
-
if (isMapCallExpression(node)) detectArrayMapJsx(ctx, node);
|
|
1733
|
-
}
|
|
1734
|
-
function detectVisit(ctx, node) {
|
|
1735
|
-
ts.forEachChild(node, (child) => {
|
|
1736
|
-
detectVisitNode(ctx, child);
|
|
1737
|
-
detectVisit(ctx, child);
|
|
1738
|
-
});
|
|
1739
|
-
}
|
|
1740
|
-
function detectReactPatterns(code, filename = "input.tsx") {
|
|
1741
|
-
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
|
|
1742
|
-
const ctx = {
|
|
1743
|
-
sf,
|
|
1744
|
-
code,
|
|
1745
|
-
diagnostics: [],
|
|
1746
|
-
reactImportedHooks: /* @__PURE__ */ new Set()
|
|
1747
|
-
};
|
|
1748
|
-
detectVisit(ctx, sf);
|
|
1749
|
-
return ctx.diagnostics;
|
|
1750
|
-
}
|
|
1751
|
-
function migrateAddImport(ctx, source, specifier) {
|
|
1752
|
-
if (!source || !specifier) return;
|
|
1753
|
-
let specs = ctx.pyreonImports.get(source);
|
|
1754
|
-
if (!specs) {
|
|
1755
|
-
specs = /* @__PURE__ */ new Set();
|
|
1756
|
-
ctx.pyreonImports.set(source, specs);
|
|
1757
|
-
}
|
|
1758
|
-
specs.add(specifier);
|
|
1759
|
-
}
|
|
1760
|
-
function migrateReplace(ctx, node, text) {
|
|
1761
|
-
ctx.replacements.push({
|
|
1762
|
-
start: node.getStart(ctx.sf),
|
|
1763
|
-
end: node.getEnd(),
|
|
1764
|
-
text
|
|
1765
|
-
});
|
|
1766
|
-
}
|
|
1767
|
-
function migrateGetNodeText(ctx, node) {
|
|
1768
|
-
return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
|
|
1769
|
-
}
|
|
1770
|
-
function migrateGetLine(ctx, node) {
|
|
1771
|
-
return ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf)).line + 1;
|
|
2219
|
+
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return /^[A-Z]/.test(parent.name.text);
|
|
2220
|
+
return false;
|
|
1772
2221
|
}
|
|
1773
|
-
function
|
|
1774
|
-
if (!node
|
|
1775
|
-
if (!(node
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
}
|
|
2222
|
+
function detectStaticReturnNullConditional(ctx, node) {
|
|
2223
|
+
if (!isComponentShapedFunction(node)) return;
|
|
2224
|
+
if (!containsJsx(node)) return;
|
|
2225
|
+
const body = node.body;
|
|
2226
|
+
if (!body || !ts.isBlock(body)) return;
|
|
2227
|
+
for (const stmt of body.statements) {
|
|
2228
|
+
if (!ts.isIfStatement(stmt)) continue;
|
|
2229
|
+
if (!returnsNullStatement(stmt.thenStatement)) continue;
|
|
2230
|
+
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);
|
|
2231
|
+
return;
|
|
1782
2232
|
}
|
|
1783
|
-
ctx.importsToRemove.add(node);
|
|
1784
2233
|
}
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
line: migrateGetLine(ctx, node),
|
|
1802
|
-
description: `useState → signal: ${valueName}`
|
|
1803
|
-
});
|
|
1804
|
-
}
|
|
2234
|
+
/**
|
|
2235
|
+
* `JSX.Element` (which is what JSX evaluates to) is already assignable to
|
|
2236
|
+
* `VNodeChild`. The `as unknown as VNodeChild` double-cast is unnecessary
|
|
2237
|
+
* — it's been showing up in `@pyreon/ui-primitives` as a defensive habit
|
|
2238
|
+
* carried over from earlier framework versions. The cast is never load-
|
|
2239
|
+
* bearing today; removing it never changes runtime behavior. Pure cosmetic
|
|
2240
|
+
* but a useful proxy for non-idiomatic Pyreon code in primitives.
|
|
2241
|
+
*/
|
|
2242
|
+
function detectAsUnknownAsVNodeChild(ctx, node) {
|
|
2243
|
+
const outerType = node.type;
|
|
2244
|
+
if (!ts.isTypeReferenceNode(outerType)) return;
|
|
2245
|
+
if (!ts.isIdentifier(outerType.typeName) || outerType.typeName.text !== "VNodeChild") return;
|
|
2246
|
+
const inner = node.expression;
|
|
2247
|
+
if (!ts.isAsExpression(inner)) return;
|
|
2248
|
+
if (inner.type.kind !== ts.SyntaxKind.UnknownKeyword) return;
|
|
2249
|
+
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);
|
|
1805
2250
|
}
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
2251
|
+
/**
|
|
2252
|
+
* Pre-pass: walk the source for `island(loader, { name: 'X', hydrate: 'never' })`
|
|
2253
|
+
* call expressions and collect the `name` field of each never-strategy island.
|
|
2254
|
+
*
|
|
2255
|
+
* Recognized shape (mirrors `@pyreon/vite-plugin`'s `scanIslandDeclarations`):
|
|
2256
|
+
*
|
|
2257
|
+
* island(() => import('./X'), { name: 'X', hydrate: 'never' })
|
|
2258
|
+
*
|
|
2259
|
+
* Edge cases the AST-walker deliberately doesn't cover (unrecognized calls
|
|
2260
|
+
* fall through and don't populate the set — false-negatives, not false
|
|
2261
|
+
* positives):
|
|
2262
|
+
*
|
|
2263
|
+
* - Loader is a variable, not an inline arrow
|
|
2264
|
+
* - Name is a variable / template / spread, not a string literal
|
|
2265
|
+
* - Options come from a spread (`island(loader, opts)`)
|
|
2266
|
+
*
|
|
2267
|
+
* The same rules apply on the registry side (`detectIslandNeverWithRegistry`):
|
|
2268
|
+
* unrecognized keys won't match. Both halves are syntactic — a semantic
|
|
2269
|
+
* cross-package audit lives in `pyreon doctor --check-islands` (separate PR).
|
|
2270
|
+
*/
|
|
2271
|
+
function collectNeverIslandNames(sf) {
|
|
2272
|
+
const names = /* @__PURE__ */ new Set();
|
|
2273
|
+
function walk(node) {
|
|
2274
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "island" && node.arguments.length >= 2) {
|
|
2275
|
+
const opts = node.arguments[1];
|
|
2276
|
+
if (opts && ts.isObjectLiteralExpression(opts)) {
|
|
2277
|
+
let nameVal;
|
|
2278
|
+
let hydrateVal;
|
|
2279
|
+
for (const prop of opts.properties) {
|
|
2280
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
2281
|
+
const key = prop.name;
|
|
2282
|
+
const keyText = ts.isIdentifier(key) ? key.text : ts.isStringLiteral(key) ? key.text : "";
|
|
2283
|
+
if (keyText === "name" && ts.isStringLiteral(prop.initializer)) nameVal = prop.initializer.text;
|
|
2284
|
+
else if (keyText === "hydrate" && ts.isStringLiteral(prop.initializer)) hydrateVal = prop.initializer.text;
|
|
2285
|
+
}
|
|
2286
|
+
if (nameVal && hydrateVal === "never") names.add(nameVal);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
ts.forEachChild(node, walk);
|
|
1826
2290
|
}
|
|
2291
|
+
walk(sf);
|
|
2292
|
+
return names;
|
|
1827
2293
|
}
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
2294
|
+
/**
|
|
2295
|
+
* Flag entries in `hydrateIslands({ X: () => import('./X'), ... })` whose
|
|
2296
|
+
* key matches an `island()` name declared with `hydrate: 'never'` in the
|
|
2297
|
+
* same file. Each matching entry produces one diagnostic at the property's
|
|
2298
|
+
* location so the IDE highlights exactly which key needs to go.
|
|
2299
|
+
*/
|
|
2300
|
+
function detectIslandNeverWithRegistry(ctx, node) {
|
|
2301
|
+
if (ctx.neverIslandNames.size === 0) return;
|
|
2302
|
+
const callee = node.expression;
|
|
2303
|
+
if (!ts.isIdentifier(callee) || callee.text !== "hydrateIslands") return;
|
|
2304
|
+
const arg = node.arguments[0];
|
|
2305
|
+
if (!arg || !ts.isObjectLiteralExpression(arg)) return;
|
|
2306
|
+
for (const prop of arg.properties) {
|
|
2307
|
+
if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue;
|
|
2308
|
+
const key = prop.name;
|
|
2309
|
+
const keyText = ts.isIdentifier(key) ? key.text : ts.isStringLiteral(key) ? key.text : "";
|
|
2310
|
+
if (!keyText || !ctx.neverIslandNames.has(keyText)) continue;
|
|
2311
|
+
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);
|
|
1838
2312
|
}
|
|
1839
2313
|
}
|
|
1840
|
-
function
|
|
1841
|
-
|
|
1842
|
-
if (
|
|
1843
|
-
|
|
1844
|
-
ctx
|
|
1845
|
-
|
|
1846
|
-
line: migrateGetLine(ctx, node),
|
|
1847
|
-
description: "useCallback → plain function (not needed in Pyreon)"
|
|
1848
|
-
});
|
|
2314
|
+
function visitNode(ctx, node) {
|
|
2315
|
+
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) detectForKeying(ctx, node);
|
|
2316
|
+
if (ts.isArrowFunction(node) || ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) {
|
|
2317
|
+
detectPropsDestructured(ctx, node);
|
|
2318
|
+
detectPropsDestructuredBody(ctx, node);
|
|
2319
|
+
detectStaticReturnNullConditional(ctx, node);
|
|
1849
2320
|
}
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
if (arg && (arg.kind === ts.SyntaxKind.NullKeyword || ts.isIdentifier(arg) && arg.text === "undefined") || !arg) {
|
|
1854
|
-
migrateReplace(ctx, node, "createRef()");
|
|
1855
|
-
migrateAddImport(ctx, "@pyreon/core", "createRef");
|
|
1856
|
-
ctx.changes.push({
|
|
1857
|
-
type: "replace",
|
|
1858
|
-
line: migrateGetLine(ctx, node),
|
|
1859
|
-
description: "useRef(null) → createRef()"
|
|
1860
|
-
});
|
|
1861
|
-
} else {
|
|
1862
|
-
migrateReplace(ctx, node, `signal(${migrateGetNodeText(ctx, arg)})`);
|
|
1863
|
-
migrateAddImport(ctx, "@pyreon/reactivity", "signal");
|
|
1864
|
-
ctx.changes.push({
|
|
1865
|
-
type: "replace",
|
|
1866
|
-
line: migrateGetLine(ctx, node),
|
|
1867
|
-
description: "useRef(value) → signal(value)"
|
|
1868
|
-
});
|
|
2321
|
+
if (ts.isBinaryExpression(node)) {
|
|
2322
|
+
detectProcessDevGate(ctx, node);
|
|
2323
|
+
detectDateMathRandomId(ctx, node);
|
|
1869
2324
|
}
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
ctx
|
|
1876
|
-
|
|
1877
|
-
line: migrateGetLine(ctx, node),
|
|
1878
|
-
description: "Removed memo() wrapper (not needed in Pyreon)"
|
|
1879
|
-
});
|
|
2325
|
+
if (ts.isTemplateExpression(node)) detectDateMathRandomId(ctx, node);
|
|
2326
|
+
if (ts.isCallExpression(node)) {
|
|
2327
|
+
detectEmptyTheme(ctx, node);
|
|
2328
|
+
detectRawEventListener(ctx, node);
|
|
2329
|
+
detectSignalWriteAsCall(ctx, node);
|
|
2330
|
+
detectIslandNeverWithRegistry(ctx, node);
|
|
2331
|
+
detectQueryOptionsAsFunction(ctx, node);
|
|
1880
2332
|
}
|
|
2333
|
+
if (ts.isJsxAttribute(node)) detectOnClickUndefined(ctx, node);
|
|
2334
|
+
if (ts.isAsExpression(node)) detectAsUnknownAsVNodeChild(ctx, node);
|
|
1881
2335
|
}
|
|
1882
|
-
function
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
type: "remove",
|
|
1888
|
-
line: migrateGetLine(ctx, node),
|
|
1889
|
-
description: "Removed forwardRef wrapper (pass ref as normal prop in Pyreon)"
|
|
1890
|
-
});
|
|
1891
|
-
}
|
|
2336
|
+
function visit(ctx, node) {
|
|
2337
|
+
ts.forEachChild(node, (child) => {
|
|
2338
|
+
visitNode(ctx, child);
|
|
2339
|
+
visit(ctx, child);
|
|
2340
|
+
});
|
|
1892
2341
|
}
|
|
1893
|
-
function
|
|
1894
|
-
const
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
2342
|
+
function detectPyreonPatterns(code, filename = "input.tsx") {
|
|
2343
|
+
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
|
|
2344
|
+
const ctx = {
|
|
2345
|
+
sf,
|
|
2346
|
+
code,
|
|
2347
|
+
diagnostics: [],
|
|
2348
|
+
signalBindings: collectSignalBindings(sf),
|
|
2349
|
+
neverIslandNames: collectNeverIslandNames(sf)
|
|
2350
|
+
};
|
|
2351
|
+
visit(ctx, sf);
|
|
2352
|
+
ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
2353
|
+
return ctx.diagnostics;
|
|
2354
|
+
}
|
|
2355
|
+
/** Fast regex pre-filter — returns true if the code is worth a full AST walk. */
|
|
2356
|
+
function hasPyreonPatterns(code) {
|
|
2357
|
+
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);
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
//#endregion
|
|
2361
|
+
//#region src/reactivity-lens.ts
|
|
2362
|
+
/**
|
|
2363
|
+
* Reactivity Lens — surface the compiler's already-computed reactivity
|
|
2364
|
+
* analysis back to the author at the source.
|
|
2365
|
+
*
|
|
2366
|
+
* Pyreon's #1 silent footgun class: whether code is reactive is invisible at
|
|
2367
|
+
* the moment you write it. `const {x}=props` compiles fine, types fine,
|
|
2368
|
+
* renders once, and is dead. `<div>{x}</div>` where `x` isn't a signal bakes
|
|
2369
|
+
* once. The `@pyreon/compiler` ALREADY decides this per-expression (it has to,
|
|
2370
|
+
* for codegen) and then throws the analysis away. This module pipes it back.
|
|
2371
|
+
*
|
|
2372
|
+
* `analyzeReactivity()` is the single entry point. It returns a sorted list of
|
|
2373
|
+
* {@link ReactivityFinding}s built from TWO faithful sources, neither of which
|
|
2374
|
+
* is a fresh approximation:
|
|
2375
|
+
*
|
|
2376
|
+
* 1. **Compiler structural facts** — `TransformResult.reactivityLens`. Each
|
|
2377
|
+
* span is a *record* of a codegen decision (`_bind`/`_bindText`/`_rp`/
|
|
2378
|
+
* hoist/static-text). The positive "this is live" claim is the codegen
|
|
2379
|
+
* branch itself, so it is correct by construction (drift-gated).
|
|
2380
|
+
* 2. **Footgun negatives** — the existing `detectPyreonPatterns` AST
|
|
2381
|
+
* detectors (`props-destructured`, `signal-write-as-call`, …). Already
|
|
2382
|
+
* shipped, already AST-based; the lens just unifies them under one
|
|
2383
|
+
* editor-facing taxonomy.
|
|
2384
|
+
*
|
|
2385
|
+
* Absence of a finding is "not asserted", NEVER an implicit static claim —
|
|
2386
|
+
* see the asymmetric-precision commitment in `.claude/plans/reactivity-lens.md`.
|
|
2387
|
+
*
|
|
2388
|
+
* JS-backend only (Phase 1). The native Rust binary emits byte-identical
|
|
2389
|
+
* codegen (527 cross-backend equivalence tests), so the JS path is a sound
|
|
2390
|
+
* oracle for the analysis; Rust-path parity is Phase 3.
|
|
2391
|
+
*
|
|
2392
|
+
* @module
|
|
2393
|
+
*/
|
|
2394
|
+
function spanToFinding(s) {
|
|
2395
|
+
return {
|
|
2396
|
+
kind: s.kind,
|
|
2397
|
+
line: s.line,
|
|
2398
|
+
column: s.column,
|
|
2399
|
+
endLine: s.endLine,
|
|
2400
|
+
endColumn: s.endColumn,
|
|
2401
|
+
detail: s.detail
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
/**
|
|
2405
|
+
* Analyze a source file's reactivity. Pure, side-effect-free, deterministic.
|
|
2406
|
+
*
|
|
2407
|
+
* @param code Source text (`.tsx` / `.jsx` / `.ts`).
|
|
2408
|
+
* @param filename Used only for parse-mode (`tsx` vs `jsx`) detection.
|
|
2409
|
+
* @param options `knownSignals` is forwarded to the compiler so
|
|
2410
|
+
* cross-module imported signals are auto-call-aware.
|
|
2411
|
+
*
|
|
2412
|
+
* @example
|
|
2413
|
+
* const { findings } = analyzeReactivity(
|
|
2414
|
+
* `function C(){ const {x}=props; return <div>{count()}</div> }`,
|
|
2415
|
+
* )
|
|
2416
|
+
* // → footgun(props-destructured) on `{x}`, reactive on `count()`
|
|
2417
|
+
*/
|
|
2418
|
+
function analyzeReactivity(code, filename = "input.tsx", options = {}) {
|
|
2419
|
+
let spans = [];
|
|
2420
|
+
try {
|
|
2421
|
+
spans = transformJSX_JS(code, filename, {
|
|
2422
|
+
reactivityLens: true,
|
|
2423
|
+
...options.knownSignals ? { knownSignals: options.knownSignals } : {}
|
|
2424
|
+
}).reactivityLens ?? [];
|
|
2425
|
+
} catch {
|
|
2426
|
+
spans = [];
|
|
1907
2427
|
}
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
start: node.name.getStart(ctx.sf),
|
|
1915
|
-
end: node.name.getEnd(),
|
|
1916
|
-
text: "onInput"
|
|
1917
|
-
});
|
|
1918
|
-
ctx.changes.push({
|
|
1919
|
-
type: "replace",
|
|
1920
|
-
line: migrateGetLine(ctx, node),
|
|
1921
|
-
description: `onChange on <${tagName}> → onInput (native DOM events)`
|
|
1922
|
-
});
|
|
1923
|
-
}
|
|
1924
|
-
}
|
|
2428
|
+
const findings = spans.map(spanToFinding);
|
|
2429
|
+
let footguns = [];
|
|
2430
|
+
try {
|
|
2431
|
+
footguns = detectPyreonPatterns(code, filename);
|
|
2432
|
+
} catch {
|
|
2433
|
+
footguns = [];
|
|
1925
2434
|
}
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
line: migrateGetLine(ctx, node),
|
|
1938
|
-
description: "dangerouslySetInnerHTML → innerHTML"
|
|
2435
|
+
for (const d of footguns) {
|
|
2436
|
+
const firstLineLen = d.current.split("\n")[0]?.length ?? d.current.length;
|
|
2437
|
+
findings.push({
|
|
2438
|
+
kind: "footgun",
|
|
2439
|
+
line: d.line,
|
|
2440
|
+
column: d.column,
|
|
2441
|
+
endLine: d.line,
|
|
2442
|
+
endColumn: d.column + firstLineLen,
|
|
2443
|
+
detail: d.message,
|
|
2444
|
+
code: d.code,
|
|
2445
|
+
fixable: d.fixable
|
|
1939
2446
|
});
|
|
1940
2447
|
}
|
|
2448
|
+
findings.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
2449
|
+
return {
|
|
2450
|
+
findings,
|
|
2451
|
+
spans
|
|
2452
|
+
};
|
|
1941
2453
|
}
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
2454
|
+
const KIND_BADGE = {
|
|
2455
|
+
reactive: "◆ live",
|
|
2456
|
+
"reactive-prop": "◆ live prop",
|
|
2457
|
+
"reactive-attr": "◆ live attr",
|
|
2458
|
+
"static-text": "○ baked once",
|
|
2459
|
+
"hoisted-static": "○ hoisted static",
|
|
2460
|
+
footgun: "⚠ footgun"
|
|
2461
|
+
};
|
|
2462
|
+
/**
|
|
2463
|
+
* Render an annotated source view for CLI / debugging — every analyzed line
|
|
2464
|
+
* followed by its reactivity findings. Not the production surface (that's the
|
|
2465
|
+
* LSP inlay hints); this is the spike's "can you see reactivity flow" probe
|
|
2466
|
+
* and a stable diff target for tests.
|
|
2467
|
+
*/
|
|
2468
|
+
function formatReactivityLens(code, result) {
|
|
2469
|
+
const lines = code.split("\n");
|
|
2470
|
+
const byLine = /* @__PURE__ */ new Map();
|
|
2471
|
+
for (const f of result.findings) {
|
|
2472
|
+
const arr = byLine.get(f.line) ?? [];
|
|
2473
|
+
arr.push(f);
|
|
2474
|
+
byLine.set(f.line, arr);
|
|
1954
2475
|
}
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
const
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
2476
|
+
const out = [];
|
|
2477
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2478
|
+
const lineNo = i + 1;
|
|
2479
|
+
out.push(`${String(lineNo).padStart(4)} | ${lines[i]}`);
|
|
2480
|
+
const fs = byLine.get(lineNo);
|
|
2481
|
+
if (fs) for (const f of fs) {
|
|
2482
|
+
const pad = " ".repeat(7 + f.column);
|
|
2483
|
+
const tag = f.code ? ` [${f.code}]` : "";
|
|
2484
|
+
out.push(`${pad}^ ${KIND_BADGE[f.kind]}${tag} — ${f.detail}`);
|
|
1964
2485
|
}
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
2486
|
+
}
|
|
2487
|
+
return out.join("\n");
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
//#endregion
|
|
2491
|
+
//#region src/project-scanner.ts
|
|
2492
|
+
/**
|
|
2493
|
+
* Project scanner — extracts route, component, and island information from source files.
|
|
2494
|
+
*/
|
|
2495
|
+
function generateContext(cwd) {
|
|
2496
|
+
const files = collectSourceFiles(cwd);
|
|
2497
|
+
return {
|
|
2498
|
+
framework: "pyreon",
|
|
2499
|
+
version: readVersion(cwd),
|
|
2500
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2501
|
+
routes: extractRoutes(files, cwd),
|
|
2502
|
+
components: extractComponents(files, cwd),
|
|
2503
|
+
islands: extractIslands(files, cwd)
|
|
2504
|
+
};
|
|
2505
|
+
}
|
|
2506
|
+
function collectSourceFiles(cwd) {
|
|
2507
|
+
const results = [];
|
|
2508
|
+
const extensions = new Set([
|
|
2509
|
+
".tsx",
|
|
2510
|
+
".jsx",
|
|
2511
|
+
".ts",
|
|
2512
|
+
".js"
|
|
2513
|
+
]);
|
|
2514
|
+
const ignoreDirs = new Set([
|
|
2515
|
+
"node_modules",
|
|
2516
|
+
"dist",
|
|
2517
|
+
"lib",
|
|
2518
|
+
".pyreon",
|
|
2519
|
+
".git",
|
|
2520
|
+
"build"
|
|
2521
|
+
]);
|
|
2522
|
+
function walk(dir) {
|
|
2523
|
+
let entries;
|
|
2524
|
+
try {
|
|
2525
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2526
|
+
} catch {
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
for (const entry of entries) {
|
|
2530
|
+
if (entry.name.startsWith(".") && entry.isDirectory()) continue;
|
|
2531
|
+
if (ignoreDirs.has(entry.name) && entry.isDirectory()) continue;
|
|
2532
|
+
const fullPath = path.join(dir, entry.name);
|
|
2533
|
+
if (entry.isDirectory()) walk(fullPath);
|
|
2534
|
+
else if (entry.isFile() && extensions.has(path.extname(entry.name))) results.push(fullPath);
|
|
1968
2535
|
}
|
|
1969
2536
|
}
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
let lastPos = 0;
|
|
1973
|
-
for (const r of deduped) {
|
|
1974
|
-
parts.push(code.slice(lastPos, r.start));
|
|
1975
|
-
parts.push(r.text);
|
|
1976
|
-
lastPos = r.end;
|
|
1977
|
-
}
|
|
1978
|
-
parts.push(code.slice(lastPos));
|
|
1979
|
-
return parts.join("");
|
|
2537
|
+
walk(cwd);
|
|
2538
|
+
return results;
|
|
1980
2539
|
}
|
|
1981
|
-
function
|
|
1982
|
-
|
|
1983
|
-
const
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
2540
|
+
function extractRoutes(files, _cwd) {
|
|
2541
|
+
const routes = [];
|
|
2542
|
+
for (const file of files) {
|
|
2543
|
+
let code;
|
|
2544
|
+
try {
|
|
2545
|
+
code = fs.readFileSync(file, "utf-8");
|
|
2546
|
+
} catch {
|
|
2547
|
+
continue;
|
|
2548
|
+
}
|
|
2549
|
+
const routeArrayRe = /(?:createRouter\s*\(\s*\[|(?:const|let)\s+routes\s*(?::\s*RouteRecord\[\])?\s*=\s*\[)([\s\S]*?)\]/g;
|
|
2550
|
+
let match;
|
|
2551
|
+
for (match = routeArrayRe.exec(code); match; match = routeArrayRe.exec(code)) {
|
|
2552
|
+
const block = match[1] ?? "";
|
|
2553
|
+
const routeObjRe = /path\s*:\s*["']([^"']+)["']/g;
|
|
2554
|
+
let routeMatch;
|
|
2555
|
+
for (routeMatch = routeObjRe.exec(block); routeMatch; routeMatch = routeObjRe.exec(block)) {
|
|
2556
|
+
const routePath = routeMatch[1] ?? "";
|
|
2557
|
+
const surroundingStart = Math.max(0, routeMatch.index - 50);
|
|
2558
|
+
const surroundingEnd = Math.min(block.length, routeMatch.index + 200);
|
|
2559
|
+
const surrounding = block.slice(surroundingStart, surroundingEnd);
|
|
2560
|
+
routes.push({
|
|
2561
|
+
path: routePath,
|
|
2562
|
+
name: surrounding.match(/name\s*:\s*["']([^"']+)["']/)?.[1],
|
|
2563
|
+
hasLoader: /loader\s*:/.test(surrounding),
|
|
2564
|
+
hasGuard: /beforeEnter\s*:|beforeLeave\s*:/.test(surrounding),
|
|
2565
|
+
params: extractParams(routePath)
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
1988
2569
|
}
|
|
1989
|
-
|
|
1990
|
-
const lastImportEnd = findLastImportEnd(code);
|
|
1991
|
-
if (lastImportEnd > 0) return `${code.slice(0, lastImportEnd)}\n${importBlock}${code.slice(lastImportEnd)}`;
|
|
1992
|
-
return `${importBlock}\n\n${code}`;
|
|
1993
|
-
}
|
|
1994
|
-
function migrateVisitNode(ctx, node) {
|
|
1995
|
-
if (ts.isImportDeclaration(node)) migrateImportDeclaration(ctx, node);
|
|
1996
|
-
if (isCallToHook(node, "useState")) migrateUseState(ctx, node);
|
|
1997
|
-
if (isCallToEffectHook(node)) migrateUseEffect(ctx, node);
|
|
1998
|
-
if (isCallToHook(node, "useMemo")) migrateUseMemo(ctx, node);
|
|
1999
|
-
if (isCallToHook(node, "useCallback")) migrateUseCallback(ctx, node);
|
|
2000
|
-
if (isCallToHook(node, "useRef")) migrateUseRef(ctx, node);
|
|
2001
|
-
if (ts.isCallExpression(node)) migrateMemoWrapper(ctx, node);
|
|
2002
|
-
if (ts.isCallExpression(node)) migrateForwardRef(ctx, node);
|
|
2003
|
-
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) migrateJsxAttributes(ctx, node);
|
|
2004
|
-
}
|
|
2005
|
-
function migrateVisit(ctx, node) {
|
|
2006
|
-
ts.forEachChild(node, (child) => {
|
|
2007
|
-
migrateVisitNode(ctx, child);
|
|
2008
|
-
migrateVisit(ctx, child);
|
|
2009
|
-
});
|
|
2010
|
-
}
|
|
2011
|
-
function migrateReactCode(code, filename = "input.tsx") {
|
|
2012
|
-
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
|
|
2013
|
-
const diagnostics = detectReactPatterns(code, filename);
|
|
2014
|
-
const ctx = {
|
|
2015
|
-
sf,
|
|
2016
|
-
code,
|
|
2017
|
-
replacements: [],
|
|
2018
|
-
changes: [],
|
|
2019
|
-
pyreonImports: /* @__PURE__ */ new Map(),
|
|
2020
|
-
importsToRemove: /* @__PURE__ */ new Set(),
|
|
2021
|
-
specifierRewrites: /* @__PURE__ */ new Map()
|
|
2022
|
-
};
|
|
2023
|
-
migrateVisit(ctx, sf);
|
|
2024
|
-
let result = applyReplacements(code, ctx);
|
|
2025
|
-
result = insertPyreonImports(result, ctx.pyreonImports);
|
|
2026
|
-
result = result.replace(/\n{3,}/g, "\n\n");
|
|
2027
|
-
return {
|
|
2028
|
-
code: result,
|
|
2029
|
-
diagnostics,
|
|
2030
|
-
changes: ctx.changes
|
|
2031
|
-
};
|
|
2570
|
+
return routes;
|
|
2032
2571
|
}
|
|
2033
|
-
function
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2572
|
+
function extractComponents(files, cwd) {
|
|
2573
|
+
const components = [];
|
|
2574
|
+
for (const file of files) {
|
|
2575
|
+
let code;
|
|
2576
|
+
try {
|
|
2577
|
+
code = fs.readFileSync(file, "utf-8");
|
|
2578
|
+
} catch {
|
|
2579
|
+
continue;
|
|
2580
|
+
}
|
|
2581
|
+
const componentRe = /(?:export\s+)?(?:const|function)\s+([A-Z]\w*)\s*(?::\s*ComponentFn<[^>]+>\s*)?=?\s*\(?(?:\s*\{?\s*([^)]*?)\s*\}?\s*)?\)?\s*(?:=>|{)/g;
|
|
2582
|
+
let match;
|
|
2583
|
+
for (match = componentRe.exec(code); match; match = componentRe.exec(code)) {
|
|
2584
|
+
const name = match[1] ?? "Unknown";
|
|
2585
|
+
const props = (match[2] ?? "").split(/[,;]/).map((p) => p.trim().replace(/[{}]/g, "").trim().split(":")[0]?.split("=")[0]?.trim() ?? "").filter((p) => p && p !== "props");
|
|
2586
|
+
const bodyStart = match.index + match[0].length;
|
|
2587
|
+
const body = code.slice(bodyStart, Math.min(code.length, bodyStart + 2e3));
|
|
2588
|
+
const signalNames = [];
|
|
2589
|
+
const signalRe = /(?:const|let)\s+(\w+)\s*=\s*signal\s*[<(]/g;
|
|
2590
|
+
let sigMatch;
|
|
2591
|
+
for (sigMatch = signalRe.exec(body); sigMatch; sigMatch = signalRe.exec(body)) if (sigMatch[1]) signalNames.push(sigMatch[1]);
|
|
2592
|
+
components.push({
|
|
2593
|
+
name,
|
|
2594
|
+
file: path.relative(cwd, file),
|
|
2595
|
+
hasSignals: signalNames.length > 0,
|
|
2596
|
+
signalNames,
|
|
2597
|
+
props
|
|
2598
|
+
});
|
|
2599
|
+
}
|
|
2040
2600
|
}
|
|
2041
|
-
return
|
|
2601
|
+
return components;
|
|
2042
2602
|
}
|
|
2043
|
-
function
|
|
2044
|
-
const
|
|
2045
|
-
|
|
2046
|
-
|
|
2603
|
+
function extractIslands(files, cwd) {
|
|
2604
|
+
const islands = [];
|
|
2605
|
+
for (const file of files) {
|
|
2606
|
+
let code;
|
|
2607
|
+
try {
|
|
2608
|
+
code = fs.readFileSync(file, "utf-8");
|
|
2609
|
+
} catch {
|
|
2610
|
+
continue;
|
|
2611
|
+
}
|
|
2612
|
+
const islandRe = /island\s*\(\s*\(\)\s*=>\s*import\(.+?\)\s*,\s*\{[^}]*name\s*:\s*["']([^"']+)["'][^}]*?(?:hydrate\s*:\s*["']([^"']+)["'])?[^}]*\}/g;
|
|
2613
|
+
let match;
|
|
2614
|
+
for (match = islandRe.exec(code); match; match = islandRe.exec(code)) if (match[1]) islands.push({
|
|
2615
|
+
name: match[1],
|
|
2616
|
+
file: path.relative(cwd, file),
|
|
2617
|
+
hydrate: match[2] ?? "load"
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
return islands;
|
|
2047
2621
|
}
|
|
2048
|
-
function
|
|
2049
|
-
const
|
|
2050
|
-
|
|
2622
|
+
function extractParams(routePath) {
|
|
2623
|
+
const params = [];
|
|
2624
|
+
const paramRe = /:(\w+)\??/g;
|
|
2051
2625
|
let match;
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
if (!match) break;
|
|
2055
|
-
lastEnd = match.index + match[0].length;
|
|
2056
|
-
}
|
|
2057
|
-
return lastEnd;
|
|
2626
|
+
for (match = paramRe.exec(routePath); match; match = paramRe.exec(routePath)) if (match[1]) params.push(match[1]);
|
|
2627
|
+
return params;
|
|
2058
2628
|
}
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2629
|
+
function readVersion(cwd) {
|
|
2630
|
+
try {
|
|
2631
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"));
|
|
2632
|
+
const deps = {
|
|
2633
|
+
...pkg.dependencies,
|
|
2634
|
+
...pkg.devDependencies
|
|
2635
|
+
};
|
|
2636
|
+
for (const [name, ver] of Object.entries(deps)) if (name.startsWith("@pyreon/") && typeof ver === "string") return ver.replace(/^[\^~]/, "");
|
|
2637
|
+
return pkg.version || "unknown";
|
|
2638
|
+
} catch {
|
|
2639
|
+
return "unknown";
|
|
2640
|
+
}
|
|
2062
2641
|
}
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2642
|
+
|
|
2643
|
+
//#endregion
|
|
2644
|
+
//#region src/react-intercept.ts
|
|
2645
|
+
/**
|
|
2646
|
+
* React Pattern Interceptor — detects React/Vue patterns in code and provides
|
|
2647
|
+
* structured diagnostics with exact fix suggestions for AI-assisted migration.
|
|
2648
|
+
*
|
|
2649
|
+
* Two modes:
|
|
2650
|
+
* - `detectReactPatterns(code)` — returns diagnostics only (non-destructive)
|
|
2651
|
+
* - `migrateReactCode(code)` — applies auto-fixes and returns transformed code
|
|
2652
|
+
*
|
|
2653
|
+
* Designed for three consumers:
|
|
2654
|
+
* 1. Compiler pre-pass (warnings during build)
|
|
2655
|
+
* 2. CLI `pyreon doctor` (project-wide scanning)
|
|
2656
|
+
* 3. MCP server `migrate_react` / `validate` tools (AI agent integration)
|
|
2657
|
+
*/
|
|
2658
|
+
/** React import sources → Pyreon equivalents */
|
|
2659
|
+
const IMPORT_REWRITES = {
|
|
2660
|
+
react: "@pyreon/core",
|
|
2661
|
+
"react-dom": "@pyreon/runtime-dom",
|
|
2662
|
+
"react-dom/client": "@pyreon/runtime-dom",
|
|
2663
|
+
"react-dom/server": "@pyreon/runtime-server",
|
|
2664
|
+
"react-router": "@pyreon/router",
|
|
2665
|
+
"react-router-dom": "@pyreon/router"
|
|
2666
|
+
};
|
|
2667
|
+
/** React specifiers that map to specific Pyreon imports */
|
|
2668
|
+
const SPECIFIER_REWRITES = {
|
|
2669
|
+
useState: {
|
|
2670
|
+
name: "signal",
|
|
2671
|
+
from: "@pyreon/reactivity"
|
|
2672
|
+
},
|
|
2673
|
+
useEffect: {
|
|
2674
|
+
name: "effect",
|
|
2675
|
+
from: "@pyreon/reactivity"
|
|
2676
|
+
},
|
|
2677
|
+
useLayoutEffect: {
|
|
2678
|
+
name: "effect",
|
|
2679
|
+
from: "@pyreon/reactivity"
|
|
2680
|
+
},
|
|
2681
|
+
useMemo: {
|
|
2682
|
+
name: "computed",
|
|
2683
|
+
from: "@pyreon/reactivity"
|
|
2684
|
+
},
|
|
2685
|
+
useReducer: {
|
|
2686
|
+
name: "signal",
|
|
2687
|
+
from: "@pyreon/reactivity"
|
|
2688
|
+
},
|
|
2689
|
+
useRef: {
|
|
2690
|
+
name: "signal",
|
|
2691
|
+
from: "@pyreon/reactivity"
|
|
2692
|
+
},
|
|
2693
|
+
createContext: {
|
|
2694
|
+
name: "createContext",
|
|
2695
|
+
from: "@pyreon/core"
|
|
2696
|
+
},
|
|
2697
|
+
useContext: {
|
|
2698
|
+
name: "useContext",
|
|
2699
|
+
from: "@pyreon/core"
|
|
2700
|
+
},
|
|
2701
|
+
Fragment: {
|
|
2702
|
+
name: "Fragment",
|
|
2703
|
+
from: "@pyreon/core"
|
|
2071
2704
|
},
|
|
2072
|
-
{
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
cause: `'${m[1]}' is not callable. If this is a signal, you need to call it: ${m[1]}()`,
|
|
2076
|
-
fix: "Pyreon signals are callable functions. Read: signal(), Write: signal.set(value)",
|
|
2077
|
-
fixCode: `// Read value:\nconst value = ${m[1]}()\n// Set value:\n${m[1]}.set(newValue)`
|
|
2078
|
-
})
|
|
2705
|
+
Suspense: {
|
|
2706
|
+
name: "Suspense",
|
|
2707
|
+
from: "@pyreon/core"
|
|
2079
2708
|
},
|
|
2080
|
-
{
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
cause: `Package ${m[1]} is not installed.`,
|
|
2084
|
-
fix: `Run: bun add ${m[1]}`,
|
|
2085
|
-
fixCode: `bun add ${m[1]}`
|
|
2086
|
-
})
|
|
2709
|
+
lazy: {
|
|
2710
|
+
name: "lazy",
|
|
2711
|
+
from: "@pyreon/core"
|
|
2087
2712
|
},
|
|
2088
|
-
{
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
cause: "Importing from 'react' in a Pyreon project.",
|
|
2092
|
-
fix: "Replace React imports with Pyreon equivalents.",
|
|
2093
|
-
fixCode: "// Instead of:\nimport { useState } from \"react\"\n// Use:\nimport { signal } from \"@pyreon/reactivity\""
|
|
2094
|
-
})
|
|
2713
|
+
memo: {
|
|
2714
|
+
name: "",
|
|
2715
|
+
from: ""
|
|
2095
2716
|
},
|
|
2096
|
-
{
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
cause: `Accessing .${m[1]} on a signal. Pyreon signals don't have a .${m[1]} property.`,
|
|
2100
|
-
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.`,
|
|
2101
|
-
fixCode: m[1] === "value" ? "// Read: mySignal()\n// Write: mySignal.set(newValue)" : void 0
|
|
2102
|
-
})
|
|
2717
|
+
forwardRef: {
|
|
2718
|
+
name: "",
|
|
2719
|
+
from: ""
|
|
2103
2720
|
},
|
|
2104
|
-
{
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
cause: `Component returned ${m[1]} instead of VNode. Components must return JSX, null, or a string.`,
|
|
2108
|
-
fix: "Make sure your component returns a JSX element, null, or a string.",
|
|
2109
|
-
fixCode: "const MyComponent = (props) => {\n return <div>{props.children}</div>\n}"
|
|
2110
|
-
})
|
|
2721
|
+
createRoot: {
|
|
2722
|
+
name: "mount",
|
|
2723
|
+
from: "@pyreon/runtime-dom"
|
|
2111
2724
|
},
|
|
2112
|
-
{
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
cause: "onMount expects a callback that optionally returns a CleanupFn.",
|
|
2116
|
-
fix: "Return a cleanup function, or return nothing.",
|
|
2117
|
-
fixCode: "onMount(() => {\n // setup code\n})"
|
|
2118
|
-
})
|
|
2725
|
+
hydrateRoot: {
|
|
2726
|
+
name: "hydrateRoot",
|
|
2727
|
+
from: "@pyreon/runtime-dom"
|
|
2119
2728
|
},
|
|
2120
|
-
{
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
cause: "<For> requires a 'by' prop for efficient keyed reconciliation.",
|
|
2124
|
-
fix: "Add a by prop that returns a unique key for each item.",
|
|
2125
|
-
fixCode: "<For each={items()} by={item => item.id}>\n {item => <li>{item.name}</li>}\n</For>"
|
|
2126
|
-
})
|
|
2729
|
+
useNavigate: {
|
|
2730
|
+
name: "useRouter",
|
|
2731
|
+
from: "@pyreon/router"
|
|
2127
2732
|
},
|
|
2128
|
-
{
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
cause: "Hook called outside a component function. Pyreon hooks must be called during component setup.",
|
|
2132
|
-
fix: "Move the hook call inside a component function body."
|
|
2133
|
-
})
|
|
2733
|
+
useParams: {
|
|
2734
|
+
name: "useRoute",
|
|
2735
|
+
from: "@pyreon/router"
|
|
2134
2736
|
},
|
|
2135
|
-
{
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
}
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2737
|
+
useLocation: {
|
|
2738
|
+
name: "useRoute",
|
|
2739
|
+
from: "@pyreon/router"
|
|
2740
|
+
},
|
|
2741
|
+
Link: {
|
|
2742
|
+
name: "RouterLink",
|
|
2743
|
+
from: "@pyreon/router"
|
|
2744
|
+
},
|
|
2745
|
+
NavLink: {
|
|
2746
|
+
name: "RouterLink",
|
|
2747
|
+
from: "@pyreon/router"
|
|
2748
|
+
},
|
|
2749
|
+
Outlet: {
|
|
2750
|
+
name: "RouterView",
|
|
2751
|
+
from: "@pyreon/router"
|
|
2752
|
+
},
|
|
2753
|
+
useSearchParams: {
|
|
2754
|
+
name: "useSearchParams",
|
|
2755
|
+
from: "@pyreon/router"
|
|
2149
2756
|
}
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2757
|
+
};
|
|
2758
|
+
/** JSX attribute rewrites (React → standard HTML) */
|
|
2759
|
+
const JSX_ATTR_REWRITES = {
|
|
2760
|
+
className: "class",
|
|
2761
|
+
htmlFor: "for"
|
|
2762
|
+
};
|
|
2155
2763
|
/**
|
|
2156
|
-
*
|
|
2157
|
-
*
|
|
2158
|
-
*
|
|
2159
|
-
*
|
|
2160
|
-
*
|
|
2161
|
-
*
|
|
2162
|
-
* Catalog of detected patterns (grounded in `.claude/rules/anti-patterns.md`):
|
|
2163
|
-
*
|
|
2164
|
-
* - `for-missing-by` — `<For each={...}>` without a `by` prop
|
|
2165
|
-
* - `for-with-key` — `<For key={...}>` (JSX reserves `key`; the keying
|
|
2166
|
-
* prop is `by` in Pyreon)
|
|
2167
|
-
* - `props-destructured` — `({ foo }: Props) => <JSX />` destructures at
|
|
2168
|
-
* the component signature; reading is captured once
|
|
2169
|
-
* and loses reactivity. Access `props.foo` instead
|
|
2170
|
-
* or use `splitProps(props, [...])`.
|
|
2171
|
-
* - `process-dev-gate` — `typeof process !== 'undefined' &&
|
|
2172
|
-
* process.env.NODE_ENV !== 'production'` is dead
|
|
2173
|
-
* code in real Vite browser bundles. Use
|
|
2174
|
-
* `import.meta.env?.DEV` instead.
|
|
2175
|
-
* - `empty-theme` — `.theme({})` chain is a no-op; remove it.
|
|
2176
|
-
* - `raw-add-event-listener` — raw `addEventListener(...)` in a component
|
|
2177
|
-
* or hook body. Use `useEventListener(...)` from
|
|
2178
|
-
* `@pyreon/hooks` for auto-cleanup.
|
|
2179
|
-
* - `raw-remove-event-listener` — same, for removeEventListener.
|
|
2180
|
-
* - `date-math-random-id` — `Date.now() + Math.random()` / template-concat
|
|
2181
|
-
* variants. Under rapid operations (paste, clone)
|
|
2182
|
-
* collision probability is non-trivial. Use a
|
|
2183
|
-
* monotonic counter.
|
|
2184
|
-
* - `on-click-undefined` — `onClick={undefined}` explicitly; the runtime
|
|
2185
|
-
* used to crash on this pattern. Omit the prop.
|
|
2186
|
-
* - `signal-write-as-call` — `sig(value)` is a no-op read that ignores
|
|
2187
|
-
* its argument; the runtime warns in dev. Static
|
|
2188
|
-
* detector spots it pre-runtime when `sig` was
|
|
2189
|
-
* declared as `const sig = signal(...)` /
|
|
2190
|
-
* `computed(...)` and called with ≥1 argument.
|
|
2191
|
-
* - `static-return-null-conditional` — `if (cond) return null` at the
|
|
2192
|
-
* top of a component body runs ONCE; signal changes
|
|
2193
|
-
* in `cond` never re-evaluate the early-return.
|
|
2194
|
-
* Wrap in a returned reactive accessor.
|
|
2195
|
-
* - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
|
|
2196
|
-
* cast on JSX returns is unnecessary (`JSX.Element`
|
|
2197
|
-
* is already assignable to `VNodeChild`).
|
|
2198
|
-
* - `island-never-with-registry-entry` — an `island()` declared with
|
|
2199
|
-
* `hydrate: 'never'` is also registered in the same
|
|
2200
|
-
* file's `hydrateIslands({ ... })` call. The whole
|
|
2201
|
-
* point of `'never'` is shipping zero client JS;
|
|
2202
|
-
* registering pulls the component module into the
|
|
2203
|
-
* client bundle graph (the runtime short-circuits
|
|
2204
|
-
* and never calls the loader, but the bundler still
|
|
2205
|
-
* includes the import). Drop the registry entry.
|
|
2206
|
-
*
|
|
2207
|
-
* Two-mode surface mirrors `react-intercept.ts`:
|
|
2208
|
-
* - `detectPyreonPatterns(code)` — diagnostics only
|
|
2209
|
-
* - `hasPyreonPatterns(code)` — fast regex pre-filter
|
|
2210
|
-
*
|
|
2211
|
-
* ## fixable: false (invariant)
|
|
2212
|
-
*
|
|
2213
|
-
* Every Pyreon diagnostic reports `fixable: false` — no exceptions.
|
|
2214
|
-
* The `migrate_react` MCP tool only knows React mappings, so claiming
|
|
2215
|
-
* a Pyreon code is auto-fixable would mislead a consumer who wires
|
|
2216
|
-
* their UX off the flag and finds nothing applies the fix. Flip to
|
|
2217
|
-
* `true` ONLY when a companion `migrate_pyreon` tool ships in a
|
|
2218
|
-
* subsequent PR. The invariant is locked in
|
|
2219
|
-
* `tests/pyreon-intercept.test.ts` under "fixable contract".
|
|
2220
|
-
*
|
|
2221
|
-
* Designed for three consumers:
|
|
2222
|
-
* 1. Compiler pre-pass warnings during build
|
|
2223
|
-
* 2. CLI `pyreon doctor`
|
|
2224
|
-
* 3. MCP server `validate` tool
|
|
2764
|
+
* Collects every identifier bound to a signal factory call. Mirrors
|
|
2765
|
+
* `pyreon-intercept.ts:collectSignalBindings` but also recognises the
|
|
2766
|
+
* `useSignal` / `createSignal` aliases (Solid / hook-style) so the React
|
|
2767
|
+
* detector — which runs on cross-framework migration input — doesn't miss a
|
|
2768
|
+
* genuine `mySignal.value = x` written by someone coming from Solid/Vue.
|
|
2225
2769
|
*/
|
|
2226
|
-
function
|
|
2770
|
+
function collectDetectSignalBindings(sf) {
|
|
2771
|
+
const names = /* @__PURE__ */ new Set();
|
|
2772
|
+
function isSignalFactoryCall(init) {
|
|
2773
|
+
if (!init || !ts.isCallExpression(init)) return false;
|
|
2774
|
+
const callee = init.expression;
|
|
2775
|
+
if (!ts.isIdentifier(callee)) return false;
|
|
2776
|
+
return callee.text === "signal" || callee.text === "computed" || callee.text === "useSignal" || callee.text === "createSignal";
|
|
2777
|
+
}
|
|
2778
|
+
function walk(node) {
|
|
2779
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
2780
|
+
const list = node.parent;
|
|
2781
|
+
if (ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && isSignalFactoryCall(node.initializer)) names.add(node.name.text);
|
|
2782
|
+
}
|
|
2783
|
+
ts.forEachChild(node, walk);
|
|
2784
|
+
}
|
|
2785
|
+
walk(sf);
|
|
2786
|
+
return names;
|
|
2787
|
+
}
|
|
2788
|
+
function detectGetNodeText(ctx, node) {
|
|
2227
2789
|
return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
|
|
2228
2790
|
}
|
|
2229
|
-
function
|
|
2230
|
-
const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf));
|
|
2231
|
-
ctx.diagnostics.push({
|
|
2232
|
-
code,
|
|
2233
|
-
message,
|
|
2234
|
-
line: line + 1,
|
|
2235
|
-
column: character,
|
|
2236
|
-
current: current.trim(),
|
|
2237
|
-
suggested: suggested.trim(),
|
|
2238
|
-
fixable
|
|
2239
|
-
});
|
|
2791
|
+
function detectDiag(ctx, node, diagCode, message, current, suggested, fixable) {
|
|
2792
|
+
const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf));
|
|
2793
|
+
ctx.diagnostics.push({
|
|
2794
|
+
code: diagCode,
|
|
2795
|
+
message,
|
|
2796
|
+
line: line + 1,
|
|
2797
|
+
column: character,
|
|
2798
|
+
current: current.trim(),
|
|
2799
|
+
suggested: suggested.trim(),
|
|
2800
|
+
fixable
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
function detectImportDeclaration(ctx, node) {
|
|
2804
|
+
if (!node.moduleSpecifier) return;
|
|
2805
|
+
const source = node.moduleSpecifier.text;
|
|
2806
|
+
const pyreonSource = IMPORT_REWRITES[source];
|
|
2807
|
+
if (pyreonSource !== void 0) {
|
|
2808
|
+
if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) for (const spec of node.importClause.namedBindings.elements) ctx.reactImportedHooks.add(spec.name.text);
|
|
2809
|
+
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);
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
function detectUseState(ctx, node) {
|
|
2813
|
+
const parent = node.parent;
|
|
2814
|
+
if (ts.isVariableDeclaration(parent) && parent.name && ts.isArrayBindingPattern(parent.name) && parent.name.elements.length >= 1) {
|
|
2815
|
+
const firstEl = parent.name.elements[0];
|
|
2816
|
+
const valueName = firstEl && ts.isBindingElement(firstEl) ? firstEl.name.text : "value";
|
|
2817
|
+
const initArg = node.arguments[0] ? detectGetNodeText(ctx, node.arguments[0]) : "undefined";
|
|
2818
|
+
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);
|
|
2819
|
+
} else detectDiag(ctx, node, "use-state", "useState is a React API. In Pyreon, use signal().", detectGetNodeText(ctx, node), "signal(initialValue)", true);
|
|
2820
|
+
}
|
|
2821
|
+
function callbackHasCleanup(callbackArg) {
|
|
2822
|
+
if (!ts.isArrowFunction(callbackArg) && !ts.isFunctionExpression(callbackArg)) return false;
|
|
2823
|
+
const body = callbackArg.body;
|
|
2824
|
+
if (!ts.isBlock(body)) return false;
|
|
2825
|
+
for (const stmt of body.statements) if (ts.isReturnStatement(stmt) && stmt.expression) return true;
|
|
2826
|
+
return false;
|
|
2827
|
+
}
|
|
2828
|
+
function detectUseEffect(ctx, node) {
|
|
2829
|
+
const hookName = node.expression.text;
|
|
2830
|
+
const depsArg = node.arguments[1];
|
|
2831
|
+
const callbackArg = node.arguments[0];
|
|
2832
|
+
if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0) {
|
|
2833
|
+
const hasCleanup = callbackArg ? callbackHasCleanup(callbackArg) : false;
|
|
2834
|
+
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);
|
|
2835
|
+
} 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);
|
|
2836
|
+
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);
|
|
2837
|
+
}
|
|
2838
|
+
function detectUseMemo(ctx, node) {
|
|
2839
|
+
const computeFn = node.arguments[0];
|
|
2840
|
+
const computeText = computeFn ? detectGetNodeText(ctx, computeFn) : "() => value";
|
|
2841
|
+
detectDiag(ctx, node, "use-memo", "useMemo is a React API. In Pyreon, use computed() — dependencies auto-tracked.", detectGetNodeText(ctx, node), `computed(${computeText})`, true);
|
|
2240
2842
|
}
|
|
2241
|
-
function
|
|
2242
|
-
const
|
|
2243
|
-
|
|
2244
|
-
|
|
2843
|
+
function detectUseCallback(ctx, node) {
|
|
2844
|
+
const callbackFn = node.arguments[0];
|
|
2845
|
+
const callbackText = callbackFn ? detectGetNodeText(ctx, callbackFn) : "() => {}";
|
|
2846
|
+
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);
|
|
2245
2847
|
}
|
|
2246
|
-
function
|
|
2247
|
-
|
|
2848
|
+
function detectUseRef(ctx, node) {
|
|
2849
|
+
const arg = node.arguments[0];
|
|
2850
|
+
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);
|
|
2851
|
+
else {
|
|
2852
|
+
const initText = arg ? detectGetNodeText(ctx, arg) : "undefined";
|
|
2853
|
+
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);
|
|
2854
|
+
}
|
|
2248
2855
|
}
|
|
2249
|
-
function
|
|
2250
|
-
|
|
2251
|
-
const keyAttr = findJsxAttribute(node, "key");
|
|
2252
|
-
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);
|
|
2253
|
-
const eachAttr = findJsxAttribute(node, "each");
|
|
2254
|
-
const byAttr = findJsxAttribute(node, "by");
|
|
2255
|
-
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);
|
|
2856
|
+
function detectUseReducer(ctx, node) {
|
|
2857
|
+
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);
|
|
2256
2858
|
}
|
|
2257
|
-
function
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2859
|
+
function isCallToReactDot(callee, methodName) {
|
|
2860
|
+
return ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === "React" && callee.name.text === methodName;
|
|
2861
|
+
}
|
|
2862
|
+
function detectMemoWrapper(ctx, node) {
|
|
2863
|
+
const callee = node.expression;
|
|
2864
|
+
if (ts.isIdentifier(callee) && callee.text === "memo" || isCallToReactDot(callee, "memo")) {
|
|
2865
|
+
const inner = node.arguments[0];
|
|
2866
|
+
const innerText = inner ? detectGetNodeText(ctx, inner) : "Component";
|
|
2867
|
+
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);
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
function detectForwardRef(ctx, node) {
|
|
2871
|
+
const callee = node.expression;
|
|
2872
|
+
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);
|
|
2873
|
+
}
|
|
2874
|
+
function detectJsxAttributes(ctx, node) {
|
|
2875
|
+
const attrName = node.name.text;
|
|
2876
|
+
if (attrName in JSX_ATTR_REWRITES) {
|
|
2877
|
+
const htmlAttr = JSX_ATTR_REWRITES[attrName];
|
|
2878
|
+
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);
|
|
2879
|
+
}
|
|
2880
|
+
if (attrName === "onChange") {
|
|
2881
|
+
const jsxElement = findParentJsxElement(node);
|
|
2882
|
+
if (jsxElement) {
|
|
2883
|
+
const tagName = getJsxTagName(jsxElement);
|
|
2884
|
+
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);
|
|
2264
2885
|
}
|
|
2265
|
-
ts.forEachChild(n, walk);
|
|
2266
2886
|
}
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2887
|
+
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);
|
|
2888
|
+
}
|
|
2889
|
+
function detectDotValueSignal(ctx, node) {
|
|
2890
|
+
const varName = node.expression.text;
|
|
2891
|
+
if (!ctx.signalBindings.has(varName)) return;
|
|
2892
|
+
const parent = node.parent;
|
|
2893
|
+
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);
|
|
2894
|
+
}
|
|
2895
|
+
function detectArrayMapJsx(ctx, node) {
|
|
2896
|
+
const parent = node.parent;
|
|
2897
|
+
if (ts.isJsxExpression(parent)) {
|
|
2898
|
+
const arrayExpr = detectGetNodeText(ctx, node.expression.expression);
|
|
2899
|
+
const mapCallback = node.arguments[0];
|
|
2900
|
+
const mapCallbackText = mapCallback ? detectGetNodeText(ctx, mapCallback) : "item => <li>{item}</li>";
|
|
2901
|
+
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);
|
|
2270
2902
|
}
|
|
2271
|
-
return found;
|
|
2272
2903
|
}
|
|
2273
|
-
function
|
|
2274
|
-
|
|
2275
|
-
const first = node.parameters[0];
|
|
2276
|
-
if (!first || !ts.isObjectBindingPattern(first.name)) return;
|
|
2277
|
-
if (first.name.elements.length === 0) return;
|
|
2278
|
-
if (!containsJsx(node)) return;
|
|
2279
|
-
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);
|
|
2904
|
+
function isCallToHook(node, hookName) {
|
|
2905
|
+
return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === hookName;
|
|
2280
2906
|
}
|
|
2281
|
-
function
|
|
2282
|
-
|
|
2283
|
-
if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false;
|
|
2284
|
-
if (!ts.isTypeOfExpression(node.left)) return false;
|
|
2285
|
-
if (!ts.isIdentifier(node.left.expression) || node.left.expression.text !== "process") return false;
|
|
2286
|
-
return ts.isStringLiteral(node.right) && node.right.text === "undefined";
|
|
2907
|
+
function isCallToEffectHook(node) {
|
|
2908
|
+
return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (node.expression.text === "useEffect" || node.expression.text === "useLayoutEffect");
|
|
2287
2909
|
}
|
|
2288
|
-
function
|
|
2289
|
-
|
|
2290
|
-
if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false;
|
|
2291
|
-
const left = node.left;
|
|
2292
|
-
if (!ts.isPropertyAccessExpression(left)) return false;
|
|
2293
|
-
if (!ts.isIdentifier(left.name) || left.name.text !== "NODE_ENV") return false;
|
|
2294
|
-
if (!ts.isPropertyAccessExpression(left.expression)) return false;
|
|
2295
|
-
if (!ts.isIdentifier(left.expression.name) || left.expression.name.text !== "env") return false;
|
|
2296
|
-
if (!ts.isIdentifier(left.expression.expression)) return false;
|
|
2297
|
-
if (left.expression.expression.text !== "process") return false;
|
|
2298
|
-
return ts.isStringLiteral(node.right) && node.right.text === "production";
|
|
2910
|
+
function isMapCallExpression(node) {
|
|
2911
|
+
return ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name) && node.expression.name.text === "map";
|
|
2299
2912
|
}
|
|
2300
|
-
function
|
|
2301
|
-
|
|
2302
|
-
if (!(isTypeofProcess(node.left) && isProcessNodeEnvProdGuard(node.right) || isTypeofProcess(node.right) && isProcessNodeEnvProdGuard(node.left))) return;
|
|
2303
|
-
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);
|
|
2913
|
+
function isDotValueAccess(node) {
|
|
2914
|
+
return ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name) && node.name.text === "value" && ts.isIdentifier(node.expression);
|
|
2304
2915
|
}
|
|
2305
|
-
function
|
|
2306
|
-
|
|
2307
|
-
if (
|
|
2308
|
-
if (
|
|
2309
|
-
if (node
|
|
2310
|
-
|
|
2311
|
-
if (
|
|
2312
|
-
if (
|
|
2313
|
-
|
|
2916
|
+
function detectVisitNode(ctx, node) {
|
|
2917
|
+
if (ts.isImportDeclaration(node)) detectImportDeclaration(ctx, node);
|
|
2918
|
+
if (isCallToHook(node, "useState")) detectUseState(ctx, node);
|
|
2919
|
+
if (isCallToEffectHook(node)) detectUseEffect(ctx, node);
|
|
2920
|
+
if (isCallToHook(node, "useMemo")) detectUseMemo(ctx, node);
|
|
2921
|
+
if (isCallToHook(node, "useCallback")) detectUseCallback(ctx, node);
|
|
2922
|
+
if (isCallToHook(node, "useRef")) detectUseRef(ctx, node);
|
|
2923
|
+
if (isCallToHook(node, "useReducer")) detectUseReducer(ctx, node);
|
|
2924
|
+
if (ts.isCallExpression(node)) detectMemoWrapper(ctx, node);
|
|
2925
|
+
if (ts.isCallExpression(node)) detectForwardRef(ctx, node);
|
|
2926
|
+
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) detectJsxAttributes(ctx, node);
|
|
2927
|
+
if (isDotValueAccess(node)) detectDotValueSignal(ctx, node);
|
|
2928
|
+
if (isMapCallExpression(node)) detectArrayMapJsx(ctx, node);
|
|
2314
2929
|
}
|
|
2315
|
-
function
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
if (method !== "addEventListener" && method !== "removeEventListener") return;
|
|
2321
|
-
const target = callee.expression;
|
|
2322
|
-
const targetName = ts.isIdentifier(target) ? target.text : ts.isPropertyAccessExpression(target) && ts.isIdentifier(target.name) ? target.name.text : "";
|
|
2323
|
-
if (!new Set([
|
|
2324
|
-
"window",
|
|
2325
|
-
"document",
|
|
2326
|
-
"body",
|
|
2327
|
-
"el",
|
|
2328
|
-
"element",
|
|
2329
|
-
"node",
|
|
2330
|
-
"target"
|
|
2331
|
-
]).has(targetName)) return;
|
|
2332
|
-
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);
|
|
2333
|
-
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);
|
|
2930
|
+
function detectVisit(ctx, node) {
|
|
2931
|
+
ts.forEachChild(node, (child) => {
|
|
2932
|
+
detectVisitNode(ctx, child);
|
|
2933
|
+
detectVisit(ctx, child);
|
|
2934
|
+
});
|
|
2334
2935
|
}
|
|
2335
|
-
function
|
|
2336
|
-
|
|
2936
|
+
function detectReactPatterns(code, filename = "input.tsx") {
|
|
2937
|
+
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
|
|
2938
|
+
const ctx = {
|
|
2939
|
+
sf,
|
|
2940
|
+
code,
|
|
2941
|
+
diagnostics: [],
|
|
2942
|
+
reactImportedHooks: /* @__PURE__ */ new Set(),
|
|
2943
|
+
signalBindings: collectDetectSignalBindings(sf)
|
|
2944
|
+
};
|
|
2945
|
+
detectVisit(ctx, sf);
|
|
2946
|
+
return ctx.diagnostics;
|
|
2337
2947
|
}
|
|
2338
|
-
function
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2948
|
+
function migrateAddImport(ctx, source, specifier) {
|
|
2949
|
+
if (!source || !specifier) return;
|
|
2950
|
+
let specs = ctx.pyreonImports.get(source);
|
|
2951
|
+
if (!specs) {
|
|
2952
|
+
specs = /* @__PURE__ */ new Set();
|
|
2953
|
+
ctx.pyreonImports.set(source, specs);
|
|
2954
|
+
}
|
|
2955
|
+
specs.add(specifier);
|
|
2956
|
+
}
|
|
2957
|
+
function migrateReplace(ctx, node, text) {
|
|
2958
|
+
ctx.replacements.push({
|
|
2959
|
+
start: node.getStart(ctx.sf),
|
|
2960
|
+
end: node.getEnd(),
|
|
2961
|
+
text
|
|
2962
|
+
});
|
|
2963
|
+
}
|
|
2964
|
+
function migrateGetNodeText(ctx, node) {
|
|
2965
|
+
return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
|
|
2966
|
+
}
|
|
2967
|
+
function migrateGetLine(ctx, node) {
|
|
2968
|
+
return ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf)).line + 1;
|
|
2969
|
+
}
|
|
2970
|
+
function migrateImportDeclaration(ctx, node) {
|
|
2971
|
+
if (!node.moduleSpecifier) return;
|
|
2972
|
+
if (!(node.moduleSpecifier.text in IMPORT_REWRITES)) return;
|
|
2973
|
+
if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) for (const spec of node.importClause.namedBindings.elements) {
|
|
2974
|
+
const rewrite = SPECIFIER_REWRITES[spec.name.text];
|
|
2975
|
+
if (rewrite) {
|
|
2976
|
+
if (rewrite.name) migrateAddImport(ctx, rewrite.from, rewrite.name);
|
|
2977
|
+
ctx.specifierRewrites.set(spec, rewrite);
|
|
2345
2978
|
}
|
|
2346
|
-
ts.forEachChild(n, walk);
|
|
2347
2979
|
}
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
if (
|
|
2353
|
-
|
|
2354
|
-
|
|
2980
|
+
ctx.importsToRemove.add(node);
|
|
2981
|
+
}
|
|
2982
|
+
function migrateUseState(ctx, node) {
|
|
2983
|
+
const parent = node.parent;
|
|
2984
|
+
if (ts.isVariableDeclaration(parent) && parent.name && ts.isArrayBindingPattern(parent.name) && parent.name.elements.length >= 1) {
|
|
2985
|
+
const firstEl = parent.name.elements[0];
|
|
2986
|
+
const valueName = firstEl && ts.isBindingElement(firstEl) ? firstEl.name.text : "value";
|
|
2987
|
+
const initArg = node.arguments[0] ? migrateGetNodeText(ctx, node.arguments[0]) : "undefined";
|
|
2988
|
+
const declStart = parent.getStart(ctx.sf);
|
|
2989
|
+
const declEnd = parent.getEnd();
|
|
2990
|
+
ctx.replacements.push({
|
|
2991
|
+
start: declStart,
|
|
2992
|
+
end: declEnd,
|
|
2993
|
+
text: `${valueName} = signal(${initArg})`
|
|
2994
|
+
});
|
|
2995
|
+
migrateAddImport(ctx, "@pyreon/reactivity", "signal");
|
|
2996
|
+
ctx.changes.push({
|
|
2997
|
+
type: "replace",
|
|
2998
|
+
line: migrateGetLine(ctx, node),
|
|
2999
|
+
description: `useState → signal: ${valueName}`
|
|
3000
|
+
});
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
function migrateUseEffect(ctx, node) {
|
|
3004
|
+
const depsArg = node.arguments[1];
|
|
3005
|
+
const callbackArg = node.arguments[0];
|
|
3006
|
+
const hookName = node.expression.text;
|
|
3007
|
+
if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0 && callbackArg) {
|
|
3008
|
+
migrateReplace(ctx, node, `onMount(${migrateGetNodeText(ctx, callbackArg)})`);
|
|
3009
|
+
migrateAddImport(ctx, "@pyreon/core", "onMount");
|
|
3010
|
+
ctx.changes.push({
|
|
3011
|
+
type: "replace",
|
|
3012
|
+
line: migrateGetLine(ctx, node),
|
|
3013
|
+
description: `${hookName}(fn, []) → onMount(fn)`
|
|
3014
|
+
});
|
|
3015
|
+
} else if (callbackArg) {
|
|
3016
|
+
migrateReplace(ctx, node, `effect(${migrateGetNodeText(ctx, callbackArg)})`);
|
|
3017
|
+
migrateAddImport(ctx, "@pyreon/reactivity", "effect");
|
|
3018
|
+
ctx.changes.push({
|
|
3019
|
+
type: "replace",
|
|
3020
|
+
line: migrateGetLine(ctx, node),
|
|
3021
|
+
description: `${hookName} → effect (auto-tracks deps)`
|
|
3022
|
+
});
|
|
3023
|
+
}
|
|
2355
3024
|
}
|
|
2356
|
-
function
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
3025
|
+
function migrateUseMemo(ctx, node) {
|
|
3026
|
+
const computeFn = node.arguments[0];
|
|
3027
|
+
if (computeFn) {
|
|
3028
|
+
migrateReplace(ctx, node, `computed(${migrateGetNodeText(ctx, computeFn)})`);
|
|
3029
|
+
migrateAddImport(ctx, "@pyreon/reactivity", "computed");
|
|
3030
|
+
ctx.changes.push({
|
|
3031
|
+
type: "replace",
|
|
3032
|
+
line: migrateGetLine(ctx, node),
|
|
3033
|
+
description: "useMemo → computed (auto-tracks deps)"
|
|
3034
|
+
});
|
|
3035
|
+
}
|
|
2365
3036
|
}
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
* (1) shadowing a signal name with a non-signal is itself unusual and
|
|
2376
|
-
* (2) the detector message points at exactly the wrong-shape call so a
|
|
2377
|
-
* human reviewer can dismiss the rare false positive in seconds.
|
|
2378
|
-
*/
|
|
2379
|
-
function collectSignalBindings(sf) {
|
|
2380
|
-
const names = /* @__PURE__ */ new Set();
|
|
2381
|
-
function isSignalFactoryCall(init) {
|
|
2382
|
-
if (!init || !ts.isCallExpression(init)) return false;
|
|
2383
|
-
const callee = init.expression;
|
|
2384
|
-
if (!ts.isIdentifier(callee)) return false;
|
|
2385
|
-
return callee.text === "signal" || callee.text === "computed";
|
|
3037
|
+
function migrateUseCallback(ctx, node) {
|
|
3038
|
+
const callbackFn = node.arguments[0];
|
|
3039
|
+
if (callbackFn) {
|
|
3040
|
+
migrateReplace(ctx, node, migrateGetNodeText(ctx, callbackFn));
|
|
3041
|
+
ctx.changes.push({
|
|
3042
|
+
type: "replace",
|
|
3043
|
+
line: migrateGetLine(ctx, node),
|
|
3044
|
+
description: "useCallback → plain function (not needed in Pyreon)"
|
|
3045
|
+
});
|
|
2386
3046
|
}
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
3047
|
+
}
|
|
3048
|
+
function migrateUseRef(ctx, node) {
|
|
3049
|
+
const arg = node.arguments[0];
|
|
3050
|
+
if (arg && (arg.kind === ts.SyntaxKind.NullKeyword || ts.isIdentifier(arg) && arg.text === "undefined") || !arg) {
|
|
3051
|
+
migrateReplace(ctx, node, "createRef()");
|
|
3052
|
+
migrateAddImport(ctx, "@pyreon/core", "createRef");
|
|
3053
|
+
ctx.changes.push({
|
|
3054
|
+
type: "replace",
|
|
3055
|
+
line: migrateGetLine(ctx, node),
|
|
3056
|
+
description: "useRef(null) → createRef()"
|
|
3057
|
+
});
|
|
3058
|
+
} else {
|
|
3059
|
+
migrateReplace(ctx, node, `signal(${migrateGetNodeText(ctx, arg)})`);
|
|
3060
|
+
migrateAddImport(ctx, "@pyreon/reactivity", "signal");
|
|
3061
|
+
ctx.changes.push({
|
|
3062
|
+
type: "replace",
|
|
3063
|
+
line: migrateGetLine(ctx, node),
|
|
3064
|
+
description: "useRef(value) → signal(value)"
|
|
3065
|
+
});
|
|
2393
3066
|
}
|
|
2394
|
-
walk(sf);
|
|
2395
|
-
return names;
|
|
2396
3067
|
}
|
|
2397
|
-
function
|
|
2398
|
-
if (ctx.signalBindings.size === 0) return;
|
|
3068
|
+
function migrateMemoWrapper(ctx, node) {
|
|
2399
3069
|
const callee = node.expression;
|
|
2400
|
-
if (
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
* components mount and never re-execute their function bodies. A signal
|
|
2408
|
-
* change inside `cond` therefore never re-evaluates the condition; the
|
|
2409
|
-
* component is permanently stuck on whichever branch the first run picked.
|
|
2410
|
-
*
|
|
2411
|
-
* The fix is to wrap the conditional in a returned reactive accessor:
|
|
2412
|
-
* return (() => { if (!cond()) return null; return <div /> })
|
|
2413
|
-
*
|
|
2414
|
-
* Detection:
|
|
2415
|
-
* - The function contains JSX (i.e. it's a component)
|
|
2416
|
-
* - The function body has an `IfStatement` whose `thenStatement` is
|
|
2417
|
-
* `return null` (either bare `return null` or `{ return null }`)
|
|
2418
|
-
* - The `if` is at the function body's top level, NOT inside a returned
|
|
2419
|
-
* arrow / IIFE (those are reactive scopes — flagging them would be a
|
|
2420
|
-
* false positive)
|
|
2421
|
-
*/
|
|
2422
|
-
function returnsNullStatement(stmt) {
|
|
2423
|
-
if (ts.isReturnStatement(stmt)) {
|
|
2424
|
-
const expr = stmt.expression;
|
|
2425
|
-
return !!expr && expr.kind === ts.SyntaxKind.NullKeyword;
|
|
3070
|
+
if ((ts.isIdentifier(callee) && callee.text === "memo" || isCallToReactDot(callee, "memo")) && node.arguments[0]) {
|
|
3071
|
+
migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]));
|
|
3072
|
+
ctx.changes.push({
|
|
3073
|
+
type: "remove",
|
|
3074
|
+
line: migrateGetLine(ctx, node),
|
|
3075
|
+
description: "Removed memo() wrapper (not needed in Pyreon)"
|
|
3076
|
+
});
|
|
2426
3077
|
}
|
|
2427
|
-
if (ts.isBlock(stmt)) return stmt.statements.length === 1 && returnsNullStatement(stmt.statements[0]);
|
|
2428
|
-
return false;
|
|
2429
|
-
}
|
|
2430
|
-
/**
|
|
2431
|
-
* Returns true if the function looks like a top-level component:
|
|
2432
|
-
* - `function PascalName(...) { ... }` (FunctionDeclaration with PascalCase id), OR
|
|
2433
|
-
* - `const PascalName = (...) => { ... }` (arrow inside a VariableDeclaration whose name is PascalCase).
|
|
2434
|
-
*
|
|
2435
|
-
* Anonymous nested arrows — most importantly the reactive accessor
|
|
2436
|
-
* `return (() => { if (!cond()) return null; return <div /> })` — are
|
|
2437
|
-
* NOT considered components here, even when they contain JSX. Without
|
|
2438
|
-
* this filter the detector would fire on the very pattern the
|
|
2439
|
-
* diagnostic recommends as the fix.
|
|
2440
|
-
*/
|
|
2441
|
-
function isComponentShapedFunction(node) {
|
|
2442
|
-
if (ts.isFunctionDeclaration(node)) return !!node.name && /^[A-Z]/.test(node.name.text);
|
|
2443
|
-
const parent = node.parent;
|
|
2444
|
-
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return /^[A-Z]/.test(parent.name.text);
|
|
2445
|
-
return false;
|
|
2446
3078
|
}
|
|
2447
|
-
function
|
|
2448
|
-
|
|
2449
|
-
if (
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
return;
|
|
3079
|
+
function migrateForwardRef(ctx, node) {
|
|
3080
|
+
const callee = node.expression;
|
|
3081
|
+
if ((ts.isIdentifier(callee) && callee.text === "forwardRef" || isCallToReactDot(callee, "forwardRef")) && node.arguments[0]) {
|
|
3082
|
+
migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]));
|
|
3083
|
+
ctx.changes.push({
|
|
3084
|
+
type: "remove",
|
|
3085
|
+
line: migrateGetLine(ctx, node),
|
|
3086
|
+
description: "Removed forwardRef wrapper (pass ref as normal prop in Pyreon)"
|
|
3087
|
+
});
|
|
2457
3088
|
}
|
|
2458
3089
|
}
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
* - Name is a variable / template / spread, not a string literal
|
|
2490
|
-
* - Options come from a spread (`island(loader, opts)`)
|
|
2491
|
-
*
|
|
2492
|
-
* The same rules apply on the registry side (`detectIslandNeverWithRegistry`):
|
|
2493
|
-
* unrecognized keys won't match. Both halves are syntactic — a semantic
|
|
2494
|
-
* cross-package audit lives in `pyreon doctor --check-islands` (separate PR).
|
|
2495
|
-
*/
|
|
2496
|
-
function collectNeverIslandNames(sf) {
|
|
2497
|
-
const names = /* @__PURE__ */ new Set();
|
|
2498
|
-
function walk(node) {
|
|
2499
|
-
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "island" && node.arguments.length >= 2) {
|
|
2500
|
-
const opts = node.arguments[1];
|
|
2501
|
-
if (opts && ts.isObjectLiteralExpression(opts)) {
|
|
2502
|
-
let nameVal;
|
|
2503
|
-
let hydrateVal;
|
|
2504
|
-
for (const prop of opts.properties) {
|
|
2505
|
-
if (!ts.isPropertyAssignment(prop)) continue;
|
|
2506
|
-
const key = prop.name;
|
|
2507
|
-
const keyText = ts.isIdentifier(key) ? key.text : ts.isStringLiteral(key) ? key.text : "";
|
|
2508
|
-
if (keyText === "name" && ts.isStringLiteral(prop.initializer)) nameVal = prop.initializer.text;
|
|
2509
|
-
else if (keyText === "hydrate" && ts.isStringLiteral(prop.initializer)) hydrateVal = prop.initializer.text;
|
|
2510
|
-
}
|
|
2511
|
-
if (nameVal && hydrateVal === "never") names.add(nameVal);
|
|
3090
|
+
function migrateJsxAttributes(ctx, node) {
|
|
3091
|
+
const attrName = node.name.text;
|
|
3092
|
+
if (attrName in JSX_ATTR_REWRITES) {
|
|
3093
|
+
const htmlAttr = JSX_ATTR_REWRITES[attrName];
|
|
3094
|
+
ctx.replacements.push({
|
|
3095
|
+
start: node.name.getStart(ctx.sf),
|
|
3096
|
+
end: node.name.getEnd(),
|
|
3097
|
+
text: htmlAttr
|
|
3098
|
+
});
|
|
3099
|
+
ctx.changes.push({
|
|
3100
|
+
type: "replace",
|
|
3101
|
+
line: migrateGetLine(ctx, node),
|
|
3102
|
+
description: `${attrName} → ${htmlAttr}`
|
|
3103
|
+
});
|
|
3104
|
+
}
|
|
3105
|
+
if (attrName === "onChange") {
|
|
3106
|
+
const jsxElement = findParentJsxElement(node);
|
|
3107
|
+
if (jsxElement) {
|
|
3108
|
+
const tagName = getJsxTagName(jsxElement);
|
|
3109
|
+
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
|
|
3110
|
+
ctx.replacements.push({
|
|
3111
|
+
start: node.name.getStart(ctx.sf),
|
|
3112
|
+
end: node.name.getEnd(),
|
|
3113
|
+
text: "onInput"
|
|
3114
|
+
});
|
|
3115
|
+
ctx.changes.push({
|
|
3116
|
+
type: "replace",
|
|
3117
|
+
line: migrateGetLine(ctx, node),
|
|
3118
|
+
description: `onChange on <${tagName}> → onInput (native DOM events)`
|
|
3119
|
+
});
|
|
2512
3120
|
}
|
|
2513
3121
|
}
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
walk(sf);
|
|
2517
|
-
return names;
|
|
3122
|
+
}
|
|
3123
|
+
if (attrName === "dangerouslySetInnerHTML") migrateDangerouslySetInnerHTML(ctx, node);
|
|
2518
3124
|
}
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
for (const prop of arg.properties) {
|
|
2532
|
-
if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue;
|
|
2533
|
-
const key = prop.name;
|
|
2534
|
-
const keyText = ts.isIdentifier(key) ? key.text : ts.isStringLiteral(key) ? key.text : "";
|
|
2535
|
-
if (!keyText || !ctx.neverIslandNames.has(keyText)) continue;
|
|
2536
|
-
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);
|
|
3125
|
+
function migrateDangerouslySetInnerHTML(ctx, node) {
|
|
3126
|
+
if (!node.initializer || !ts.isJsxExpression(node.initializer) || !node.initializer.expression) return;
|
|
3127
|
+
const expr = node.initializer.expression;
|
|
3128
|
+
if (!ts.isObjectLiteralExpression(expr)) return;
|
|
3129
|
+
const htmlProp = expr.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "__html");
|
|
3130
|
+
if (htmlProp) {
|
|
3131
|
+
migrateReplace(ctx, node, `innerHTML={${migrateGetNodeText(ctx, htmlProp.initializer)}}`);
|
|
3132
|
+
ctx.changes.push({
|
|
3133
|
+
type: "replace",
|
|
3134
|
+
line: migrateGetLine(ctx, node),
|
|
3135
|
+
description: "dangerouslySetInnerHTML → innerHTML"
|
|
3136
|
+
});
|
|
2537
3137
|
}
|
|
2538
3138
|
}
|
|
2539
|
-
function
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
3139
|
+
function applyReplacements(code, ctx) {
|
|
3140
|
+
for (const imp of ctx.importsToRemove) {
|
|
3141
|
+
ctx.replacements.push({
|
|
3142
|
+
start: imp.getStart(ctx.sf),
|
|
3143
|
+
end: imp.getEnd(),
|
|
3144
|
+
text: ""
|
|
3145
|
+
});
|
|
3146
|
+
ctx.changes.push({
|
|
3147
|
+
type: "remove",
|
|
3148
|
+
line: ctx.sf.getLineAndCharacterOfPosition(imp.getStart(ctx.sf)).line + 1,
|
|
3149
|
+
description: "Removed React import"
|
|
3150
|
+
});
|
|
2544
3151
|
}
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
3152
|
+
ctx.replacements.sort((a, b) => b.start - a.start);
|
|
3153
|
+
const applied = /* @__PURE__ */ new Set();
|
|
3154
|
+
const deduped = [];
|
|
3155
|
+
for (const r of ctx.replacements) {
|
|
3156
|
+
const key = `${r.start}:${r.end}`;
|
|
3157
|
+
let overlaps = false;
|
|
3158
|
+
for (const d of deduped) if (r.start < d.end && r.end > d.start) {
|
|
3159
|
+
overlaps = true;
|
|
3160
|
+
break;
|
|
3161
|
+
}
|
|
3162
|
+
if (!overlaps && !applied.has(key)) {
|
|
3163
|
+
applied.add(key);
|
|
3164
|
+
deduped.push(r);
|
|
3165
|
+
}
|
|
2548
3166
|
}
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
3167
|
+
deduped.sort((a, b) => a.start - b.start);
|
|
3168
|
+
const parts = [];
|
|
3169
|
+
let lastPos = 0;
|
|
3170
|
+
for (const r of deduped) {
|
|
3171
|
+
parts.push(code.slice(lastPos, r.start));
|
|
3172
|
+
parts.push(r.text);
|
|
3173
|
+
lastPos = r.end;
|
|
2555
3174
|
}
|
|
2556
|
-
|
|
2557
|
-
|
|
3175
|
+
parts.push(code.slice(lastPos));
|
|
3176
|
+
return parts.join("");
|
|
2558
3177
|
}
|
|
2559
|
-
function
|
|
3178
|
+
function insertPyreonImports(code, pyreonImports) {
|
|
3179
|
+
if (pyreonImports.size === 0) return code;
|
|
3180
|
+
const importLines = [];
|
|
3181
|
+
const sorted = [...pyreonImports.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
3182
|
+
for (const [source, specs] of sorted) {
|
|
3183
|
+
const specList = [...specs].sort().join(", ");
|
|
3184
|
+
importLines.push(`import { ${specList} } from "${source}"`);
|
|
3185
|
+
}
|
|
3186
|
+
const importBlock = importLines.join("\n");
|
|
3187
|
+
const lastImportEnd = findLastImportEnd(code);
|
|
3188
|
+
if (lastImportEnd > 0) return `${code.slice(0, lastImportEnd)}\n${importBlock}${code.slice(lastImportEnd)}`;
|
|
3189
|
+
return `${importBlock}\n\n${code}`;
|
|
3190
|
+
}
|
|
3191
|
+
function migrateVisitNode(ctx, node) {
|
|
3192
|
+
if (ts.isImportDeclaration(node)) migrateImportDeclaration(ctx, node);
|
|
3193
|
+
if (isCallToHook(node, "useState")) migrateUseState(ctx, node);
|
|
3194
|
+
if (isCallToEffectHook(node)) migrateUseEffect(ctx, node);
|
|
3195
|
+
if (isCallToHook(node, "useMemo")) migrateUseMemo(ctx, node);
|
|
3196
|
+
if (isCallToHook(node, "useCallback")) migrateUseCallback(ctx, node);
|
|
3197
|
+
if (isCallToHook(node, "useRef")) migrateUseRef(ctx, node);
|
|
3198
|
+
if (ts.isCallExpression(node)) migrateMemoWrapper(ctx, node);
|
|
3199
|
+
if (ts.isCallExpression(node)) migrateForwardRef(ctx, node);
|
|
3200
|
+
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) migrateJsxAttributes(ctx, node);
|
|
3201
|
+
}
|
|
3202
|
+
function migrateVisit(ctx, node) {
|
|
2560
3203
|
ts.forEachChild(node, (child) => {
|
|
2561
|
-
|
|
2562
|
-
|
|
3204
|
+
migrateVisitNode(ctx, child);
|
|
3205
|
+
migrateVisit(ctx, child);
|
|
2563
3206
|
});
|
|
2564
3207
|
}
|
|
2565
|
-
function
|
|
3208
|
+
function migrateReactCode(code, filename = "input.tsx") {
|
|
2566
3209
|
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
|
|
3210
|
+
const diagnostics = detectReactPatterns(code, filename);
|
|
2567
3211
|
const ctx = {
|
|
2568
3212
|
sf,
|
|
2569
3213
|
code,
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
3214
|
+
replacements: [],
|
|
3215
|
+
changes: [],
|
|
3216
|
+
pyreonImports: /* @__PURE__ */ new Map(),
|
|
3217
|
+
importsToRemove: /* @__PURE__ */ new Set(),
|
|
3218
|
+
specifierRewrites: /* @__PURE__ */ new Map()
|
|
3219
|
+
};
|
|
3220
|
+
migrateVisit(ctx, sf);
|
|
3221
|
+
let result = applyReplacements(code, ctx);
|
|
3222
|
+
result = insertPyreonImports(result, ctx.pyreonImports);
|
|
3223
|
+
result = result.replace(/\n{3,}/g, "\n\n");
|
|
3224
|
+
return {
|
|
3225
|
+
code: result,
|
|
3226
|
+
diagnostics,
|
|
3227
|
+
changes: ctx.changes
|
|
2573
3228
|
};
|
|
2574
|
-
visit(ctx, sf);
|
|
2575
|
-
ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
2576
|
-
return ctx.diagnostics;
|
|
2577
3229
|
}
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
3230
|
+
function findParentJsxElement(node) {
|
|
3231
|
+
let current = node.parent;
|
|
3232
|
+
while (current) {
|
|
3233
|
+
if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current)) return current;
|
|
3234
|
+
if (ts.isJsxElement(current)) return current.openingElement;
|
|
3235
|
+
if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) return null;
|
|
3236
|
+
current = current.parent;
|
|
3237
|
+
}
|
|
3238
|
+
return null;
|
|
3239
|
+
}
|
|
3240
|
+
function getJsxTagName(node) {
|
|
3241
|
+
const tagName = node.tagName;
|
|
3242
|
+
if (ts.isIdentifier(tagName)) return tagName.text;
|
|
3243
|
+
return "";
|
|
3244
|
+
}
|
|
3245
|
+
function findLastImportEnd(code) {
|
|
3246
|
+
const importRe = /^import\s.+$/gm;
|
|
3247
|
+
let lastEnd = 0;
|
|
3248
|
+
let match;
|
|
3249
|
+
while (true) {
|
|
3250
|
+
match = importRe.exec(code);
|
|
3251
|
+
if (!match) break;
|
|
3252
|
+
lastEnd = match.index + match[0].length;
|
|
3253
|
+
}
|
|
3254
|
+
return lastEnd;
|
|
3255
|
+
}
|
|
3256
|
+
/** Fast regex check — returns true if code likely contains React patterns worth analyzing */
|
|
3257
|
+
function hasReactPatterns(code) {
|
|
3258
|
+
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);
|
|
3259
|
+
}
|
|
3260
|
+
const ERROR_PATTERNS = [
|
|
3261
|
+
{
|
|
3262
|
+
pattern: /Cannot read properties of undefined \(reading '(set|update|peek|subscribe)'\)/,
|
|
3263
|
+
diagnose: (m) => ({
|
|
3264
|
+
cause: `Calling .${m[1]}() on undefined. The signal variable is likely out of scope, misspelled, or not yet initialized.`,
|
|
3265
|
+
fix: "Check that the signal is defined and in scope. Signals must be created with signal() before use.",
|
|
3266
|
+
fixCode: `const mySignal = signal(initialValue)\nmySignal.${m[1]}(newValue)`
|
|
3267
|
+
})
|
|
3268
|
+
},
|
|
3269
|
+
{
|
|
3270
|
+
pattern: /(\w+) is not a function/,
|
|
3271
|
+
diagnose: (m) => ({
|
|
3272
|
+
cause: `'${m[1]}' is not callable. If this is a signal, you need to call it: ${m[1]}()`,
|
|
3273
|
+
fix: "Pyreon signals are callable functions. Read: signal(), Write: signal.set(value)",
|
|
3274
|
+
fixCode: `// Read value:\nconst value = ${m[1]}()\n// Set value:\n${m[1]}.set(newValue)`
|
|
3275
|
+
})
|
|
3276
|
+
},
|
|
3277
|
+
{
|
|
3278
|
+
pattern: /Cannot find module '(@pyreon\/\w[\w-]*)'/,
|
|
3279
|
+
diagnose: (m) => ({
|
|
3280
|
+
cause: `Package ${m[1]} is not installed.`,
|
|
3281
|
+
fix: `Run: bun add ${m[1]}`,
|
|
3282
|
+
fixCode: `bun add ${m[1]}`
|
|
3283
|
+
})
|
|
3284
|
+
},
|
|
3285
|
+
{
|
|
3286
|
+
pattern: /Cannot find module 'react'/,
|
|
3287
|
+
diagnose: () => ({
|
|
3288
|
+
cause: "Importing from 'react' in a Pyreon project.",
|
|
3289
|
+
fix: "Replace React imports with Pyreon equivalents.",
|
|
3290
|
+
fixCode: "// Instead of:\nimport { useState } from \"react\"\n// Use:\nimport { signal } from \"@pyreon/reactivity\""
|
|
3291
|
+
})
|
|
3292
|
+
},
|
|
3293
|
+
{
|
|
3294
|
+
pattern: /Property '(\w+)' does not exist on type 'Signal<\w+>'/,
|
|
3295
|
+
diagnose: (m) => ({
|
|
3296
|
+
cause: `Accessing .${m[1]} on a signal. Pyreon signals don't have a .${m[1]} property.`,
|
|
3297
|
+
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.`,
|
|
3298
|
+
fixCode: m[1] === "value" ? "// Read: mySignal()\n// Write: mySignal.set(newValue)" : void 0
|
|
3299
|
+
})
|
|
3300
|
+
},
|
|
3301
|
+
{
|
|
3302
|
+
pattern: /Type '(\w+)' is not assignable to type 'VNode'/,
|
|
3303
|
+
diagnose: (m) => ({
|
|
3304
|
+
cause: `Component returned ${m[1]} instead of VNode. Components must return JSX, null, or a string.`,
|
|
3305
|
+
fix: "Make sure your component returns a JSX element, null, or a string.",
|
|
3306
|
+
fixCode: "const MyComponent = (props) => {\n return <div>{props.children}</div>\n}"
|
|
3307
|
+
})
|
|
3308
|
+
},
|
|
3309
|
+
{
|
|
3310
|
+
pattern: /onMount callback must return/,
|
|
3311
|
+
diagnose: () => ({
|
|
3312
|
+
cause: "onMount expects a callback that optionally returns a CleanupFn.",
|
|
3313
|
+
fix: "Return a cleanup function, or return nothing.",
|
|
3314
|
+
fixCode: "onMount(() => {\n // setup code\n})"
|
|
3315
|
+
})
|
|
3316
|
+
},
|
|
3317
|
+
{
|
|
3318
|
+
pattern: /Expected 'by' prop on <For>/,
|
|
3319
|
+
diagnose: () => ({
|
|
3320
|
+
cause: "<For> requires a 'by' prop for efficient keyed reconciliation.",
|
|
3321
|
+
fix: "Add a by prop that returns a unique key for each item.",
|
|
3322
|
+
fixCode: "<For each={items()} by={item => item.id}>\n {item => <li>{item.name}</li>}\n</For>"
|
|
3323
|
+
})
|
|
3324
|
+
},
|
|
3325
|
+
{
|
|
3326
|
+
pattern: /useHook.*outside.*component/i,
|
|
3327
|
+
diagnose: () => ({
|
|
3328
|
+
cause: "Hook called outside a component function. Pyreon hooks must be called during component setup.",
|
|
3329
|
+
fix: "Move the hook call inside a component function body."
|
|
3330
|
+
})
|
|
3331
|
+
},
|
|
3332
|
+
{
|
|
3333
|
+
pattern: /Hydration mismatch/,
|
|
3334
|
+
diagnose: () => ({
|
|
3335
|
+
cause: "Server-rendered HTML doesn't match client-rendered output.",
|
|
3336
|
+
fix: "Ensure SSR and client render the same initial content. Check for browser-only APIs (window, document) in SSR code.",
|
|
3337
|
+
related: "Use typeof window !== 'undefined' checks or onMount() for client-only code."
|
|
3338
|
+
})
|
|
3339
|
+
}
|
|
3340
|
+
];
|
|
3341
|
+
/** Diagnose an error message and return structured fix information */
|
|
3342
|
+
function diagnoseError(error) {
|
|
3343
|
+
for (const { pattern, diagnose } of ERROR_PATTERNS) {
|
|
3344
|
+
const match = error.match(pattern);
|
|
3345
|
+
if (match) return diagnose(match);
|
|
3346
|
+
}
|
|
3347
|
+
return null;
|
|
2581
3348
|
}
|
|
2582
3349
|
|
|
2583
3350
|
//#endregion
|
|
@@ -3671,5 +4438,5 @@ function formatSsgAudit(result, _options = {}) {
|
|
|
3671
4438
|
}
|
|
3672
4439
|
|
|
3673
4440
|
//#endregion
|
|
3674
|
-
export { auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformJSX, transformJSX_JS };
|
|
4441
|
+
export { analyzeReactivity, auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatReactivityLens, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformDeferInline, transformJSX, transformJSX_JS };
|
|
3675
4442
|
//# sourceMappingURL=index.js.map
|