@pyreon/compiler 0.18.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/index.js CHANGED
@@ -3,9 +3,9 @@ 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
10
  //#region src/defer-inline.ts
11
11
  /**
@@ -14,40 +14,38 @@ import ts from "typescript";
14
14
  * Rewrites:
15
15
  *
16
16
  * import { Modal } from './Modal'
17
- * <Defer when={open()}><Modal /></Defer>
17
+ * <Defer when={open()}><Modal title="hi" /></Defer>
18
18
  *
19
19
  * into:
20
20
  *
21
- * <Defer when={open()} chunk={() => import('./Modal').then(m => ({ default: m.Modal }))}>
22
- * {C => <C />}
21
+ * <Defer when={open()} chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}>
22
+ * {(__C) => <__C title="hi" />}
23
23
  * </Defer>
24
24
  *
25
25
  * The static `import { Modal } from './Modal'` is removed when `Modal` is
26
26
  * referenced ONLY inside the Defer subtree — otherwise Rolldown would
27
27
  * bundle the module statically and the dynamic import becomes a no-op.
28
28
  *
29
- * Scope of v1 (this file):
30
- * - Single Defer element per file (no nested handling — bail otherwise).
31
- * - Children: exactly ONE JSXElement, self-closing, capitalised name
32
- * (component reference), no props. Props or multiple children leave
33
- * the Defer untransformed (user must use the explicit `chunk` form).
34
- * - Imports: named OR default. Namespace imports (`import * as Mod`)
35
- * and destructured-renamed (`{ X as Y }`) not handled in v1.
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.
36
42
  * - Triggers (`when={...}`, `on="visible"`, `on="idle"`) pass through.
37
43
  * - Other props on `<Defer>` (e.g. `fallback`) pass through.
38
44
  *
39
45
  * The transform is intentionally conservative — anything unusual leaves
40
- * the source unchanged + emits a warning. v2 follow-ups can relax these
41
- * constraints with closure-capture handling, namespace imports, etc.
42
- *
43
- * Pipeline: this runs BEFORE `transformJSX()` in the vite plugin. The
44
- * output is still JSX — `transformJSX` then converts it to `h()` /
45
- * `_tpl()` calls as usual.
46
- */
47
- /**
48
- * Detect the language for `parseSync`. `oxc-parser` infers from filename
49
- * by extension — we mirror the same logic for the few extensions we
50
- * support so the parser is invoked correctly.
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.
51
49
  */
52
50
  function getLang$1(filename) {
53
51
  if (filename.endsWith(".tsx")) return "tsx";
@@ -57,9 +55,9 @@ function getLang$1(filename) {
57
55
  }
58
56
  /**
59
57
  * Returns the JSX tag name as a string when the opening element's name
60
- * is a simple identifier (the only shape we recognise as a "named JSX
61
- * element"). Member-expression names (`<obj.X />`) and namespaced names
62
- * (`<svg:rect />`) return null — the caller treats those as non-matches.
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.
63
61
  */
64
62
  function getJsxName(node) {
65
63
  const open = node.openingElement;
@@ -69,33 +67,83 @@ function getJsxName(node) {
69
67
  return name.name;
70
68
  }
71
69
  /**
72
- * `<Tag />` qualifies as a "bare component reference child" when:
73
- * - It's a JSXElement (not text, fragment, or expression container).
74
- * - The opening name is a capitalised JSXIdentifier (component).
75
- * - It has no attributes (no props passed).
76
- * - It's self-closing OR has zero non-whitespace children.
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.
77
75
  */
78
- function isBareComponentChild(node) {
79
- if (node.type !== "JSXElement") return null;
80
- const tag = getJsxName(node);
81
- if (!tag || !/^[A-Z]/.test(tag)) return null;
82
- if ((node.openingElement.attributes ?? []).length > 0) return null;
83
- const children = node.children ?? [];
84
- for (const child of children) {
85
- if (child.type === "JSXText" && /^\s*$/.test(child.value)) continue;
86
- return null;
87
- }
88
- return { name: tag };
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
89
  }
90
90
  /**
91
- * Filter whitespace-only JSXText nodes; return remaining children. JSX
92
- * source like `<Defer>\n <Modal />\n</Defer>` has 3 children at the AST
93
- * level: text, element, text. The text nodes are formatting noise.
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.
94
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). */
95
143
  function nonWhitespaceChildren(node) {
96
144
  return (node.children ?? []).filter((c) => !(c.type === "JSXText" && /^\s*$/.test(c.value)));
97
145
  }
98
- function findDeferMatches(program) {
146
+ function findDeferMatches(program, warnings, code) {
99
147
  const matches = [];
100
148
  const walk = (node) => {
101
149
  if (!node || typeof node !== "object") return;
@@ -103,20 +151,36 @@ function findDeferMatches(program) {
103
151
  const open = node.openingElement;
104
152
  if (!(open.attributes ?? []).some((a) => a.type === "JSXAttribute" && a.name?.type === "JSXIdentifier" && a.name.name === "chunk")) {
105
153
  const live = nonWhitespaceChildren(node);
106
- if (live.length === 1) {
107
- const childInfo = isBareComponentChild(live[0]);
108
- if (childInfo) {
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) {
109
165
  const close = node.closingElement;
110
166
  matches.push({
111
167
  node,
112
168
  child: live[0],
113
- childName: childInfo.name,
169
+ childAnalysis: analysis,
114
170
  insertChunkAt: open.end - 1,
115
171
  childrenRange: {
116
172
  start: open.end,
117
173
  end: close?.start ?? node.end
118
174
  }
119
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
+ });
120
184
  }
121
185
  }
122
186
  }
@@ -131,69 +195,117 @@ function findDeferMatches(program) {
131
195
  walk(program);
132
196
  return matches;
133
197
  }
134
- function findImportFor(program, name) {
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) {
135
207
  const body = program.body ?? [];
136
208
  for (const stmt of body) {
137
209
  if (stmt.type !== "ImportDeclaration") continue;
138
210
  const specifiers = stmt.specifiers ?? [];
139
211
  for (const spec of specifiers) if (spec.type === "ImportDefaultSpecifier") {
140
- if (spec.local.name === name) return {
141
- node: stmt,
212
+ if (spec.local.name === localName) return {
213
+ declaration: stmt,
214
+ specifier: spec,
142
215
  source: stmt.source.value,
143
- kind: "default"
216
+ kind: "default",
217
+ importedName: "default"
144
218
  };
145
219
  } else if (spec.type === "ImportSpecifier") {
146
- const local = spec.local.name;
147
- const imported = spec.imported?.name;
148
- if (local === name && imported !== void 0 && imported === local) return {
149
- node: stmt,
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,
150
225
  source: stmt.source.value,
151
- kind: "named"
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: ""
152
236
  };
153
237
  }
154
238
  }
155
239
  return null;
156
240
  }
157
241
  /**
158
- * Count references to `name` outside the given JSXElement subtree. The
159
- * static import can only be safely removed if the binding is used
160
- * EXCLUSIVELY inside that subtree.
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).
161
248
  */
162
- function countReferencesOutside(program, name, skipSubtree) {
249
+ function countReferencesOutside(program, localName, skipSubtree, skipDeclaration) {
163
250
  let count = 0;
164
251
  const skipStart = skipSubtree.start;
165
252
  const skipEnd = skipSubtree.end;
166
- const countInNode = (node) => {
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) => {
167
257
  if (!node || typeof node !== "object") return;
168
258
  const ns = node.start;
169
259
  const ne = node.end;
170
- if (typeof ns === "number" && typeof ne === "number" && ns >= skipStart && ne <= skipEnd) return;
171
- if (node.type === "Identifier" && node.name === name) count++;
172
- if (node.type === "JSXIdentifier" && node.name === name) count++;
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++;
173
263
  for (const key in node) {
174
264
  if (key === "parent") continue;
175
265
  const v = node[key];
176
- if (Array.isArray(v)) for (const item of v) countInNode(item);
177
- else if (v && typeof v === "object" && typeof v.type === "string") countInNode(v);
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);
178
268
  }
179
269
  };
180
270
  const body = program.body ?? [];
181
- for (const stmt of body) {
182
- if (stmt.type === "ImportDeclaration") continue;
183
- countInNode(stmt);
184
- }
271
+ for (const stmt of body) visit(stmt);
185
272
  return count;
186
273
  }
187
- /** Build the chunk={...} attribute string for a default or named import. */
188
- function buildChunkAttr(source, kind, name) {
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) {
189
287
  if (kind === "default") return ` chunk={() => import('${source}')}`;
190
- return ` chunk={() => import('${source}').then((__m) => ({ default: __m.${name} }))}`;
288
+ return ` chunk={() => import('${source}').then((__m) => ({ default: __m.${exportName} }))}`;
191
289
  }
192
290
  /**
193
- * Apply edits to the source string. Edits MUST be non-overlapping; we
194
- * sort by start descending and splice each into the source so earlier
195
- * positions stay valid as we work backwards.
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.
196
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
+ }
197
309
  function applyEdits(source, edits) {
198
310
  const sorted = [...edits].sort((a, b) => b.start - a.start);
199
311
  let out = source;
@@ -201,22 +313,44 @@ function applyEdits(source, edits) {
201
313
  return out;
202
314
  }
203
315
  /**
204
- * Main entry. Returns the (possibly transformed) source plus the list
205
- * of warnings for cases the transform deliberately skipped.
206
- *
207
- * Bails (returns input unchanged with `changed: false`) when:
208
- * - No `<Defer>` JSX element appears in the file (fast path).
209
- * - The file fails to parse (syntax error let downstream handle).
210
- * - No `<Defer>` matches the inline-eligible shape.
211
- *
212
- * Per-Defer skips with a warning:
213
- * - Multiple children user must use render-prop form
214
- * - Child has props → user must use render-prop form
215
- * - Child name isn't imported → can't resolve the chunk source
216
- * - Child binding is used outside the Defer subtree → can't remove
217
- * the static import (dynamic import would be a no-op via Rolldown's
218
- * same-module dedup)
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.
219
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
+ }
220
354
  function transformDeferInline(code, filename = "input.tsx") {
221
355
  const warnings = [];
222
356
  if (!code.includes("Defer")) return {
@@ -237,7 +371,7 @@ function transformDeferInline(code, filename = "input.tsx") {
237
371
  warnings
238
372
  };
239
373
  }
240
- const matches = findDeferMatches(program);
374
+ const matches = findDeferMatches(program, warnings, code);
241
375
  if (matches.length === 0) return {
242
376
  code,
243
377
  changed: false,
@@ -246,45 +380,51 @@ function transformDeferInline(code, filename = "input.tsx") {
246
380
  const edits = [];
247
381
  let changed = false;
248
382
  for (const m of matches) {
249
- const importInfo = findImportFor(program, m.childName);
383
+ const displayName = m.childAnalysis.kind === "member" ? `${m.childAnalysis.lookupName}.${m.childAnalysis.propertyName}` : m.childAnalysis.lookupName;
384
+ const importInfo = findImportFor(program, m.childAnalysis.lookupName);
250
385
  if (!importInfo) {
251
386
  const loc = getLoc(code, m.child.start ?? 0);
252
387
  warnings.push({
253
- message: `<Defer>'s inline child <${m.childName} /> isn't imported — can't resolve a chunk source. Use the explicit \`chunk\` prop, or import ${m.childName} from a module.`,
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.`,
254
389
  line: loc.line,
255
390
  column: loc.column,
256
391
  code: "defer-inline/import-not-found"
257
392
  });
258
393
  continue;
259
394
  }
260
- if (countReferencesOutside(program, m.childName, m.node) > 0) {
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) {
261
407
  const loc = getLoc(code, m.node.start ?? 0);
262
408
  warnings.push({
263
- message: `<Defer>'s inline child <${m.childName} /> 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.`,
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.`,
264
410
  line: loc.line,
265
411
  column: loc.column,
266
412
  code: "defer-inline/import-used-elsewhere"
267
413
  });
268
414
  continue;
269
415
  }
416
+ const exportName = importInfo.kind === "namespace" ? m.childAnalysis.propertyName : importInfo.importedName;
270
417
  edits.push({
271
418
  start: m.insertChunkAt,
272
419
  end: m.insertChunkAt,
273
- replacement: buildChunkAttr(importInfo.source, importInfo.kind, m.childName)
420
+ replacement: buildChunkAttr(importInfo.source, importInfo.kind, exportName)
274
421
  });
275
422
  edits.push({
276
423
  start: m.childrenRange.start,
277
424
  end: m.childrenRange.end,
278
- replacement: `{(__C) => <__C />}`
279
- });
280
- const impStart = importInfo.node.start;
281
- let impEnd = importInfo.node.end;
282
- if (code[impEnd] === "\n") impEnd += 1;
283
- edits.push({
284
- start: impStart,
285
- end: impEnd,
286
- replacement: ""
425
+ replacement: buildRenderPropBody(code, m.childAnalysis, m.childrenRange)
287
426
  });
427
+ edits.push(buildImportRemovalEdit(code, importInfo));
288
428
  changed = true;
289
429
  }
290
430
  if (!changed) return {
@@ -298,7 +438,6 @@ function transformDeferInline(code, filename = "input.tsx") {
298
438
  warnings
299
439
  };
300
440
  }
301
- /** Resolve a byte offset into 1-based line + 0-based column. */
302
441
  function getLoc(code, offset) {
303
442
  let line = 1;
304
443
  let lastNl = -1;
@@ -572,7 +711,7 @@ function jsxChildren(node) {
572
711
  }
573
712
  function transformJSX(code, filename = "input.tsx", options = {}) {
574
713
  if (nativeTransformJsx) try {
575
- return nativeTransformJsx(code, filename, options.ssr === true, options.knownSignals ?? null);
714
+ return nativeTransformJsx(code, filename, options.ssr === true, options.knownSignals ?? null, options.reactivityLens === true);
576
715
  } catch {}
577
716
  return transformJSX_JS(code, filename, options);
578
717
  }
@@ -603,6 +742,23 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
603
742
  code: warnCode
604
743
  });
605
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
+ }
606
762
  const parentMap = /* @__PURE__ */ new WeakMap();
607
763
  const childrenMap = /* @__PURE__ */ new WeakMap();
608
764
  /** Build parent pointers + cached children arrays for the entire AST. */
@@ -653,6 +809,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
653
809
  name,
654
810
  text
655
811
  });
812
+ lens(node.start, node.end, "hoisted-static", "static — hoisted once to module scope, never re-evaluated");
656
813
  return name;
657
814
  }
658
815
  return null;
@@ -667,6 +824,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
667
824
  end,
668
825
  text
669
826
  });
827
+ lens(start, end, "reactive", "live — re-evaluates whenever its signals change");
670
828
  }
671
829
  function hoistOrWrap(expr) {
672
830
  const hoistName = maybeHoist(expr);
@@ -763,6 +921,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
763
921
  text: `_rp(() => ${inner})`
764
922
  });
765
923
  needsRpImport = true;
924
+ lens(start, end, "reactive-prop", "live prop — signal reads here are tracked into the component");
766
925
  }
767
926
  } else hoistOrWrap(expr);
768
927
  }
@@ -1036,7 +1195,11 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
1036
1195
  if (scopeShadows) for (const name of scopeShadows) shadowedSignals.delete(name);
1037
1196
  }
1038
1197
  walkNode(program);
1039
- 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
+ } : {
1040
1203
  code,
1041
1204
  warnings
1042
1205
  };
@@ -1066,7 +1229,12 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
1066
1229
  if (needsWrapSpreadImport) coreImports.push("_wrapSpread");
1067
1230
  output = `import { ${coreImports.join(", ")} } from "@pyreon/core";\n` + output;
1068
1231
  }
1069
- return {
1232
+ return collectLens ? {
1233
+ code: output,
1234
+ usesTemplates: needsTplImport,
1235
+ warnings,
1236
+ reactivityLens
1237
+ } : {
1070
1238
  code: output,
1071
1239
  usesTemplates: needsTplImport,
1072
1240
  warnings
@@ -1203,6 +1371,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
1203
1371
  bindLines.push(attrSetter(htmlAttrName, varName, expr));
1204
1372
  return;
1205
1373
  }
1374
+ lens(exprNode.start, exprNode.end, "reactive-attr", `live attribute — \`${htmlAttrName}\` re-applies whenever its signals change`);
1206
1375
  const directRef = tryDirectSignalRef(exprNode);
1207
1376
  if (directRef) {
1208
1377
  needsBindDirectImport = true;
@@ -1363,7 +1532,12 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
1363
1532
  bindLines.push(`const ${d} = _mountSlot(${expr}, ${parentRef}, ${placeholder})`);
1364
1533
  return "<!>";
1365
1534
  }
1366
- if (isReactive) return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder);
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)");
1367
1541
  return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder);
1368
1542
  }
1369
1543
  function processChildren(el, varName, accessor) {
@@ -1666,1266 +1840,1511 @@ function isPureStaticCall(node) {
1666
1840
  }
1667
1841
 
1668
1842
  //#endregion
1669
- //#region src/project-scanner.ts
1843
+ //#region src/pyreon-intercept.ts
1670
1844
  /**
1671
- * Project scannerextracts route, component, and island information from source files.
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
1672
1920
  */
1673
- function generateContext(cwd) {
1674
- const files = collectSourceFiles(cwd);
1675
- return {
1676
- framework: "pyreon",
1677
- version: readVersion(cwd),
1678
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1679
- routes: extractRoutes(files, cwd),
1680
- components: extractComponents(files, cwd),
1681
- islands: extractIslands(files, cwd)
1682
- };
1921
+ function getNodeText(ctx, node) {
1922
+ return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
1683
1923
  }
1684
- function collectSourceFiles(cwd) {
1685
- const results = [];
1686
- const extensions = new Set([
1687
- ".tsx",
1688
- ".jsx",
1689
- ".ts",
1690
- ".js"
1691
- ]);
1692
- const ignoreDirs = new Set([
1693
- "node_modules",
1694
- "dist",
1695
- "lib",
1696
- ".pyreon",
1697
- ".git",
1698
- "build"
1699
- ]);
1700
- function walk(dir) {
1701
- let entries;
1702
- try {
1703
- entries = fs.readdirSync(dir, { withFileTypes: true });
1704
- } catch {
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
+ });
1935
+ }
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;
1705
1958
  return;
1706
1959
  }
1707
- for (const entry of entries) {
1708
- if (entry.name.startsWith(".") && entry.isDirectory()) continue;
1709
- if (ignoreDirs.has(entry.name) && entry.isDirectory()) continue;
1710
- const fullPath = path.join(dir, entry.name);
1711
- if (entry.isDirectory()) walk(fullPath);
1712
- else if (entry.isFile() && extensions.has(path.extname(entry.name))) results.push(fullPath);
1713
- }
1960
+ ts.forEachChild(n, walk);
1714
1961
  }
1715
- walk(cwd);
1716
- return results;
1717
- }
1718
- function extractRoutes(files, _cwd) {
1719
- const routes = [];
1720
- for (const file of files) {
1721
- let code;
1722
- try {
1723
- code = fs.readFileSync(file, "utf-8");
1724
- } catch {
1725
- continue;
1726
- }
1727
- const routeArrayRe = /(?:createRouter\s*\(\s*\[|(?:const|let)\s+routes\s*(?::\s*RouteRecord\[\])?\s*=\s*\[)([\s\S]*?)\]/g;
1728
- let match;
1729
- for (match = routeArrayRe.exec(code); match; match = routeArrayRe.exec(code)) {
1730
- const block = match[1] ?? "";
1731
- const routeObjRe = /path\s*:\s*["']([^"']+)["']/g;
1732
- let routeMatch;
1733
- for (routeMatch = routeObjRe.exec(block); routeMatch; routeMatch = routeObjRe.exec(block)) {
1734
- const routePath = routeMatch[1] ?? "";
1735
- const surroundingStart = Math.max(0, routeMatch.index - 50);
1736
- const surroundingEnd = Math.min(block.length, routeMatch.index + 200);
1737
- const surrounding = block.slice(surroundingStart, surroundingEnd);
1738
- routes.push({
1739
- path: routePath,
1740
- name: surrounding.match(/name\s*:\s*["']([^"']+)["']/)?.[1],
1741
- hasLoader: /loader\s*:/.test(surrounding),
1742
- hasGuard: /beforeEnter\s*:|beforeLeave\s*:/.test(surrounding),
1743
- params: extractParams(routePath)
1744
- });
1745
- }
1746
- }
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;
1747
1965
  }
1748
- return routes;
1966
+ return found;
1749
1967
  }
1750
- function extractComponents(files, cwd) {
1751
- const components = [];
1752
- for (const file of files) {
1753
- let code;
1754
- try {
1755
- code = fs.readFileSync(file, "utf-8");
1756
- } catch {
1757
- continue;
1758
- }
1759
- const componentRe = /(?:export\s+)?(?:const|function)\s+([A-Z]\w*)\s*(?::\s*ComponentFn<[^>]+>\s*)?=?\s*\(?(?:\s*\{?\s*([^)]*?)\s*\}?\s*)?\)?\s*(?:=>|{)/g;
1760
- let match;
1761
- for (match = componentRe.exec(code); match; match = componentRe.exec(code)) {
1762
- const name = match[1] ?? "Unknown";
1763
- const props = (match[2] ?? "").split(/[,;]/).map((p) => p.trim().replace(/[{}]/g, "").trim().split(":")[0]?.split("=")[0]?.trim() ?? "").filter((p) => p && p !== "props");
1764
- const bodyStart = match.index + match[0].length;
1765
- const body = code.slice(bodyStart, Math.min(code.length, bodyStart + 2e3));
1766
- const signalNames = [];
1767
- const signalRe = /(?:const|let)\s+(\w+)\s*=\s*signal\s*[<(]/g;
1768
- let sigMatch;
1769
- for (sigMatch = signalRe.exec(body); sigMatch; sigMatch = signalRe.exec(body)) if (sigMatch[1]) signalNames.push(sigMatch[1]);
1770
- components.push({
1771
- name,
1772
- file: path.relative(cwd, file),
1773
- hasSignals: signalNames.length > 0,
1774
- signalNames,
1775
- props
1776
- });
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;
1990
+ }
1991
+ return cur;
1992
+ }
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);
1777
2036
  }
2037
+ ts.forEachChild(n, walk);
1778
2038
  }
1779
- return components;
2039
+ for (const stmt of body.statements) walk(stmt);
1780
2040
  }
1781
- function extractIslands(files, cwd) {
1782
- const islands = [];
1783
- for (const file of files) {
1784
- let code;
1785
- try {
1786
- code = fs.readFileSync(file, "utf-8");
1787
- } catch {
1788
- continue;
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";
2047
+ }
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;
1789
2120
  }
1790
- const islandRe = /island\s*\(\s*\(\)\s*=>\s*import\(.+?\)\s*,\s*\{[^}]*name\s*:\s*["']([^"']+)["'][^}]*?(?:hydrate\s*:\s*["']([^"']+)["'])?[^}]*\}/g;
1791
- let match;
1792
- for (match = islandRe.exec(code); match; match = islandRe.exec(code)) if (match[1]) islands.push({
1793
- name: match[1],
1794
- file: path.relative(cwd, file),
1795
- hydrate: match[2] ?? "load"
1796
- });
2121
+ ts.forEachChild(n, walk);
1797
2122
  }
1798
- return islands;
2123
+ walk(node);
2124
+ return found;
1799
2125
  }
1800
- function extractParams(routePath) {
1801
- const params = [];
1802
- const paramRe = /:(\w+)\??/g;
1803
- let match;
1804
- for (match = paramRe.exec(routePath); match; match = paramRe.exec(routePath)) if (match[1]) params.push(match[1]);
1805
- return params;
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);
1806
2130
  }
1807
- function readVersion(cwd) {
1808
- try {
1809
- const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"));
1810
- const deps = {
1811
- ...pkg.dependencies,
1812
- ...pkg.devDependencies
1813
- };
1814
- for (const [name, ver] of Object.entries(deps)) if (name.startsWith("@pyreon/") && typeof ver === "string") return ver.replace(/^[\^~]/, "");
1815
- return pkg.version || "unknown";
1816
- } catch {
1817
- return "unknown";
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);
2140
+ }
2141
+ /**
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.
2146
+ *
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";
2161
+ }
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);
1818
2168
  }
2169
+ walk(sf);
2170
+ return names;
2171
+ }
2172
+ function detectSignalWriteAsCall(ctx, node) {
2173
+ if (ctx.signalBindings.size === 0) return;
2174
+ const callee = node.expression;
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);
1819
2179
  }
1820
-
1821
- //#endregion
1822
- //#region src/react-intercept.ts
1823
2180
  /**
1824
- * React Pattern Interceptor detects React/Vue patterns in code and provides
1825
- * structured diagnostics with exact fix suggestions for AI-assisted migration.
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.
1826
2185
  *
1827
- * Two modes:
1828
- * - `detectReactPatterns(code)` returns diagnostics only (non-destructive)
1829
- * - `migrateReactCode(code)` — applies auto-fixes and returns transformed code
2186
+ * The fix is to wrap the conditional in a returned reactive accessor:
2187
+ * return (() => { if (!cond()) return null; return <div /> })
1830
2188
  *
1831
- * Designed for three consumers:
1832
- * 1. Compiler pre-pass (warnings during build)
1833
- * 2. CLI `pyreon doctor` (project-wide scanning)
1834
- * 3. MCP server `migrate_react` / `validate` tools (AI agent integration)
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)
1835
2196
  */
1836
- /** React import sources → Pyreon equivalents */
1837
- const IMPORT_REWRITES = {
1838
- react: "@pyreon/core",
1839
- "react-dom": "@pyreon/runtime-dom",
1840
- "react-dom/client": "@pyreon/runtime-dom",
1841
- "react-dom/server": "@pyreon/runtime-server",
1842
- "react-router": "@pyreon/router",
1843
- "react-router-dom": "@pyreon/router"
1844
- };
1845
- /** React specifiers that map to specific Pyreon imports */
1846
- const SPECIFIER_REWRITES = {
1847
- useState: {
1848
- name: "signal",
1849
- from: "@pyreon/reactivity"
1850
- },
1851
- useEffect: {
1852
- name: "effect",
1853
- from: "@pyreon/reactivity"
1854
- },
1855
- useLayoutEffect: {
1856
- name: "effect",
1857
- from: "@pyreon/reactivity"
1858
- },
1859
- useMemo: {
1860
- name: "computed",
1861
- from: "@pyreon/reactivity"
1862
- },
1863
- useReducer: {
1864
- name: "signal",
1865
- from: "@pyreon/reactivity"
1866
- },
1867
- useRef: {
1868
- name: "signal",
1869
- from: "@pyreon/reactivity"
1870
- },
1871
- createContext: {
1872
- name: "createContext",
1873
- from: "@pyreon/core"
1874
- },
1875
- useContext: {
1876
- name: "useContext",
1877
- from: "@pyreon/core"
1878
- },
1879
- Fragment: {
1880
- name: "Fragment",
1881
- from: "@pyreon/core"
1882
- },
1883
- Suspense: {
1884
- name: "Suspense",
1885
- from: "@pyreon/core"
1886
- },
1887
- lazy: {
1888
- name: "lazy",
1889
- from: "@pyreon/core"
1890
- },
1891
- memo: {
1892
- name: "",
1893
- from: ""
1894
- },
1895
- forwardRef: {
1896
- name: "",
1897
- from: ""
1898
- },
1899
- createRoot: {
1900
- name: "mount",
1901
- from: "@pyreon/runtime-dom"
1902
- },
1903
- hydrateRoot: {
1904
- name: "hydrateRoot",
1905
- from: "@pyreon/runtime-dom"
1906
- },
1907
- useNavigate: {
1908
- name: "useRouter",
1909
- from: "@pyreon/router"
1910
- },
1911
- useParams: {
1912
- name: "useRoute",
1913
- from: "@pyreon/router"
1914
- },
1915
- useLocation: {
1916
- name: "useRoute",
1917
- from: "@pyreon/router"
1918
- },
1919
- Link: {
1920
- name: "RouterLink",
1921
- from: "@pyreon/router"
1922
- },
1923
- NavLink: {
1924
- name: "RouterLink",
1925
- from: "@pyreon/router"
1926
- },
1927
- Outlet: {
1928
- name: "RouterView",
1929
- from: "@pyreon/router"
1930
- },
1931
- useSearchParams: {
1932
- name: "useSearchParams",
1933
- from: "@pyreon/router"
1934
- }
1935
- };
1936
- /** JSX attribute rewrites (React → standard HTML) */
1937
- const JSX_ATTR_REWRITES = {
1938
- className: "class",
1939
- htmlFor: "for"
1940
- };
1941
- function detectGetNodeText(ctx, node) {
1942
- return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
1943
- }
1944
- function detectDiag(ctx, node, diagCode, message, current, suggested, fixable) {
1945
- const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf));
1946
- ctx.diagnostics.push({
1947
- code: diagCode,
1948
- message,
1949
- line: line + 1,
1950
- column: character,
1951
- current: current.trim(),
1952
- suggested: suggested.trim(),
1953
- fixable
1954
- });
1955
- }
1956
- function detectImportDeclaration(ctx, node) {
1957
- if (!node.moduleSpecifier) return;
1958
- const source = node.moduleSpecifier.text;
1959
- const pyreonSource = IMPORT_REWRITES[source];
1960
- if (pyreonSource !== void 0) {
1961
- if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) for (const spec of node.importClause.namedBindings.elements) ctx.reactImportedHooks.add(spec.name.text);
1962
- detectDiag(ctx, node, source.startsWith("react-router") ? "react-router-import" : source.startsWith("react-dom") ? "react-dom-import" : "react-import", `Import from '${source}' is a React package. Use Pyreon equivalent.`, detectGetNodeText(ctx, node), pyreonSource ? `import { ... } from "${pyreonSource}"` : "Remove this import — not needed in Pyreon", true);
2197
+ function returnsNullStatement(stmt) {
2198
+ if (ts.isReturnStatement(stmt)) {
2199
+ const expr = stmt.expression;
2200
+ return !!expr && expr.kind === ts.SyntaxKind.NullKeyword;
1963
2201
  }
2202
+ if (ts.isBlock(stmt)) return stmt.statements.length === 1 && returnsNullStatement(stmt.statements[0]);
2203
+ return false;
1964
2204
  }
1965
- function detectUseState(ctx, node) {
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);
1966
2218
  const parent = node.parent;
1967
- if (ts.isVariableDeclaration(parent) && parent.name && ts.isArrayBindingPattern(parent.name) && parent.name.elements.length >= 1) {
1968
- const firstEl = parent.name.elements[0];
1969
- const valueName = firstEl && ts.isBindingElement(firstEl) ? firstEl.name.text : "value";
1970
- const initArg = node.arguments[0] ? detectGetNodeText(ctx, node.arguments[0]) : "undefined";
1971
- detectDiag(ctx, node, "use-state", `useState is a React API. In Pyreon, use signal(). Read: ${valueName}(), Write: ${valueName}.set(x)`, detectGetNodeText(ctx, parent), `${valueName} = signal(${initArg})`, true);
1972
- } else detectDiag(ctx, node, "use-state", "useState is a React API. In Pyreon, use signal().", detectGetNodeText(ctx, node), "signal(initialValue)", true);
1973
- }
1974
- function callbackHasCleanup(callbackArg) {
1975
- if (!ts.isArrowFunction(callbackArg) && !ts.isFunctionExpression(callbackArg)) return false;
1976
- const body = callbackArg.body;
1977
- if (!ts.isBlock(body)) return false;
1978
- for (const stmt of body.statements) if (ts.isReturnStatement(stmt) && stmt.expression) return true;
2219
+ if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return /^[A-Z]/.test(parent.name.text);
1979
2220
  return false;
1980
2221
  }
1981
- function detectUseEffect(ctx, node) {
1982
- const hookName = node.expression.text;
1983
- const depsArg = node.arguments[1];
1984
- const callbackArg = node.arguments[0];
1985
- if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0) {
1986
- const hasCleanup = callbackArg ? callbackHasCleanup(callbackArg) : false;
1987
- detectDiag(ctx, node, "use-effect-mount", `${hookName} with empty deps [] means "run once on mount". Use onMount() in Pyreon.`, detectGetNodeText(ctx, node), hasCleanup ? "onMount(() => {\n // setup...\n return () => { /* cleanup */ }\n})" : "onMount(() => {\n // setup...\n})", true);
1988
- } else if (depsArg && ts.isArrayLiteralExpression(depsArg)) detectDiag(ctx, node, "use-effect-deps", `${hookName} with dependency array. In Pyreon, effect() auto-tracks dependencies — no array needed.`, detectGetNodeText(ctx, node), "effect(() => {\n // reads are auto-tracked\n})", true);
1989
- else if (!depsArg) detectDiag(ctx, node, "use-effect-no-deps", `${hookName} with no dependency array. In Pyreon, use effect() — it auto-tracks signal reads.`, detectGetNodeText(ctx, node), "effect(() => {\n // runs when accessed signals change\n})", true);
1990
- }
1991
- function detectUseMemo(ctx, node) {
1992
- const computeFn = node.arguments[0];
1993
- const computeText = computeFn ? detectGetNodeText(ctx, computeFn) : "() => value";
1994
- detectDiag(ctx, node, "use-memo", "useMemo is a React API. In Pyreon, use computed() — dependencies auto-tracked.", detectGetNodeText(ctx, node), `computed(${computeText})`, true);
1995
- }
1996
- function detectUseCallback(ctx, node) {
1997
- const callbackFn = node.arguments[0];
1998
- const callbackText = callbackFn ? detectGetNodeText(ctx, callbackFn) : "() => {}";
1999
- 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);
2000
- }
2001
- function detectUseRef(ctx, node) {
2002
- const arg = node.arguments[0];
2003
- 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);
2004
- else {
2005
- const initText = arg ? detectGetNodeText(ctx, arg) : "undefined";
2006
- detectDiag(ctx, node, "use-ref-box", "useRef for mutable values. In Pyreon, use signal() — it works the same way but is reactive.", detectGetNodeText(ctx, node), `signal(${initText})`, true);
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;
2007
2232
  }
2008
2233
  }
2009
- function detectUseReducer(ctx, node) {
2010
- detectDiag(ctx, node, "use-reducer", "useReducer is a React API. In Pyreon, use signal() with update() for reducer patterns.", detectGetNodeText(ctx, node), "const state = signal(initialState)\nconst dispatch = (action) => state.update(s => reducer(s, action))", false);
2011
- }
2012
- function isCallToReactDot(callee, methodName) {
2013
- return ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === "React" && callee.name.text === methodName;
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);
2014
2250
  }
2015
- function detectMemoWrapper(ctx, node) {
2016
- const callee = node.expression;
2017
- if (ts.isIdentifier(callee) && callee.text === "memo" || isCallToReactDot(callee, "memo")) {
2018
- const inner = node.arguments[0];
2019
- const innerText = inner ? detectGetNodeText(ctx, inner) : "Component";
2020
- 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);
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);
2021
2290
  }
2291
+ walk(sf);
2292
+ return names;
2022
2293
  }
2023
- function detectForwardRef(ctx, node) {
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;
2024
2302
  const callee = node.expression;
2025
- if (ts.isIdentifier(callee) && callee.text === "forwardRef" || isCallToReactDot(callee, "forwardRef")) detectDiag(ctx, node, "forward-ref", "forwardRef is not needed in Pyreon. Pass ref as a regular prop.", detectGetNodeText(ctx, node), "// Just pass ref as a prop:\nconst MyInput = (props) => <input ref={props.ref} />", true);
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);
2312
+ }
2026
2313
  }
2027
- function detectJsxAttributes(ctx, node) {
2028
- const attrName = node.name.text;
2029
- if (attrName in JSX_ATTR_REWRITES) {
2030
- const htmlAttr = JSX_ATTR_REWRITES[attrName];
2031
- 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);
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);
2032
2320
  }
2033
- if (attrName === "onChange") {
2034
- const jsxElement = findParentJsxElement(node);
2035
- if (jsxElement) {
2036
- const tagName = getJsxTagName$1(jsxElement);
2037
- if (tagName === "input" || tagName === "textarea" || tagName === "select") detectDiag(ctx, node, "on-change-input", `onChange on <${tagName}> fires on blur in Pyreon (native DOM behavior). For keypress-by-keypress updates, use onInput.`, detectGetNodeText(ctx, node), detectGetNodeText(ctx, node).replace("onChange", "onInput"), true);
2038
- }
2321
+ if (ts.isBinaryExpression(node)) {
2322
+ detectProcessDevGate(ctx, node);
2323
+ detectDateMathRandomId(ctx, node);
2039
2324
  }
2040
- 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);
2041
- }
2042
- function detectDotValueSignal(ctx, node) {
2043
- const varName = node.expression.text;
2044
- const parent = node.parent;
2045
- 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);
2046
- }
2047
- function detectArrayMapJsx(ctx, node) {
2048
- const parent = node.parent;
2049
- if (ts.isJsxExpression(parent)) {
2050
- const arrayExpr = detectGetNodeText(ctx, node.expression.expression);
2051
- const mapCallback = node.arguments[0];
2052
- const mapCallbackText = mapCallback ? detectGetNodeText(ctx, mapCallback) : "item => <li>{item}</li>";
2053
- 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);
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);
2054
2332
  }
2333
+ if (ts.isJsxAttribute(node)) detectOnClickUndefined(ctx, node);
2334
+ if (ts.isAsExpression(node)) detectAsUnknownAsVNodeChild(ctx, node);
2055
2335
  }
2056
- function isCallToHook(node, hookName) {
2057
- return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === hookName;
2058
- }
2059
- function isCallToEffectHook(node) {
2060
- return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (node.expression.text === "useEffect" || node.expression.text === "useLayoutEffect");
2061
- }
2062
- function isMapCallExpression(node) {
2063
- return ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name) && node.expression.name.text === "map";
2064
- }
2065
- function isDotValueAccess(node) {
2066
- return ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name) && node.name.text === "value" && ts.isIdentifier(node.expression);
2067
- }
2068
- function detectVisitNode(ctx, node) {
2069
- if (ts.isImportDeclaration(node)) detectImportDeclaration(ctx, node);
2070
- if (isCallToHook(node, "useState")) detectUseState(ctx, node);
2071
- if (isCallToEffectHook(node)) detectUseEffect(ctx, node);
2072
- if (isCallToHook(node, "useMemo")) detectUseMemo(ctx, node);
2073
- if (isCallToHook(node, "useCallback")) detectUseCallback(ctx, node);
2074
- if (isCallToHook(node, "useRef")) detectUseRef(ctx, node);
2075
- if (isCallToHook(node, "useReducer")) detectUseReducer(ctx, node);
2076
- if (ts.isCallExpression(node)) detectMemoWrapper(ctx, node);
2077
- if (ts.isCallExpression(node)) detectForwardRef(ctx, node);
2078
- if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) detectJsxAttributes(ctx, node);
2079
- if (isDotValueAccess(node)) detectDotValueSignal(ctx, node);
2080
- if (isMapCallExpression(node)) detectArrayMapJsx(ctx, node);
2081
- }
2082
- function detectVisit(ctx, node) {
2336
+ function visit(ctx, node) {
2083
2337
  ts.forEachChild(node, (child) => {
2084
- detectVisitNode(ctx, child);
2085
- detectVisit(ctx, child);
2338
+ visitNode(ctx, child);
2339
+ visit(ctx, child);
2086
2340
  });
2087
2341
  }
2088
- function detectReactPatterns(code, filename = "input.tsx") {
2342
+ function detectPyreonPatterns(code, filename = "input.tsx") {
2089
2343
  const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
2090
2344
  const ctx = {
2091
2345
  sf,
2092
2346
  code,
2093
2347
  diagnostics: [],
2094
- reactImportedHooks: /* @__PURE__ */ new Set()
2348
+ signalBindings: collectSignalBindings(sf),
2349
+ neverIslandNames: collectNeverIslandNames(sf)
2095
2350
  };
2096
- detectVisit(ctx, sf);
2351
+ visit(ctx, sf);
2352
+ ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column);
2097
2353
  return ctx.diagnostics;
2098
2354
  }
2099
- function migrateAddImport(ctx, source, specifier) {
2100
- if (!source || !specifier) return;
2101
- let specs = ctx.pyreonImports.get(source);
2102
- if (!specs) {
2103
- specs = /* @__PURE__ */ new Set();
2104
- ctx.pyreonImports.set(source, specs);
2105
- }
2106
- specs.add(specifier);
2107
- }
2108
- function migrateReplace(ctx, node, text) {
2109
- ctx.replacements.push({
2110
- start: node.getStart(ctx.sf),
2111
- end: node.getEnd(),
2112
- text
2113
- });
2114
- }
2115
- function migrateGetNodeText(ctx, node) {
2116
- return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
2117
- }
2118
- function migrateGetLine(ctx, node) {
2119
- return ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf)).line + 1;
2120
- }
2121
- function migrateImportDeclaration(ctx, node) {
2122
- if (!node.moduleSpecifier) return;
2123
- if (!(node.moduleSpecifier.text in IMPORT_REWRITES)) return;
2124
- if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) for (const spec of node.importClause.namedBindings.elements) {
2125
- const rewrite = SPECIFIER_REWRITES[spec.name.text];
2126
- if (rewrite) {
2127
- if (rewrite.name) migrateAddImport(ctx, rewrite.from, rewrite.name);
2128
- ctx.specifierRewrites.set(spec, rewrite);
2129
- }
2130
- }
2131
- ctx.importsToRemove.add(node);
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);
2132
2358
  }
2133
- function migrateUseState(ctx, node) {
2134
- const parent = node.parent;
2135
- if (ts.isVariableDeclaration(parent) && parent.name && ts.isArrayBindingPattern(parent.name) && parent.name.elements.length >= 1) {
2136
- const firstEl = parent.name.elements[0];
2137
- const valueName = firstEl && ts.isBindingElement(firstEl) ? firstEl.name.text : "value";
2138
- const initArg = node.arguments[0] ? migrateGetNodeText(ctx, node.arguments[0]) : "undefined";
2139
- const declStart = parent.getStart(ctx.sf);
2140
- const declEnd = parent.getEnd();
2141
- ctx.replacements.push({
2142
- start: declStart,
2143
- end: declEnd,
2144
- text: `${valueName} = signal(${initArg})`
2145
- });
2146
- migrateAddImport(ctx, "@pyreon/reactivity", "signal");
2147
- ctx.changes.push({
2148
- type: "replace",
2149
- line: migrateGetLine(ctx, node),
2150
- description: `useState signal: ${valueName}`
2151
- });
2152
- }
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
+ };
2153
2403
  }
2154
- function migrateUseEffect(ctx, node) {
2155
- const depsArg = node.arguments[1];
2156
- const callbackArg = node.arguments[0];
2157
- const hookName = node.expression.text;
2158
- if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0 && callbackArg) {
2159
- migrateReplace(ctx, node, `onMount(${migrateGetNodeText(ctx, callbackArg)})`);
2160
- migrateAddImport(ctx, "@pyreon/core", "onMount");
2161
- ctx.changes.push({
2162
- type: "replace",
2163
- line: migrateGetLine(ctx, node),
2164
- description: `${hookName}(fn, []) → onMount(fn)`
2165
- });
2166
- } else if (callbackArg) {
2167
- migrateReplace(ctx, node, `effect(${migrateGetNodeText(ctx, callbackArg)})`);
2168
- migrateAddImport(ctx, "@pyreon/reactivity", "effect");
2169
- ctx.changes.push({
2170
- type: "replace",
2171
- line: migrateGetLine(ctx, node),
2172
- description: `${hookName} → effect (auto-tracks deps)`
2173
- });
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 = [];
2174
2427
  }
2175
- }
2176
- function migrateUseMemo(ctx, node) {
2177
- const computeFn = node.arguments[0];
2178
- if (computeFn) {
2179
- migrateReplace(ctx, node, `computed(${migrateGetNodeText(ctx, computeFn)})`);
2180
- migrateAddImport(ctx, "@pyreon/reactivity", "computed");
2181
- ctx.changes.push({
2182
- type: "replace",
2183
- line: migrateGetLine(ctx, node),
2184
- description: "useMemo → computed (auto-tracks deps)"
2185
- });
2428
+ const findings = spans.map(spanToFinding);
2429
+ let footguns = [];
2430
+ try {
2431
+ footguns = detectPyreonPatterns(code, filename);
2432
+ } catch {
2433
+ footguns = [];
2186
2434
  }
2187
- }
2188
- function migrateUseCallback(ctx, node) {
2189
- const callbackFn = node.arguments[0];
2190
- if (callbackFn) {
2191
- migrateReplace(ctx, node, migrateGetNodeText(ctx, callbackFn));
2192
- ctx.changes.push({
2193
- type: "replace",
2194
- line: migrateGetLine(ctx, node),
2195
- description: "useCallback → plain function (not needed in Pyreon)"
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
2196
2446
  });
2197
2447
  }
2448
+ findings.sort((a, b) => a.line - b.line || a.column - b.column);
2449
+ return {
2450
+ findings,
2451
+ spans
2452
+ };
2198
2453
  }
2199
- function migrateUseRef(ctx, node) {
2200
- const arg = node.arguments[0];
2201
- if (arg && (arg.kind === ts.SyntaxKind.NullKeyword || ts.isIdentifier(arg) && arg.text === "undefined") || !arg) {
2202
- migrateReplace(ctx, node, "createRef()");
2203
- migrateAddImport(ctx, "@pyreon/core", "createRef");
2204
- ctx.changes.push({
2205
- type: "replace",
2206
- line: migrateGetLine(ctx, node),
2207
- description: "useRef(null) → createRef()"
2208
- });
2209
- } else {
2210
- migrateReplace(ctx, node, `signal(${migrateGetNodeText(ctx, arg)})`);
2211
- migrateAddImport(ctx, "@pyreon/reactivity", "signal");
2212
- ctx.changes.push({
2213
- type: "replace",
2214
- line: migrateGetLine(ctx, node),
2215
- description: "useRef(value) signal(value)"
2216
- });
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);
2217
2475
  }
2218
- }
2219
- function migrateMemoWrapper(ctx, node) {
2220
- const callee = node.expression;
2221
- if ((ts.isIdentifier(callee) && callee.text === "memo" || isCallToReactDot(callee, "memo")) && node.arguments[0]) {
2222
- migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]));
2223
- ctx.changes.push({
2224
- type: "remove",
2225
- line: migrateGetLine(ctx, node),
2226
- description: "Removed memo() wrapper (not needed in Pyreon)"
2227
- });
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}`);
2485
+ }
2228
2486
  }
2487
+ return out.join("\n");
2229
2488
  }
2230
- function migrateForwardRef(ctx, node) {
2231
- const callee = node.expression;
2232
- if ((ts.isIdentifier(callee) && callee.text === "forwardRef" || isCallToReactDot(callee, "forwardRef")) && node.arguments[0]) {
2233
- migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]));
2234
- ctx.changes.push({
2235
- type: "remove",
2236
- line: migrateGetLine(ctx, node),
2237
- description: "Removed forwardRef wrapper (pass ref as normal prop in Pyreon)"
2238
- });
2239
- }
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
+ };
2240
2505
  }
2241
- function migrateJsxAttributes(ctx, node) {
2242
- const attrName = node.name.text;
2243
- if (attrName in JSX_ATTR_REWRITES) {
2244
- const htmlAttr = JSX_ATTR_REWRITES[attrName];
2245
- ctx.replacements.push({
2246
- start: node.name.getStart(ctx.sf),
2247
- end: node.name.getEnd(),
2248
- text: htmlAttr
2249
- });
2250
- ctx.changes.push({
2251
- type: "replace",
2252
- line: migrateGetLine(ctx, node),
2253
- description: `${attrName} → ${htmlAttr}`
2254
- });
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);
2535
+ }
2255
2536
  }
2256
- if (attrName === "onChange") {
2257
- const jsxElement = findParentJsxElement(node);
2258
- if (jsxElement) {
2259
- const tagName = getJsxTagName$1(jsxElement);
2260
- if (tagName === "input" || tagName === "textarea" || tagName === "select") {
2261
- ctx.replacements.push({
2262
- start: node.name.getStart(ctx.sf),
2263
- end: node.name.getEnd(),
2264
- text: "onInput"
2265
- });
2266
- ctx.changes.push({
2267
- type: "replace",
2268
- line: migrateGetLine(ctx, node),
2269
- description: `onChange on <${tagName}> → onInput (native DOM events)`
2537
+ walk(cwd);
2538
+ return results;
2539
+ }
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)
2270
2566
  });
2271
2567
  }
2272
2568
  }
2273
2569
  }
2274
- if (attrName === "dangerouslySetInnerHTML") migrateDangerouslySetInnerHTML(ctx, node);
2275
- }
2276
- function migrateDangerouslySetInnerHTML(ctx, node) {
2277
- if (!node.initializer || !ts.isJsxExpression(node.initializer) || !node.initializer.expression) return;
2278
- const expr = node.initializer.expression;
2279
- if (!ts.isObjectLiteralExpression(expr)) return;
2280
- const htmlProp = expr.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "__html");
2281
- if (htmlProp) {
2282
- migrateReplace(ctx, node, `innerHTML={${migrateGetNodeText(ctx, htmlProp.initializer)}}`);
2283
- ctx.changes.push({
2284
- type: "replace",
2285
- line: migrateGetLine(ctx, node),
2286
- description: "dangerouslySetInnerHTML → innerHTML"
2287
- });
2288
- }
2570
+ return routes;
2289
2571
  }
2290
- function applyReplacements(code, ctx) {
2291
- for (const imp of ctx.importsToRemove) {
2292
- ctx.replacements.push({
2293
- start: imp.getStart(ctx.sf),
2294
- end: imp.getEnd(),
2295
- text: ""
2296
- });
2297
- ctx.changes.push({
2298
- type: "remove",
2299
- line: ctx.sf.getLineAndCharacterOfPosition(imp.getStart(ctx.sf)).line + 1,
2300
- description: "Removed React import"
2301
- });
2302
- }
2303
- ctx.replacements.sort((a, b) => b.start - a.start);
2304
- const applied = /* @__PURE__ */ new Set();
2305
- const deduped = [];
2306
- for (const r of ctx.replacements) {
2307
- const key = `${r.start}:${r.end}`;
2308
- let overlaps = false;
2309
- for (const d of deduped) if (r.start < d.end && r.end > d.start) {
2310
- overlaps = true;
2311
- break;
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;
2312
2580
  }
2313
- if (!overlaps && !applied.has(key)) {
2314
- applied.add(key);
2315
- deduped.push(r);
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
+ });
2316
2599
  }
2317
2600
  }
2318
- deduped.sort((a, b) => a.start - b.start);
2319
- const parts = [];
2320
- let lastPos = 0;
2321
- for (const r of deduped) {
2322
- parts.push(code.slice(lastPos, r.start));
2323
- parts.push(r.text);
2324
- lastPos = r.end;
2325
- }
2326
- parts.push(code.slice(lastPos));
2327
- return parts.join("");
2328
- }
2329
- function insertPyreonImports(code, pyreonImports) {
2330
- if (pyreonImports.size === 0) return code;
2331
- const importLines = [];
2332
- const sorted = [...pyreonImports.entries()].sort(([a], [b]) => a.localeCompare(b));
2333
- for (const [source, specs] of sorted) {
2334
- const specList = [...specs].sort().join(", ");
2335
- importLines.push(`import { ${specList} } from "${source}"`);
2336
- }
2337
- const importBlock = importLines.join("\n");
2338
- const lastImportEnd = findLastImportEnd(code);
2339
- if (lastImportEnd > 0) return `${code.slice(0, lastImportEnd)}\n${importBlock}${code.slice(lastImportEnd)}`;
2340
- return `${importBlock}\n\n${code}`;
2341
- }
2342
- function migrateVisitNode(ctx, node) {
2343
- if (ts.isImportDeclaration(node)) migrateImportDeclaration(ctx, node);
2344
- if (isCallToHook(node, "useState")) migrateUseState(ctx, node);
2345
- if (isCallToEffectHook(node)) migrateUseEffect(ctx, node);
2346
- if (isCallToHook(node, "useMemo")) migrateUseMemo(ctx, node);
2347
- if (isCallToHook(node, "useCallback")) migrateUseCallback(ctx, node);
2348
- if (isCallToHook(node, "useRef")) migrateUseRef(ctx, node);
2349
- if (ts.isCallExpression(node)) migrateMemoWrapper(ctx, node);
2350
- if (ts.isCallExpression(node)) migrateForwardRef(ctx, node);
2351
- if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) migrateJsxAttributes(ctx, node);
2352
- }
2353
- function migrateVisit(ctx, node) {
2354
- ts.forEachChild(node, (child) => {
2355
- migrateVisitNode(ctx, child);
2356
- migrateVisit(ctx, child);
2357
- });
2358
- }
2359
- function migrateReactCode(code, filename = "input.tsx") {
2360
- const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
2361
- const diagnostics = detectReactPatterns(code, filename);
2362
- const ctx = {
2363
- sf,
2364
- code,
2365
- replacements: [],
2366
- changes: [],
2367
- pyreonImports: /* @__PURE__ */ new Map(),
2368
- importsToRemove: /* @__PURE__ */ new Set(),
2369
- specifierRewrites: /* @__PURE__ */ new Map()
2370
- };
2371
- migrateVisit(ctx, sf);
2372
- let result = applyReplacements(code, ctx);
2373
- result = insertPyreonImports(result, ctx.pyreonImports);
2374
- result = result.replace(/\n{3,}/g, "\n\n");
2375
- return {
2376
- code: result,
2377
- diagnostics,
2378
- changes: ctx.changes
2379
- };
2601
+ return components;
2380
2602
  }
2381
- function findParentJsxElement(node) {
2382
- let current = node.parent;
2383
- while (current) {
2384
- if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current)) return current;
2385
- if (ts.isJsxElement(current)) return current.openingElement;
2386
- if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) return null;
2387
- current = current.parent;
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
+ });
2388
2619
  }
2389
- return null;
2390
- }
2391
- function getJsxTagName$1(node) {
2392
- const tagName = node.tagName;
2393
- if (ts.isIdentifier(tagName)) return tagName.text;
2394
- return "";
2620
+ return islands;
2395
2621
  }
2396
- function findLastImportEnd(code) {
2397
- const importRe = /^import\s.+$/gm;
2398
- let lastEnd = 0;
2622
+ function extractParams(routePath) {
2623
+ const params = [];
2624
+ const paramRe = /:(\w+)\??/g;
2399
2625
  let match;
2400
- while (true) {
2401
- match = importRe.exec(code);
2402
- if (!match) break;
2403
- lastEnd = match.index + match[0].length;
2404
- }
2405
- return lastEnd;
2626
+ for (match = paramRe.exec(routePath); match; match = paramRe.exec(routePath)) if (match[1]) params.push(match[1]);
2627
+ return params;
2406
2628
  }
2407
- /** Fast regex check — returns true if code likely contains React patterns worth analyzing */
2408
- function hasReactPatterns(code) {
2409
- 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);
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
+ }
2410
2641
  }
2411
- const ERROR_PATTERNS = [
2412
- {
2413
- pattern: /Cannot read properties of undefined \(reading '(set|update|peek|subscribe)'\)/,
2414
- diagnose: (m) => ({
2415
- cause: `Calling .${m[1]}() on undefined. The signal variable is likely out of scope, misspelled, or not yet initialized.`,
2416
- fix: "Check that the signal is defined and in scope. Signals must be created with signal() before use.",
2417
- fixCode: `const mySignal = signal(initialValue)\nmySignal.${m[1]}(newValue)`
2418
- })
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"
2419
2704
  },
2420
- {
2421
- pattern: /(\w+) is not a function/,
2422
- diagnose: (m) => ({
2423
- cause: `'${m[1]}' is not callable. If this is a signal, you need to call it: ${m[1]}()`,
2424
- fix: "Pyreon signals are callable functions. Read: signal(), Write: signal.set(value)",
2425
- fixCode: `// Read value:\nconst value = ${m[1]}()\n// Set value:\n${m[1]}.set(newValue)`
2426
- })
2705
+ Suspense: {
2706
+ name: "Suspense",
2707
+ from: "@pyreon/core"
2427
2708
  },
2428
- {
2429
- pattern: /Cannot find module '(@pyreon\/\w[\w-]*)'/,
2430
- diagnose: (m) => ({
2431
- cause: `Package ${m[1]} is not installed.`,
2432
- fix: `Run: bun add ${m[1]}`,
2433
- fixCode: `bun add ${m[1]}`
2434
- })
2709
+ lazy: {
2710
+ name: "lazy",
2711
+ from: "@pyreon/core"
2435
2712
  },
2436
- {
2437
- pattern: /Cannot find module 'react'/,
2438
- diagnose: () => ({
2439
- cause: "Importing from 'react' in a Pyreon project.",
2440
- fix: "Replace React imports with Pyreon equivalents.",
2441
- fixCode: "// Instead of:\nimport { useState } from \"react\"\n// Use:\nimport { signal } from \"@pyreon/reactivity\""
2442
- })
2713
+ memo: {
2714
+ name: "",
2715
+ from: ""
2443
2716
  },
2444
- {
2445
- pattern: /Property '(\w+)' does not exist on type 'Signal<\w+>'/,
2446
- diagnose: (m) => ({
2447
- cause: `Accessing .${m[1]} on a signal. Pyreon signals don't have a .${m[1]} property.`,
2448
- fix: m[1] === "value" ? "Pyreon signals are callable functions, not .value getters. Call signal() to read, signal.set() to write." : `Signals have these methods: .set(), .update(), .peek(), .subscribe(). '${m[1]}' is not one of them.`,
2449
- fixCode: m[1] === "value" ? "// Read: mySignal()\n// Write: mySignal.set(newValue)" : void 0
2450
- })
2717
+ forwardRef: {
2718
+ name: "",
2719
+ from: ""
2451
2720
  },
2452
- {
2453
- pattern: /Type '(\w+)' is not assignable to type 'VNode'/,
2454
- diagnose: (m) => ({
2455
- cause: `Component returned ${m[1]} instead of VNode. Components must return JSX, null, or a string.`,
2456
- fix: "Make sure your component returns a JSX element, null, or a string.",
2457
- fixCode: "const MyComponent = (props) => {\n return <div>{props.children}</div>\n}"
2458
- })
2721
+ createRoot: {
2722
+ name: "mount",
2723
+ from: "@pyreon/runtime-dom"
2459
2724
  },
2460
- {
2461
- pattern: /onMount callback must return/,
2462
- diagnose: () => ({
2463
- cause: "onMount expects a callback that optionally returns a CleanupFn.",
2464
- fix: "Return a cleanup function, or return nothing.",
2465
- fixCode: "onMount(() => {\n // setup code\n})"
2466
- })
2725
+ hydrateRoot: {
2726
+ name: "hydrateRoot",
2727
+ from: "@pyreon/runtime-dom"
2467
2728
  },
2468
- {
2469
- pattern: /Expected 'by' prop on <For>/,
2470
- diagnose: () => ({
2471
- cause: "<For> requires a 'by' prop for efficient keyed reconciliation.",
2472
- fix: "Add a by prop that returns a unique key for each item.",
2473
- fixCode: "<For each={items()} by={item => item.id}>\n {item => <li>{item.name}</li>}\n</For>"
2474
- })
2729
+ useNavigate: {
2730
+ name: "useRouter",
2731
+ from: "@pyreon/router"
2475
2732
  },
2476
- {
2477
- pattern: /useHook.*outside.*component/i,
2478
- diagnose: () => ({
2479
- cause: "Hook called outside a component function. Pyreon hooks must be called during component setup.",
2480
- fix: "Move the hook call inside a component function body."
2481
- })
2733
+ useParams: {
2734
+ name: "useRoute",
2735
+ from: "@pyreon/router"
2482
2736
  },
2483
- {
2484
- pattern: /Hydration mismatch/,
2485
- diagnose: () => ({
2486
- cause: "Server-rendered HTML doesn't match client-rendered output.",
2487
- fix: "Ensure SSR and client render the same initial content. Check for browser-only APIs (window, document) in SSR code.",
2488
- related: "Use typeof window !== 'undefined' checks or onMount() for client-only code."
2489
- })
2490
- }
2491
- ];
2492
- /** Diagnose an error message and return structured fix information */
2493
- function diagnoseError(error) {
2494
- for (const { pattern, diagnose } of ERROR_PATTERNS) {
2495
- const match = error.match(pattern);
2496
- if (match) return diagnose(match);
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"
2497
2756
  }
2498
- return null;
2499
- }
2500
-
2501
- //#endregion
2502
- //#region src/pyreon-intercept.ts
2757
+ };
2758
+ /** JSX attribute rewrites (React → standard HTML) */
2759
+ const JSX_ATTR_REWRITES = {
2760
+ className: "class",
2761
+ htmlFor: "for"
2762
+ };
2503
2763
  /**
2504
- * Pyreon Pattern Interceptor detects Pyreon-specific anti-patterns in
2505
- * code that has ALREADY committed to the framework (imports are Pyreon,
2506
- * not React). Complements `react-intercept.ts` the React detector
2507
- * catches "coming from React" mistakes; this one catches "using Pyreon
2508
- * wrong" mistakes.
2509
- *
2510
- * Catalog of detected patterns (grounded in `.claude/rules/anti-patterns.md`):
2511
- *
2512
- * - `for-missing-by` — `<For each={...}>` without a `by` prop
2513
- * - `for-with-key` — `<For key={...}>` (JSX reserves `key`; the keying
2514
- * prop is `by` in Pyreon)
2515
- * - `props-destructured` — `({ foo }: Props) => <JSX />` destructures at
2516
- * the component signature; reading is captured once
2517
- * and loses reactivity. Access `props.foo` instead
2518
- * or use `splitProps(props, [...])`.
2519
- * - `process-dev-gate` — `typeof process !== 'undefined' &&
2520
- * process.env.NODE_ENV !== 'production'` is dead
2521
- * code in real Vite browser bundles. Use
2522
- * `import.meta.env?.DEV` instead.
2523
- * - `empty-theme` — `.theme({})` chain is a no-op; remove it.
2524
- * - `raw-add-event-listener` — raw `addEventListener(...)` in a component
2525
- * or hook body. Use `useEventListener(...)` from
2526
- * `@pyreon/hooks` for auto-cleanup.
2527
- * - `raw-remove-event-listener` — same, for removeEventListener.
2528
- * - `date-math-random-id` — `Date.now() + Math.random()` / template-concat
2529
- * variants. Under rapid operations (paste, clone)
2530
- * collision probability is non-trivial. Use a
2531
- * monotonic counter.
2532
- * - `on-click-undefined` — `onClick={undefined}` explicitly; the runtime
2533
- * used to crash on this pattern. Omit the prop.
2534
- * - `signal-write-as-call` — `sig(value)` is a no-op read that ignores
2535
- * its argument; the runtime warns in dev. Static
2536
- * detector spots it pre-runtime when `sig` was
2537
- * declared as `const sig = signal(...)` /
2538
- * `computed(...)` and called with ≥1 argument.
2539
- * - `static-return-null-conditional` — `if (cond) return null` at the
2540
- * top of a component body runs ONCE; signal changes
2541
- * in `cond` never re-evaluate the early-return.
2542
- * Wrap in a returned reactive accessor.
2543
- * - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
2544
- * cast on JSX returns is unnecessary (`JSX.Element`
2545
- * is already assignable to `VNodeChild`).
2546
- * - `island-never-with-registry-entry` — an `island()` declared with
2547
- * `hydrate: 'never'` is also registered in the same
2548
- * file's `hydrateIslands({ ... })` call. The whole
2549
- * point of `'never'` is shipping zero client JS;
2550
- * registering pulls the component module into the
2551
- * client bundle graph (the runtime short-circuits
2552
- * and never calls the loader, but the bundler still
2553
- * includes the import). Drop the registry entry.
2554
- *
2555
- * Two-mode surface mirrors `react-intercept.ts`:
2556
- * - `detectPyreonPatterns(code)` — diagnostics only
2557
- * - `hasPyreonPatterns(code)` — fast regex pre-filter
2558
- *
2559
- * ## fixable: false (invariant)
2560
- *
2561
- * Every Pyreon diagnostic reports `fixable: false` — no exceptions.
2562
- * The `migrate_react` MCP tool only knows React mappings, so claiming
2563
- * a Pyreon code is auto-fixable would mislead a consumer who wires
2564
- * their UX off the flag and finds nothing applies the fix. Flip to
2565
- * `true` ONLY when a companion `migrate_pyreon` tool ships in a
2566
- * subsequent PR. The invariant is locked in
2567
- * `tests/pyreon-intercept.test.ts` under "fixable contract".
2568
- *
2569
- * Designed for three consumers:
2570
- * 1. Compiler pre-pass warnings during build
2571
- * 2. CLI `pyreon doctor`
2572
- * 3. MCP server `validate` tool
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.
2573
2769
  */
2574
- function getNodeText(ctx, node) {
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) {
2575
2789
  return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
2576
2790
  }
2577
- function pushDiag(ctx, node, code, message, current, suggested, fixable) {
2578
- const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf));
2579
- ctx.diagnostics.push({
2580
- code,
2581
- message,
2582
- line: line + 1,
2583
- column: character,
2584
- current: current.trim(),
2585
- suggested: suggested.trim(),
2586
- fixable
2587
- });
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);
2588
2842
  }
2589
- function getJsxTagName(node) {
2590
- const t = node.tagName;
2591
- if (ts.isIdentifier(t)) return t.text;
2592
- return "";
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);
2593
2847
  }
2594
- function findJsxAttribute(node, name) {
2595
- for (const attr of node.attributes.properties) if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === name) return attr;
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
+ }
2596
2855
  }
2597
- function detectForKeying(ctx, node) {
2598
- if (getJsxTagName(node) !== "For") return;
2599
- const keyAttr = findJsxAttribute(node, "key");
2600
- if (keyAttr) pushDiag(ctx, keyAttr, "for-with-key", "`key` on <For> is reserved by JSX for VNode reconciliation and is extracted before the prop reaches the runtime. In Pyreon, use `by` for list identity.", getNodeText(ctx, keyAttr), getNodeText(ctx, keyAttr).replace(/^key\b/, "by"), false);
2601
- const eachAttr = findJsxAttribute(node, "each");
2602
- const byAttr = findJsxAttribute(node, "by");
2603
- if (eachAttr && !byAttr && !keyAttr) pushDiag(ctx, node, "for-missing-by", "<For each={...}> requires a `by` prop so the keyed reconciler can preserve item identity across reorders. Without `by`, every update remounts the full list.", getNodeText(ctx, node), "<For each={items} by={(item) => item.id}>", false);
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);
2604
2858
  }
2605
- function containsJsx(node) {
2606
- let found = false;
2607
- function walk(n) {
2608
- if (found) return;
2609
- if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n) || ts.isJsxOpeningElement(n)) {
2610
- found = true;
2611
- return;
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);
2612
2885
  }
2613
- ts.forEachChild(n, walk);
2614
2886
  }
2615
- ts.forEachChild(node, walk);
2616
- if (!found) {
2617
- if (ts.isArrowFunction(node) && !ts.isBlock(node.body) && (ts.isJsxElement(node.body) || ts.isJsxSelfClosingElement(node.body) || ts.isJsxFragment(node.body))) found = true;
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);
2618
2902
  }
2619
- return found;
2620
2903
  }
2621
- function detectPropsDestructured(ctx, node) {
2622
- if (!node.parameters.length) return;
2623
- const first = node.parameters[0];
2624
- if (!first || !ts.isObjectBindingPattern(first.name)) return;
2625
- if (first.name.elements.length === 0) return;
2626
- if (!containsJsx(node)) return;
2627
- pushDiag(ctx, first, "props-destructured", "Destructuring props at the component signature captures the values ONCE during setup — subsequent signal writes in the parent do not update the destructured locals. Access `props.x` directly, or use `splitProps(props, [...])` to carve out a group while preserving reactivity.", getNodeText(ctx, first), "(props: Props) => /* read props.x directly */", false);
2904
+ function isCallToHook(node, hookName) {
2905
+ return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === hookName;
2628
2906
  }
2629
- function isTypeofProcess(node) {
2630
- if (!ts.isBinaryExpression(node)) return false;
2631
- if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false;
2632
- if (!ts.isTypeOfExpression(node.left)) return false;
2633
- if (!ts.isIdentifier(node.left.expression) || node.left.expression.text !== "process") return false;
2634
- return ts.isStringLiteral(node.right) && node.right.text === "undefined";
2907
+ function isCallToEffectHook(node) {
2908
+ return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (node.expression.text === "useEffect" || node.expression.text === "useLayoutEffect");
2635
2909
  }
2636
- function isProcessNodeEnvProdGuard(node) {
2637
- if (!ts.isBinaryExpression(node)) return false;
2638
- if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false;
2639
- const left = node.left;
2640
- if (!ts.isPropertyAccessExpression(left)) return false;
2641
- if (!ts.isIdentifier(left.name) || left.name.text !== "NODE_ENV") return false;
2642
- if (!ts.isPropertyAccessExpression(left.expression)) return false;
2643
- if (!ts.isIdentifier(left.expression.name) || left.expression.name.text !== "env") return false;
2644
- if (!ts.isIdentifier(left.expression.expression)) return false;
2645
- if (left.expression.expression.text !== "process") return false;
2646
- return ts.isStringLiteral(node.right) && node.right.text === "production";
2910
+ function isMapCallExpression(node) {
2911
+ return ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name) && node.expression.name.text === "map";
2647
2912
  }
2648
- function detectProcessDevGate(ctx, node) {
2649
- if (node.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) return;
2650
- if (!(isTypeofProcess(node.left) && isProcessNodeEnvProdGuard(node.right) || isTypeofProcess(node.right) && isProcessNodeEnvProdGuard(node.left))) return;
2651
- pushDiag(ctx, node, "process-dev-gate", "The `typeof process !== \"undefined\" && process.env.NODE_ENV !== \"production\"` gate is DEAD CODE in real Vite browser bundles — Vite does not polyfill `process`. Unit tests pass (vitest has `process`) but the warning never fires in production. Use `import.meta.env?.DEV` instead, which Vite literal-replaces at build time.", getNodeText(ctx, node), "import.meta.env?.DEV === true", false);
2913
+ function isDotValueAccess(node) {
2914
+ return ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name) && node.name.text === "value" && ts.isIdentifier(node.expression);
2652
2915
  }
2653
- function detectEmptyTheme(ctx, node) {
2654
- const callee = node.expression;
2655
- if (!ts.isPropertyAccessExpression(callee)) return;
2656
- if (!ts.isIdentifier(callee.name) || callee.name.text !== "theme") return;
2657
- if (node.arguments.length !== 1) return;
2658
- const arg = node.arguments[0];
2659
- if (!arg || !ts.isObjectLiteralExpression(arg)) return;
2660
- if (arg.properties.length !== 0) return;
2661
- 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);
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);
2662
2929
  }
2663
- function detectRawEventListener(ctx, node) {
2664
- const callee = node.expression;
2665
- if (!ts.isPropertyAccessExpression(callee)) return;
2666
- if (!ts.isIdentifier(callee.name)) return;
2667
- const method = callee.name.text;
2668
- if (method !== "addEventListener" && method !== "removeEventListener") return;
2669
- const target = callee.expression;
2670
- const targetName = ts.isIdentifier(target) ? target.text : ts.isPropertyAccessExpression(target) && ts.isIdentifier(target.name) ? target.name.text : "";
2671
- if (!new Set([
2672
- "window",
2673
- "document",
2674
- "body",
2675
- "el",
2676
- "element",
2677
- "node",
2678
- "target"
2679
- ]).has(targetName)) return;
2680
- if (method === "addEventListener") pushDiag(ctx, node, "raw-add-event-listener", "Raw `addEventListener` in a component / hook body bypasses Pyreon's lifecycle cleanup — listeners leak on unmount. Use `useEventListener` from `@pyreon/hooks` for auto-cleanup.", getNodeText(ctx, node), "useEventListener(target, event, handler)", false);
2681
- else pushDiag(ctx, node, "raw-remove-event-listener", "Raw `removeEventListener` is the symptom of manual listener management. Replace the paired `addEventListener` with `useEventListener` from `@pyreon/hooks` — it registers the cleanup automatically.", getNodeText(ctx, node), "useEventListener(target, event, handler) // cleanup is automatic", false);
2930
+ function detectVisit(ctx, node) {
2931
+ ts.forEachChild(node, (child) => {
2932
+ detectVisitNode(ctx, child);
2933
+ detectVisit(ctx, child);
2934
+ });
2682
2935
  }
2683
- function isCallTo(node, object, method) {
2684
- 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;
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;
2685
2947
  }
2686
- function subtreeHas(node, predicate) {
2687
- let found = false;
2688
- function walk(n) {
2689
- if (found) return;
2690
- if (predicate(n)) {
2691
- found = true;
2692
- return;
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);
2693
2978
  }
2694
- ts.forEachChild(n, walk);
2695
2979
  }
2696
- walk(node);
2697
- return found;
2698
- }
2699
- function detectDateMathRandomId(ctx, node) {
2700
- if (!subtreeHas(node, (n) => isCallTo(n, "Date", "now"))) return;
2701
- if (!subtreeHas(node, (n) => isCallTo(n, "Math", "random"))) return;
2702
- 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);
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
+ }
2703
3024
  }
2704
- function detectOnClickUndefined(ctx, node) {
2705
- if (!ts.isIdentifier(node.name)) return;
2706
- const attrName = node.name.text;
2707
- if (!attrName.startsWith("on") || attrName.length < 3) return;
2708
- if (!node.initializer || !ts.isJsxExpression(node.initializer)) return;
2709
- const expr = node.initializer.expression;
2710
- if (!expr) return;
2711
- if (!(ts.isIdentifier(expr) && expr.text === "undefined" || expr.kind === ts.SyntaxKind.VoidExpression)) return;
2712
- 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);
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
+ }
2713
3036
  }
2714
- /**
2715
- * Walks the file and collects every identifier bound to a `signal(...)` or
2716
- * `computed(...)` call. Only `const` declarations are tracked — `let`/`var`
2717
- * may be reassigned to non-signal values, so a use-site call wouldn't be a
2718
- * reliable signal-write.
2719
- *
2720
- * The collection is intentionally scope-blind: a name shadowed in a nested
2721
- * scope (`const x = signal(0); function f() { const x = 5; x(7) }`) would
2722
- * produce a false positive on `x(7)`. That tradeoff is acceptable because
2723
- * (1) shadowing a signal name with a non-signal is itself unusual and
2724
- * (2) the detector message points at exactly the wrong-shape call so a
2725
- * human reviewer can dismiss the rare false positive in seconds.
2726
- */
2727
- function collectSignalBindings(sf) {
2728
- const names = /* @__PURE__ */ new Set();
2729
- function isSignalFactoryCall(init) {
2730
- if (!init || !ts.isCallExpression(init)) return false;
2731
- const callee = init.expression;
2732
- if (!ts.isIdentifier(callee)) return false;
2733
- return callee.text === "signal" || callee.text === "computed";
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
+ });
2734
3046
  }
2735
- function walk(node) {
2736
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
2737
- const list = node.parent;
2738
- if (ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && isSignalFactoryCall(node.initializer)) names.add(node.name.text);
2739
- }
2740
- ts.forEachChild(node, walk);
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
+ });
2741
3066
  }
2742
- walk(sf);
2743
- return names;
2744
3067
  }
2745
- function detectSignalWriteAsCall(ctx, node) {
2746
- if (ctx.signalBindings.size === 0) return;
3068
+ function migrateMemoWrapper(ctx, node) {
2747
3069
  const callee = node.expression;
2748
- if (!ts.isIdentifier(callee)) return;
2749
- if (!ctx.signalBindings.has(callee.text)) return;
2750
- if (node.arguments.length === 0) return;
2751
- 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);
2752
- }
2753
- /**
2754
- * `if (cond) return null` at the top of a component body runs ONCE — Pyreon
2755
- * components mount and never re-execute their function bodies. A signal
2756
- * change inside `cond` therefore never re-evaluates the condition; the
2757
- * component is permanently stuck on whichever branch the first run picked.
2758
- *
2759
- * The fix is to wrap the conditional in a returned reactive accessor:
2760
- * return (() => { if (!cond()) return null; return <div /> })
2761
- *
2762
- * Detection:
2763
- * - The function contains JSX (i.e. it's a component)
2764
- * - The function body has an `IfStatement` whose `thenStatement` is
2765
- * `return null` (either bare `return null` or `{ return null }`)
2766
- * - The `if` is at the function body's top level, NOT inside a returned
2767
- * arrow / IIFE (those are reactive scopes — flagging them would be a
2768
- * false positive)
2769
- */
2770
- function returnsNullStatement(stmt) {
2771
- if (ts.isReturnStatement(stmt)) {
2772
- const expr = stmt.expression;
2773
- return !!expr && expr.kind === ts.SyntaxKind.NullKeyword;
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
+ });
2774
3077
  }
2775
- if (ts.isBlock(stmt)) return stmt.statements.length === 1 && returnsNullStatement(stmt.statements[0]);
2776
- return false;
2777
- }
2778
- /**
2779
- * Returns true if the function looks like a top-level component:
2780
- * - `function PascalName(...) { ... }` (FunctionDeclaration with PascalCase id), OR
2781
- * - `const PascalName = (...) => { ... }` (arrow inside a VariableDeclaration whose name is PascalCase).
2782
- *
2783
- * Anonymous nested arrows — most importantly the reactive accessor
2784
- * `return (() => { if (!cond()) return null; return <div /> })` — are
2785
- * NOT considered components here, even when they contain JSX. Without
2786
- * this filter the detector would fire on the very pattern the
2787
- * diagnostic recommends as the fix.
2788
- */
2789
- function isComponentShapedFunction(node) {
2790
- if (ts.isFunctionDeclaration(node)) return !!node.name && /^[A-Z]/.test(node.name.text);
2791
- const parent = node.parent;
2792
- if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return /^[A-Z]/.test(parent.name.text);
2793
- return false;
2794
3078
  }
2795
- function detectStaticReturnNullConditional(ctx, node) {
2796
- if (!isComponentShapedFunction(node)) return;
2797
- if (!containsJsx(node)) return;
2798
- const body = node.body;
2799
- if (!body || !ts.isBlock(body)) return;
2800
- for (const stmt of body.statements) {
2801
- if (!ts.isIfStatement(stmt)) continue;
2802
- if (!returnsNullStatement(stmt.thenStatement)) continue;
2803
- 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);
2804
- 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
+ });
2805
3088
  }
2806
3089
  }
2807
- /**
2808
- * `JSX.Element` (which is what JSX evaluates to) is already assignable to
2809
- * `VNodeChild`. The `as unknown as VNodeChild` double-cast is unnecessary
2810
- * it's been showing up in `@pyreon/ui-primitives` as a defensive habit
2811
- * carried over from earlier framework versions. The cast is never load-
2812
- * bearing today; removing it never changes runtime behavior. Pure cosmetic
2813
- * but a useful proxy for non-idiomatic Pyreon code in primitives.
2814
- */
2815
- function detectAsUnknownAsVNodeChild(ctx, node) {
2816
- const outerType = node.type;
2817
- if (!ts.isTypeReferenceNode(outerType)) return;
2818
- if (!ts.isIdentifier(outerType.typeName) || outerType.typeName.text !== "VNodeChild") return;
2819
- const inner = node.expression;
2820
- if (!ts.isAsExpression(inner)) return;
2821
- if (inner.type.kind !== ts.SyntaxKind.UnknownKeyword) return;
2822
- 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);
2823
- }
2824
- /**
2825
- * Pre-pass: walk the source for `island(loader, { name: 'X', hydrate: 'never' })`
2826
- * call expressions and collect the `name` field of each never-strategy island.
2827
- *
2828
- * Recognized shape (mirrors `@pyreon/vite-plugin`'s `scanIslandDeclarations`):
2829
- *
2830
- * island(() => import('./X'), { name: 'X', hydrate: 'never' })
2831
- *
2832
- * Edge cases the AST-walker deliberately doesn't cover (unrecognized calls
2833
- * fall through and don't populate the set — false-negatives, not false
2834
- * positives):
2835
- *
2836
- * - Loader is a variable, not an inline arrow
2837
- * - Name is a variable / template / spread, not a string literal
2838
- * - Options come from a spread (`island(loader, opts)`)
2839
- *
2840
- * The same rules apply on the registry side (`detectIslandNeverWithRegistry`):
2841
- * unrecognized keys won't match. Both halves are syntactic — a semantic
2842
- * cross-package audit lives in `pyreon doctor --check-islands` (separate PR).
2843
- */
2844
- function collectNeverIslandNames(sf) {
2845
- const names = /* @__PURE__ */ new Set();
2846
- function walk(node) {
2847
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "island" && node.arguments.length >= 2) {
2848
- const opts = node.arguments[1];
2849
- if (opts && ts.isObjectLiteralExpression(opts)) {
2850
- let nameVal;
2851
- let hydrateVal;
2852
- for (const prop of opts.properties) {
2853
- if (!ts.isPropertyAssignment(prop)) continue;
2854
- const key = prop.name;
2855
- const keyText = ts.isIdentifier(key) ? key.text : ts.isStringLiteral(key) ? key.text : "";
2856
- if (keyText === "name" && ts.isStringLiteral(prop.initializer)) nameVal = prop.initializer.text;
2857
- else if (keyText === "hydrate" && ts.isStringLiteral(prop.initializer)) hydrateVal = prop.initializer.text;
2858
- }
2859
- if (nameVal && hydrateVal === "never") names.add(nameVal);
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
+ });
2860
3120
  }
2861
3121
  }
2862
- ts.forEachChild(node, walk);
2863
- }
2864
- walk(sf);
2865
- return names;
3122
+ }
3123
+ if (attrName === "dangerouslySetInnerHTML") migrateDangerouslySetInnerHTML(ctx, node);
2866
3124
  }
2867
- /**
2868
- * Flag entries in `hydrateIslands({ X: () => import('./X'), ... })` whose
2869
- * key matches an `island()` name declared with `hydrate: 'never'` in the
2870
- * same file. Each matching entry produces one diagnostic at the property's
2871
- * location so the IDE highlights exactly which key needs to go.
2872
- */
2873
- function detectIslandNeverWithRegistry(ctx, node) {
2874
- if (ctx.neverIslandNames.size === 0) return;
2875
- const callee = node.expression;
2876
- if (!ts.isIdentifier(callee) || callee.text !== "hydrateIslands") return;
2877
- const arg = node.arguments[0];
2878
- if (!arg || !ts.isObjectLiteralExpression(arg)) return;
2879
- for (const prop of arg.properties) {
2880
- if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue;
2881
- const key = prop.name;
2882
- const keyText = ts.isIdentifier(key) ? key.text : ts.isStringLiteral(key) ? key.text : "";
2883
- if (!keyText || !ctx.neverIslandNames.has(keyText)) continue;
2884
- pushDiag(ctx, prop, "island-never-with-registry-entry", `island "${keyText}" was declared with \`hydrate: 'never'\` and MUST NOT be registered in \`hydrateIslands({ ... })\`. The whole point of the \`'never'\` strategy is shipping zero client JS — registering pulls the component module into the client bundle graph (the runtime short-circuits never-strategy before the registry lookup, but the bundler still includes the import). Drop this entry; the framework handles never-strategy islands at SSR with no client-side wiring.`, getNodeText(ctx, prop), `// remove the "${keyText}" entry — never-strategy islands need no registry entry`, false);
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
+ });
2885
3137
  }
2886
3138
  }
2887
- function visitNode(ctx, node) {
2888
- if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) detectForKeying(ctx, node);
2889
- if (ts.isArrowFunction(node) || ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) {
2890
- detectPropsDestructured(ctx, node);
2891
- detectStaticReturnNullConditional(ctx, node);
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
+ });
2892
3151
  }
2893
- if (ts.isBinaryExpression(node)) {
2894
- detectProcessDevGate(ctx, node);
2895
- detectDateMathRandomId(ctx, node);
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
+ }
2896
3166
  }
2897
- if (ts.isTemplateExpression(node)) detectDateMathRandomId(ctx, node);
2898
- if (ts.isCallExpression(node)) {
2899
- detectEmptyTheme(ctx, node);
2900
- detectRawEventListener(ctx, node);
2901
- detectSignalWriteAsCall(ctx, node);
2902
- detectIslandNeverWithRegistry(ctx, node);
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;
2903
3174
  }
2904
- if (ts.isJsxAttribute(node)) detectOnClickUndefined(ctx, node);
2905
- if (ts.isAsExpression(node)) detectAsUnknownAsVNodeChild(ctx, node);
3175
+ parts.push(code.slice(lastPos));
3176
+ return parts.join("");
2906
3177
  }
2907
- function visit(ctx, node) {
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) {
2908
3203
  ts.forEachChild(node, (child) => {
2909
- visitNode(ctx, child);
2910
- visit(ctx, child);
3204
+ migrateVisitNode(ctx, child);
3205
+ migrateVisit(ctx, child);
2911
3206
  });
2912
3207
  }
2913
- function detectPyreonPatterns(code, filename = "input.tsx") {
3208
+ function migrateReactCode(code, filename = "input.tsx") {
2914
3209
  const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
3210
+ const diagnostics = detectReactPatterns(code, filename);
2915
3211
  const ctx = {
2916
3212
  sf,
2917
3213
  code,
2918
- diagnostics: [],
2919
- signalBindings: collectSignalBindings(sf),
2920
- neverIslandNames: collectNeverIslandNames(sf)
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
2921
3228
  };
2922
- visit(ctx, sf);
2923
- ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column);
2924
- return ctx.diagnostics;
2925
3229
  }
2926
- /** Fast regex pre-filter — returns true if the code is worth a full AST walk. */
2927
- function hasPyreonPatterns(code) {
2928
- 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(?: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) || /\bisland\s*\(/.test(code) && /\bhydrate\s*:\s*['"]never['"]/.test(code);
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;
2929
3348
  }
2930
3349
 
2931
3350
  //#endregion
@@ -4019,5 +4438,5 @@ function formatSsgAudit(result, _options = {}) {
4019
4438
  }
4020
4439
 
4021
4440
  //#endregion
4022
- export { auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformDeferInline, transformJSX, transformJSX_JS };
4441
+ export { analyzeReactivity, auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatReactivityLens, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformDeferInline, transformJSX, transformJSX_JS };
4023
4442
  //# sourceMappingURL=index.js.map