@pyreon/compiler 0.13.1 → 0.14.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
@@ -1,6 +1,11 @@
1
- import ts from "typescript";
2
- import * as fs from "node:fs";
1
+ import { parseSync } from "oxc-parser";
2
+ import { createRequire } from "node:module";
3
+ import { fileURLToPath } from "node:url";
3
4
  import * as path from "node:path";
5
+ import { dirname, join, relative, resolve } from "node:path";
6
+ import * as fs from "node:fs";
7
+ import { readFileSync, readdirSync, statSync } from "node:fs";
8
+ import ts from "typescript";
4
9
 
5
10
  //#region src/jsx.ts
6
11
  /**
@@ -22,21 +27,21 @@ import * as path from "node:path";
22
27
  * values, and all children are text nodes or other static JSX nodes.
23
28
  *
24
29
  * Template emission:
25
- * - JSX element trees with ≥ 2 DOM elements (no components, no spread attrs)
26
- * are compiled to `_tpl(html, bindFn)` calls instead of nested `h()` calls.
30
+ * - JSX element trees with ≥ 1 DOM elements (no components, no spread attrs on
31
+ * inner elements) are compiled to `_tpl(html, bindFn)` calls instead of nested
32
+ * `h()` calls.
27
33
  * - The HTML string is parsed once via <template>.innerHTML, then cloneNode(true)
28
34
  * for each instance (~5-10x faster than sequential createElement calls).
29
35
  * - Static attributes are baked into the HTML string; dynamic attributes and
30
36
  * text content use renderEffect in the bind function.
31
37
  *
32
- * Implementation: TypeScript parser for positions + magic-string replacements.
33
- * No extra runtime dependencies — `typescript` is already in devDependencies.
34
- *
35
- * Known limitation (v0): expressions inside *nested* JSX within a child
36
- * expression container are not individually wrapped. They are still reactive
37
- * because the outer wrapper re-evaluates the whole subtree, just at a coarser
38
- * granularity. Fine-grained nested wrapping is planned for a future pass.
38
+ * Implementation: Rust native binary (napi-rs) when available, JS fallback via oxc-parser.
39
39
  */
40
+ let nativeTransformJsx = null;
41
+ try {
42
+ const __dirname = dirname(fileURLToPath(import.meta.url));
43
+ nativeTransformJsx = createRequire(import.meta.url)(join(__dirname, "..", "native", "pyreon-compiler.node")).transformJsx;
44
+ } catch {}
40
45
  const SKIP_PROPS = new Set(["key", "ref"]);
41
46
  const EVENT_RE = /^on[A-Z]/;
42
47
  const DELEGATED_EVENTS = new Set([
@@ -64,21 +69,123 @@ const DELEGATED_EVENTS = new Set([
64
69
  "touchmove",
65
70
  "submit"
66
71
  ]);
72
+ function getLang(filename) {
73
+ if (filename.endsWith(".jsx")) return "jsx";
74
+ return "tsx";
75
+ }
76
+ /** Binary search for line/column from byte offset. */
77
+ function makeLineIndex(code) {
78
+ const lineStarts = [0];
79
+ for (let i = 0; i < code.length; i++) if (code[i] === "\n") lineStarts.push(i + 1);
80
+ return (offset) => {
81
+ let lo = 0;
82
+ let hi = lineStarts.length - 1;
83
+ while (lo <= hi) {
84
+ const mid = lo + hi >>> 1;
85
+ if (lineStarts[mid] <= offset) lo = mid + 1;
86
+ else hi = mid - 1;
87
+ }
88
+ return {
89
+ line: lo,
90
+ column: offset - lineStarts[lo - 1]
91
+ };
92
+ };
93
+ }
94
+ /** Iterate all direct children of an ESTree node via known property keys. */
95
+ function forEachChild(node, cb) {
96
+ if (!node || typeof node !== "object") return;
97
+ const keys = Object.keys(node);
98
+ for (let i = 0; i < keys.length; i++) {
99
+ const key = keys[i];
100
+ if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
101
+ const val = node[key];
102
+ if (Array.isArray(val)) for (let j = 0; j < val.length; j++) {
103
+ const item = val[j];
104
+ if (item && typeof item === "object" && item.type) cb(item);
105
+ }
106
+ else if (val && typeof val === "object" && val.type) cb(val);
107
+ }
108
+ }
109
+ function jsxTagName(node) {
110
+ const opening = node.openingElement;
111
+ if (!opening) return "";
112
+ const name = opening.name;
113
+ return name?.type === "JSXIdentifier" ? name.name : "";
114
+ }
115
+ function isSelfClosing(node) {
116
+ return node.type === "JSXElement" && node.openingElement?.selfClosing === true;
117
+ }
118
+ function jsxAttrs(node) {
119
+ return node.openingElement?.attributes ?? [];
120
+ }
121
+ function jsxChildren(node) {
122
+ return node.children ?? [];
123
+ }
67
124
  function transformJSX(code, filename = "input.tsx", options = {}) {
125
+ if (nativeTransformJsx) try {
126
+ return nativeTransformJsx(code, filename, options.ssr === true, options.knownSignals ?? null);
127
+ } catch {}
128
+ return transformJSX_JS(code, filename, options);
129
+ }
130
+ /** JS fallback implementation — used when the native binary isn't available. */
131
+ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
68
132
  const ssr = options.ssr === true;
69
- const scriptKind = filename.endsWith(".tsx") || filename.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TSX;
70
- const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, scriptKind);
133
+ let program;
134
+ try {
135
+ program = parseSync(filename, code, {
136
+ sourceType: "module",
137
+ lang: getLang(filename)
138
+ }).program;
139
+ } catch {
140
+ return {
141
+ code,
142
+ warnings: []
143
+ };
144
+ }
145
+ const locate = makeLineIndex(code);
71
146
  const replacements = [];
72
147
  const warnings = [];
73
148
  function warn(node, message, warnCode) {
74
- const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
149
+ const { line, column } = locate(node.start);
75
150
  warnings.push({
76
151
  message,
77
- line: line + 1,
78
- column: character,
152
+ line,
153
+ column,
79
154
  code: warnCode
80
155
  });
81
156
  }
157
+ const parentMap = /* @__PURE__ */ new WeakMap();
158
+ const childrenMap = /* @__PURE__ */ new WeakMap();
159
+ /** Build parent pointers + cached children arrays for the entire AST. */
160
+ function buildMaps(node) {
161
+ const kids = [];
162
+ const keys = Object.keys(node);
163
+ for (let i = 0; i < keys.length; i++) {
164
+ const key = keys[i];
165
+ if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
166
+ const val = node[key];
167
+ if (Array.isArray(val)) for (let j = 0; j < val.length; j++) {
168
+ const item = val[j];
169
+ if (item && typeof item === "object" && item.type) kids.push(item);
170
+ }
171
+ else if (val && typeof val === "object" && val.type) kids.push(val);
172
+ }
173
+ childrenMap.set(node, kids);
174
+ for (let i = 0; i < kids.length; i++) {
175
+ parentMap.set(kids[i], node);
176
+ buildMaps(kids[i]);
177
+ }
178
+ }
179
+ buildMaps(program);
180
+ function findParent(node) {
181
+ return parentMap.get(node);
182
+ }
183
+ /** Fast child iteration using pre-computed children array. */
184
+ function forEachChildFast(node, cb) {
185
+ const kids = childrenMap.get(node);
186
+ if (!kids) return;
187
+ for (let i = 0; i < kids.length; i++) cb(kids[i]);
188
+ }
82
189
  const hoists = [];
83
190
  let hoistIdx = 0;
84
191
  let needsTplImport = false;
@@ -88,14 +195,10 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
88
195
  let needsBindImportGlobal = false;
89
196
  let needsApplyPropsImportGlobal = false;
90
197
  let needsMountSlotImportGlobal = false;
91
- /**
92
- * If `node` is a fully-static JSX element/fragment, register a module-scope
93
- * hoist for it and return the generated variable name. Otherwise return null.
94
- */
95
198
  function maybeHoist(node) {
96
- if ((ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) && isStaticJSXNode(node)) {
199
+ if ((node.type === "JSXElement" || node.type === "JSXFragment") && isStaticJSXNode(node)) {
97
200
  const name = `_$h${hoistIdx++}`;
98
- const text = code.slice(node.getStart(sf), node.getEnd());
201
+ const text = code.slice(node.start, node.end);
99
202
  hoists.push({
100
203
  name,
101
204
  text
@@ -105,36 +208,35 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
105
208
  return null;
106
209
  }
107
210
  function wrap(expr) {
108
- const start = expr.getStart(sf);
109
- const end = expr.getEnd();
211
+ const start = expr.start;
212
+ const end = expr.end;
110
213
  const sliced = sliceExpr(expr);
111
- const text = ts.isObjectLiteralExpression(expr) ? `() => (${sliced})` : `() => ${sliced}`;
214
+ const text = expr.type === "ObjectExpression" ? `() => (${sliced})` : `() => ${sliced}`;
112
215
  replacements.push({
113
216
  start,
114
217
  end,
115
218
  text
116
219
  });
117
220
  }
118
- /** Try to hoist or wrap an expression, pushing a replacement if needed. */
119
221
  function hoistOrWrap(expr) {
120
222
  const hoistName = maybeHoist(expr);
121
223
  if (hoistName) replacements.push({
122
- start: expr.getStart(sf),
123
- end: expr.getEnd(),
224
+ start: expr.start,
225
+ end: expr.end,
124
226
  text: hoistName
125
227
  });
126
228
  else if (shouldWrap(expr)) wrap(expr);
127
229
  }
128
- /** Try to emit a template for a JsxElement. Returns true if handled. */
129
230
  function tryTemplateEmit(node) {
130
231
  if (ssr) return false;
232
+ if (isSelfClosing(node)) return false;
131
233
  if (templateElementCount(node, true) < 1) return false;
132
234
  const tplCall = buildTemplateCall(node);
133
235
  if (!tplCall) return false;
134
- const start = node.getStart(sf);
135
- const end = node.getEnd();
136
- const parent = node.parent;
137
- const needsBraces = parent && (ts.isJsxElement(parent) || ts.isJsxFragment(parent));
236
+ const start = node.start;
237
+ const end = node.end;
238
+ const parent = findParent(node);
239
+ const needsBraces = parent && (parent.type === "JSXElement" || parent.type === "JSXFragment");
138
240
  replacements.push({
139
241
  start,
140
242
  end,
@@ -143,46 +245,33 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
143
245
  needsTplImport = true;
144
246
  return true;
145
247
  }
146
- /** Emit warnings for common JSX mistakes (e.g. <For> without by). */
147
248
  function checkForWarnings(node) {
148
- const opening = ts.isJsxElement(node) ? node.openingElement : node;
149
- if ((ts.isIdentifier(opening.tagName) ? opening.tagName.text : "") !== "For") return;
150
- if (!opening.attributes.properties.some((p) => ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === "by")) warn(opening.tagName, `<For> without a "by" prop will use index-based diffing, which is slower and may cause bugs with stateful children. Add by={(item) => item.id} for efficient keyed reconciliation.`, "missing-key-on-for");
151
- }
152
- /** Handle a JSX attribute node wrap or hoist its value if needed.
153
- *
154
- * Both DOM and component props are processed:
155
- * - DOM props: () => expr — applyProp creates renderEffect
156
- * - Component props: _rp(() => expr) — makeReactiveProps converts to getters
157
- *
158
- * The _rp() brand distinguishes compiler wrappers from user-written accessor
159
- * props (like Show's when, For's each) so makeReactiveProps only converts
160
- * compiler-emitted wrappers.
161
- */
162
- function handleJsxAttribute(node) {
163
- const name = ts.isIdentifier(node.name) ? node.name.text : "";
249
+ if (jsxTagName(node) !== "For") return;
250
+ if (!jsxAttrs(node).some((p) => p.type === "JSXAttribute" && p.name?.type === "JSXIdentifier" && p.name.name === "by")) warn(node.openingElement?.name ?? node, `<For> without a "by" prop will use index-based diffing, which is slower and may cause bugs with stateful children. Add by={(item) => item.id} for efficient keyed reconciliation.`, "missing-key-on-for");
251
+ }
252
+ function handleJsxAttribute(node, parentElement) {
253
+ const name = node.name?.type === "JSXIdentifier" ? node.name.name : "";
164
254
  if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return;
165
- if (!node.initializer || !ts.isJsxExpression(node.initializer)) return;
166
- const expr = node.initializer.expression;
167
- if (!expr) return;
168
- const openingEl = node.parent.parent;
169
- const tagName = ts.isIdentifier(openingEl.tagName) ? openingEl.tagName.text : "";
255
+ if (!node.value || node.value.type !== "JSXExpressionContainer") return;
256
+ const expr = node.value.expression;
257
+ if (!expr || expr.type === "JSXEmptyExpression") return;
258
+ const tagName = jsxTagName(parentElement);
170
259
  if (tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()) {
171
- if (ts.isJsxElement(expr) || ts.isJsxSelfClosingElement(expr)) {
172
- ts.forEachChild(expr, walk);
260
+ if (expr.type === "JSXElement" || expr.type === "JSXFragment") {
261
+ walkNode(expr);
173
262
  return;
174
263
  }
175
264
  const hoistName = maybeHoist(expr);
176
265
  if (hoistName) replacements.push({
177
- start: expr.getStart(sf),
178
- end: expr.getEnd(),
266
+ start: expr.start,
267
+ end: expr.end,
179
268
  text: hoistName
180
269
  });
181
270
  else if (shouldWrap(expr)) {
182
- const start = expr.getStart(sf);
183
- const end = expr.getEnd();
271
+ const start = expr.start;
272
+ const end = expr.end;
184
273
  const sliced = sliceExpr(expr);
185
- const inner = ts.isObjectLiteralExpression(expr) ? `(${sliced})` : sliced;
274
+ const inner = expr.type === "ObjectExpression" ? `(${sliced})` : sliced;
186
275
  replacements.push({
187
276
  start,
188
277
  end,
@@ -192,15 +281,14 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
192
281
  }
193
282
  } else hoistOrWrap(expr);
194
283
  }
195
- /** Handle a JSX expression in child position — wrap, hoist, or recurse. */
196
284
  function handleJsxExpression(node) {
197
285
  const expr = node.expression;
198
- if (!expr) return;
286
+ if (!expr || expr.type === "JSXEmptyExpression") return;
199
287
  const hoistName = maybeHoist(expr);
200
288
  if (hoistName) {
201
289
  replacements.push({
202
- start: expr.getStart(sf),
203
- end: expr.getEnd(),
290
+ start: expr.start,
291
+ end: expr.end,
204
292
  text: hoistName
205
293
  });
206
294
  return;
@@ -209,180 +297,259 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
209
297
  wrap(expr);
210
298
  return;
211
299
  }
212
- ts.forEachChild(expr, walk);
300
+ walkNode(expr);
213
301
  }
214
- /** Names that refer to the props object or splitProps results. */
215
302
  const propsNames = /* @__PURE__ */ new Set();
216
- /** Map of variable name → AST node of the original expression.
217
- * Using AST nodes instead of text avoids all string manipulation edge cases. */
218
303
  const propDerivedVars = /* @__PURE__ */ new Map();
219
- /** Check if an expression reads from a tracked props-like object. */
304
+ const signalVars = new Set(options.knownSignals);
305
+ const shadowedSignals = /* @__PURE__ */ new Set();
306
+ /** Check if an identifier name is an active (non-shadowed) signal variable. */
307
+ function isActiveSignal(name) {
308
+ return signalVars.has(name) && !shadowedSignals.has(name);
309
+ }
310
+ /** Find variable declarations and parameters in a function that shadow signal names. */
311
+ function findShadowingNames(node) {
312
+ const shadows = [];
313
+ for (const param of node.params ?? []) {
314
+ if (param.type === "Identifier" && signalVars.has(param.name)) shadows.push(param.name);
315
+ if (param.type === "ObjectPattern") for (const prop of param.properties ?? []) {
316
+ const val = prop.value ?? prop.key;
317
+ if (val?.type === "Identifier" && signalVars.has(val.name)) shadows.push(val.name);
318
+ }
319
+ if (param.type === "ArrayPattern") {
320
+ for (const el of param.elements ?? []) if (el?.type === "Identifier" && signalVars.has(el.name)) shadows.push(el.name);
321
+ }
322
+ }
323
+ const body = node.body;
324
+ const stmts = body?.body ?? body?.statements;
325
+ if (!Array.isArray(stmts)) return shadows;
326
+ for (const stmt of stmts) if (stmt.type === "VariableDeclaration") {
327
+ for (const decl of stmt.declarations ?? []) if (decl.id?.type === "Identifier" && signalVars.has(decl.id.name)) {
328
+ if (!decl.init || !isSignalCall(decl.init)) shadows.push(decl.id.name);
329
+ }
330
+ }
331
+ return shadows;
332
+ }
220
333
  function readsFromProps(node) {
221
- if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) return propsNames.has(node.expression.text);
222
- if (ts.isElementAccessExpression(node) && ts.isIdentifier(node.expression)) return propsNames.has(node.expression.text);
334
+ if (node.type === "MemberExpression" && node.object?.type === "Identifier") {
335
+ if (propsNames.has(node.object.name)) return true;
336
+ }
223
337
  let found = false;
224
- ts.forEachChild(node, (child) => {
338
+ forEachChildFast(node, (child) => {
225
339
  if (found) return;
226
340
  if (readsFromProps(child)) found = true;
227
341
  });
228
342
  return found;
229
343
  }
230
- /** Pre-pass: scan a function body for prop-derived variable declarations.
231
- * callbackDepth tracks nesting inside callback arguments (map, filter, etc.)
232
- * to avoid tracking variables declared inside callbacks as prop-derived. */
233
- let _callbackDepth = 0;
234
- function scanForPropDerivedVars(node) {
235
- if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
236
- const parent = node.parent;
237
- if (parent && ts.isCallExpression(parent) && parent.arguments.includes(node)) {
238
- _callbackDepth++;
239
- ts.forEachChild(node, scanForPropDerivedVars);
240
- _callbackDepth--;
241
- return;
242
- }
344
+ /** Check if an expression references any prop-derived variable. */
345
+ function referencesPropDerived(node) {
346
+ if (node.type === "Identifier" && propDerivedVars.has(node.name)) {
347
+ const p = findParent(node);
348
+ if (p && p.type === "MemberExpression" && p.property === node && !p.computed) return false;
349
+ return true;
243
350
  }
244
- if ((ts.isFunctionDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && node.parameters.length > 0) {
245
- const parent = node.parent;
246
- if (parent && ts.isCallExpression(parent) && parent.arguments.includes(node)) {
247
- ts.forEachChild(node, scanForPropDerivedVars);
248
- return;
351
+ let found = false;
352
+ forEachChildFast(node, (child) => {
353
+ if (found) return;
354
+ if (referencesPropDerived(child)) found = true;
355
+ });
356
+ return found;
357
+ }
358
+ /** Collect prop-derived variable info from a VariableDeclaration node.
359
+ * Called inline during the single-pass walk when we encounter a declaration. */
360
+ function collectPropDerivedFromDecl(node, callbackDepth) {
361
+ if (node.type !== "VariableDeclaration") return;
362
+ for (const decl of node.declarations ?? []) {
363
+ if (decl.id?.type === "ArrayPattern" && decl.init?.type === "CallExpression") {
364
+ const callee = decl.init.callee;
365
+ if (callee?.type === "Identifier" && callee.name === "splitProps") {
366
+ for (const el of decl.id.elements ?? []) if (el?.type === "Identifier") propsNames.add(el.name);
367
+ }
368
+ }
369
+ if (node.kind !== "const") continue;
370
+ if (callbackDepth > 0) continue;
371
+ if (decl.id?.type === "Identifier" && decl.init) {
372
+ if (isStatefulCall(decl.init)) {
373
+ if (isSignalCall(decl.init)) signalVars.add(decl.id.name);
374
+ continue;
375
+ }
376
+ if (readsFromProps(decl.init) || referencesPropDerived(decl.init)) propDerivedVars.set(decl.id.name, {
377
+ start: decl.init.start,
378
+ end: decl.init.end
379
+ });
249
380
  }
250
- const firstParam = node.parameters[0];
251
- if (ts.isIdentifier(firstParam.name)) {
381
+ }
382
+ }
383
+ /** Detect component functions and register their first param as a props name.
384
+ * Called inline during the walk when entering a function. */
385
+ function maybeRegisterComponentProps(node) {
386
+ if ((node.type === "FunctionDeclaration" || node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") && (node.params?.length ?? 0) > 0) {
387
+ const parent = findParent(node);
388
+ if (parent && parent.type === "CallExpression" && (parent.arguments ?? []).includes(node)) return;
389
+ const firstParam = node.params[0];
390
+ if (firstParam?.type === "Identifier") {
252
391
  let hasJSX = false;
253
- ts.forEachChild(node, function checkJSX(n) {
392
+ function checkJSX(n) {
254
393
  if (hasJSX) return;
255
- if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
394
+ if (n.type === "JSXElement" || n.type === "JSXFragment") {
256
395
  hasJSX = true;
257
396
  return;
258
397
  }
259
- ts.forEachChild(n, checkJSX);
260
- });
261
- if (hasJSX) propsNames.add(firstParam.name.text);
262
- }
263
- }
264
- if (ts.isVariableStatement(node)) for (const decl of node.declarationList.declarations) {
265
- if (ts.isArrayBindingPattern(decl.name) && decl.initializer && ts.isCallExpression(decl.initializer)) {
266
- const callee = decl.initializer.expression;
267
- if (ts.isIdentifier(callee) && callee.text === "splitProps") {
268
- for (const el of decl.name.elements) if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) propsNames.add(el.name.text);
398
+ forEachChildFast(n, checkJSX);
269
399
  }
270
- }
271
- if (!(node.declarationList.flags & ts.NodeFlags.Const)) continue;
272
- if (_callbackDepth > 0) continue;
273
- if (ts.isIdentifier(decl.name) && decl.initializer) {
274
- if (isStatefulCall(decl.initializer)) continue;
275
- if (readsFromProps(decl.initializer)) propDerivedVars.set(decl.name.text, decl.initializer);
400
+ forEachChildFast(node, checkJSX);
401
+ if (hasJSX) propsNames.add(firstParam.name);
276
402
  }
277
403
  }
278
- ts.forEachChild(node, scanForPropDerivedVars);
279
- }
280
- scanForPropDerivedVars(sf);
281
- let changed = true;
282
- while (changed) {
283
- changed = false;
284
- sf.forEachChild(function scanTransitive(node) {
285
- if (!ts.isVariableStatement(node)) {
286
- ts.forEachChild(node, scanTransitive);
287
- return;
288
- }
289
- for (const decl of node.declarationList.declarations) {
290
- if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
291
- const varName = decl.name.text;
292
- if (propDerivedVars.has(varName)) continue;
293
- if (node.declarationList.flags & ts.NodeFlags.Let) continue;
294
- let usesPropVar = false;
295
- ts.forEachChild(decl.initializer, function check(n) {
296
- if (usesPropVar) return;
297
- if (ts.isIdentifier(n) && propDerivedVars.has(n.text)) {
298
- const parent = n.parent;
299
- if (parent && ts.isPropertyAccessExpression(parent) && parent.name === n) return;
300
- usesPropVar = true;
301
- }
302
- ts.forEachChild(n, check);
303
- });
304
- if (usesPropVar) {
305
- propDerivedVars.set(varName, decl.initializer);
306
- changed = true;
307
- }
308
- }
309
- });
310
404
  }
405
+ const resolvedCache = /* @__PURE__ */ new Map();
406
+ const resolving = /* @__PURE__ */ new Set();
311
407
  const warnedCycles = /* @__PURE__ */ new Set();
312
- function resolveExprTransitive(node, visited = /* @__PURE__ */ new Set(), sourceNode) {
313
- return ts.visitNode(node, function visit(n) {
314
- if (ts.isIdentifier(n) && propDerivedVars.has(n.text)) {
315
- const parent = n.parent;
408
+ function resolveVarToString(varName, sourceNode) {
409
+ if (resolvedCache.has(varName)) return resolvedCache.get(varName);
410
+ if (resolving.has(varName)) {
411
+ const cycleKey = [...resolving, varName].sort().join(",");
412
+ if (!warnedCycles.has(cycleKey)) {
413
+ warnedCycles.add(cycleKey);
414
+ const chain = [...resolving, varName].join(" → ");
415
+ warn(sourceNode ?? program, `[Pyreon] Circular prop-derived const reference: ${chain}. The cyclic identifier \`${varName}\` will use its captured value instead of being reactively inlined. Break the cycle by reading from \`props.*\` directly or restructuring the derivation chain.`, "circular-prop-derived");
416
+ }
417
+ return varName;
418
+ }
419
+ resolving.add(varName);
420
+ const span = propDerivedVars.get(varName);
421
+ const resolved = resolveIdentifiersInText(code.slice(span.start, span.end), span.start, sourceNode);
422
+ resolving.delete(varName);
423
+ resolvedCache.set(varName, resolved);
424
+ return resolved;
425
+ }
426
+ function resolveIdentifiersInText(text, baseOffset, sourceNode) {
427
+ const endOffset = baseOffset + text.length;
428
+ const idents = [];
429
+ function findIdents(node, parent) {
430
+ const nodeStart = node.start;
431
+ const nodeEnd = node.end;
432
+ if (nodeStart >= endOffset || nodeEnd <= baseOffset) return;
433
+ if (node.type === "Identifier" && propDerivedVars.has(node.name)) {
316
434
  if (parent) {
317
- if ("name" in parent && parent.name === n) return n;
318
- if (ts.isShorthandPropertyAssignment(parent)) return n;
319
- }
320
- if (visited.has(n.text)) {
321
- const cycleKey = [...visited, n.text].sort().join(",");
322
- if (!warnedCycles.has(cycleKey)) {
323
- warnedCycles.add(cycleKey);
324
- const chain = [...visited, n.text].join(" → ");
325
- warn(sourceNode ?? n, `[Pyreon] Circular prop-derived const reference: ${chain}. The cyclic identifier \`${n.text}\` will use its captured value instead of being reactively inlined. Break the cycle by reading from \`props.*\` directly or restructuring the derivation chain.`, "circular-prop-derived");
326
- }
327
- return n;
328
- }
329
- const resolved = propDerivedVars.get(n.text);
330
- const nextVisited = new Set(visited);
331
- nextVisited.add(n.text);
332
- return ts.factory.createParenthesizedExpression(resolveExprTransitive(resolved, nextVisited, sourceNode));
435
+ if (parent.type === "MemberExpression" && parent.property === node && !parent.computed) {} else if (parent.type === "VariableDeclarator" && parent.id === node) {} else if (parent.type === "Property" && parent.key === node && !parent.computed) {} else if (parent.type === "Property" && parent.shorthand) {} else if (nodeStart >= baseOffset && nodeEnd <= endOffset) idents.push({
436
+ start: nodeStart,
437
+ end: nodeEnd,
438
+ name: node.name
439
+ });
440
+ } else if (nodeStart >= baseOffset && nodeEnd <= endOffset) idents.push({
441
+ start: nodeStart,
442
+ end: nodeEnd,
443
+ name: node.name
444
+ });
333
445
  }
334
- return ts.visitEachChild(n, visit, void 0);
335
- });
446
+ forEachChildFast(node, (child) => findIdents(child, node));
447
+ }
448
+ findIdents(program, null);
449
+ if (idents.length === 0) return text;
450
+ idents.sort((a, b) => a.start - b.start);
451
+ const parts = [];
452
+ let lastPos = baseOffset;
453
+ for (const id of idents) {
454
+ parts.push(code.slice(lastPos, id.start));
455
+ parts.push(`(${resolveVarToString(id.name, sourceNode)})`);
456
+ lastPos = id.end;
457
+ }
458
+ parts.push(code.slice(lastPos, endOffset));
459
+ return parts.join("");
336
460
  }
337
- /** Print an AST expression back to source text. */
338
- const printer = ts.createPrinter({ removeComments: false });
339
- /**
340
- * Enhanced dynamic check — combines containsCall with props awareness.
341
- * Returns true if an expression is reactive (contains signal calls,
342
- * accesses props members, or references prop-derived variables).
343
- */
461
+ const _isDynamicCache = /* @__PURE__ */ new Map();
462
+ /** Fused isDynamic: checks both containsCall and accessesProps in one traversal. */
344
463
  function isDynamic(node) {
345
- if (containsCall(node)) return true;
346
- return accessesProps(node);
464
+ const key = node.start;
465
+ const cached = _isDynamicCache.get(key);
466
+ if (cached !== void 0) return cached;
467
+ const result = _isDynamicImpl(node);
468
+ _isDynamicCache.set(key, result);
469
+ return result;
470
+ }
471
+ function _isDynamicImpl(node) {
472
+ if (node.type === "CallExpression") {
473
+ if (!isPureStaticCall(node)) return true;
474
+ }
475
+ if (node.type === "TaggedTemplateExpression") return true;
476
+ if (node.type === "MemberExpression" && !node.computed && node.object?.type === "Identifier") {
477
+ if (propsNames.has(node.object.name)) return true;
478
+ }
479
+ if (node.type === "Identifier" && propDerivedVars.has(node.name)) {
480
+ const parent = findParent(node);
481
+ if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) {} else return true;
482
+ }
483
+ if (node.type === "Identifier" && isActiveSignal(node.name)) {
484
+ const parent = findParent(node);
485
+ if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) {} else if (parent && parent.type === "CallExpression" && parent.callee === node) {} else return true;
486
+ }
487
+ if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") return false;
488
+ let found = false;
489
+ forEachChildFast(node, (child) => {
490
+ if (found) return;
491
+ if (isDynamic(child)) found = true;
492
+ });
493
+ return found;
347
494
  }
348
- /** Check if an expression accesses a tracked props object or a prop-derived variable. */
495
+ /** accessesProps kept for sliceExpr's quick check (does this need resolution?) */
349
496
  function accessesProps(node) {
350
- if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
351
- if (propsNames.has(node.expression.text)) return true;
497
+ if (node.type === "MemberExpression" && !node.computed && node.object?.type === "Identifier") {
498
+ if (propsNames.has(node.object.name)) return true;
352
499
  }
353
- if (ts.isIdentifier(node) && propDerivedVars.has(node.text)) {
354
- const parent = node.parent;
355
- if (parent && ts.isPropertyAccessExpression(parent) && parent.name === node) return false;
500
+ if (node.type === "Identifier" && propDerivedVars.has(node.name)) {
501
+ const parent = findParent(node);
502
+ if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) return false;
356
503
  return true;
357
504
  }
358
505
  let found = false;
359
- ts.forEachChild(node, (child) => {
506
+ forEachChildFast(node, (child) => {
360
507
  if (found) return;
361
- if (ts.isArrowFunction(child) || ts.isFunctionExpression(child)) return;
508
+ if (child.type === "ArrowFunctionExpression" || child.type === "FunctionExpression") return;
362
509
  if (accessesProps(child)) found = true;
363
510
  });
364
511
  return found;
365
512
  }
366
513
  function shouldWrap(node) {
367
- if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false;
514
+ if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") return false;
368
515
  if (isStatic(node)) return false;
369
- if (ts.isCallExpression(node) && isPureStaticCall(node)) return false;
516
+ if (node.type === "CallExpression" && isPureStaticCall(node)) return false;
370
517
  return isDynamic(node);
371
518
  }
372
- function walk(node) {
373
- if (ts.isJsxElement(node) && tryTemplateEmit(node)) return;
374
- if (ts.isJsxSelfClosingElement(node) || ts.isJsxElement(node)) checkForWarnings(node);
375
- if (ts.isJsxAttribute(node)) {
376
- handleJsxAttribute(node);
519
+ let _callbackDepth = 0;
520
+ function walkNode(node) {
521
+ const isFunction = node.type === "FunctionDeclaration" || node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
522
+ let scopeShadows = null;
523
+ if (isFunction) {
524
+ const parent = findParent(node);
525
+ if (parent && parent.type === "CallExpression" && (parent.arguments ?? []).includes(node)) _callbackDepth++;
526
+ maybeRegisterComponentProps(node);
527
+ if (signalVars.size > 0) {
528
+ scopeShadows = findShadowingNames(node);
529
+ for (const name of scopeShadows) shadowedSignals.add(name);
530
+ }
531
+ }
532
+ if (node.type === "VariableDeclaration") collectPropDerivedFromDecl(node, _callbackDepth);
533
+ if (node.type === "JSXElement") {
534
+ if (!isSelfClosing(node) && tryTemplateEmit(node)) return;
535
+ checkForWarnings(node);
536
+ for (const attr of jsxAttrs(node)) if (attr.type === "JSXAttribute") handleJsxAttribute(attr, node);
537
+ for (const child of jsxChildren(node)) if (child.type === "JSXExpressionContainer") handleJsxExpression(child);
538
+ else walkNode(child);
377
539
  return;
378
540
  }
379
- if (ts.isJsxExpression(node)) {
541
+ if (node.type === "JSXExpressionContainer") {
380
542
  handleJsxExpression(node);
381
543
  return;
382
544
  }
383
- ts.forEachChild(node, walk);
545
+ forEachChildFast(node, walkNode);
546
+ if (isFunction) {
547
+ const parent = findParent(node);
548
+ if (parent && parent.type === "CallExpression" && (parent.arguments ?? []).includes(node)) _callbackDepth--;
549
+ }
550
+ if (scopeShadows) for (const name of scopeShadows) shadowedSignals.delete(name);
384
551
  }
385
- walk(sf);
552
+ walkNode(program);
386
553
  if (replacements.length === 0 && hoists.length === 0) return {
387
554
  code,
388
555
  warnings
@@ -413,67 +580,49 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
413
580
  usesTemplates: needsTplImport,
414
581
  warnings
415
582
  };
416
- /**
417
- * Check if attributes prevent template emission.
418
- * - `key` always bails (VNode reconciliation prop)
419
- * - Spread on inner elements bails (too complex to merge in _bind)
420
- * - Spread on root element is allowed — applied via applyProps in _bind
421
- */
422
583
  function hasBailAttr(node, isRoot = false) {
423
584
  for (const attr of jsxAttrs(node)) {
424
- if (ts.isJsxSpreadAttribute(attr)) {
585
+ if (attr.type === "JSXSpreadAttribute") {
425
586
  if (isRoot) continue;
426
587
  return true;
427
588
  }
428
- if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === "key") return true;
589
+ if (attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === "key") return true;
429
590
  }
430
591
  return false;
431
592
  }
432
- /**
433
- * Count template-eligible elements for a single JSX child.
434
- * Returns 0 for skippable children, -1 for bail, positive for element count.
435
- */
436
593
  function countChildForTemplate(child) {
437
- if (ts.isJsxText(child)) return 0;
438
- if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) return templateElementCount(child);
439
- if (ts.isJsxExpression(child)) {
440
- if (!child.expression) return 0;
441
- return containsJSXInExpr(child.expression) ? -1 : 0;
594
+ if (child.type === "JSXText") return 0;
595
+ if (child.type === "JSXElement") return templateElementCount(child);
596
+ if (child.type === "JSXExpressionContainer") {
597
+ const expr = child.expression;
598
+ if (!expr || expr.type === "JSXEmptyExpression") return 0;
599
+ return containsJSXInExpr(expr) ? -1 : 0;
442
600
  }
443
- if (ts.isJsxFragment(child)) return templateFragmentCount(child);
601
+ if (child.type === "JSXFragment") return templateFragmentCount(child);
444
602
  return -1;
445
603
  }
446
- /**
447
- * Count DOM elements in a JSX subtree. Returns -1 if the tree is not
448
- * eligible for template emission.
449
- */
450
604
  function templateElementCount(node, isRoot = false) {
451
605
  const tag = jsxTagName(node);
452
606
  if (!tag || !isLowerCase(tag)) return -1;
453
607
  if (hasBailAttr(node, isRoot)) return -1;
454
- if (!ts.isJsxElement(node)) return 1;
608
+ if (isSelfClosing(node)) return 1;
455
609
  let count = 1;
456
- for (const child of node.children) {
610
+ for (const child of jsxChildren(node)) {
457
611
  const c = countChildForTemplate(child);
458
612
  if (c === -1) return -1;
459
613
  count += c;
460
614
  }
461
615
  return count;
462
616
  }
463
- /** Count template-eligible elements inside a fragment. */
464
617
  function templateFragmentCount(frag) {
465
618
  let count = 0;
466
- for (const child of frag.children) {
619
+ for (const child of jsxChildren(frag)) {
467
620
  const c = countChildForTemplate(child);
468
621
  if (c === -1) return -1;
469
622
  count += c;
470
623
  }
471
624
  return count;
472
625
  }
473
- /**
474
- * Build the complete `_tpl("html", (__root) => { ... })` call string
475
- * for a template-eligible JSX element tree. Returns null if codegen fails.
476
- */
477
626
  function buildTemplateCall(node) {
478
627
  const bindLines = [];
479
628
  const disposerNames = [];
@@ -495,7 +644,6 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
495
644
  function nextTextVar() {
496
645
  return `__t${varIdx++}`;
497
646
  }
498
- /** Resolve the variable name for an element given its accessor path. */
499
647
  function resolveElementVar(accessor, hasDynamic) {
500
648
  if (accessor === "__root") return "__root";
501
649
  if (hasDynamic) {
@@ -505,52 +653,44 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
505
653
  }
506
654
  return accessor;
507
655
  }
508
- /** Emit bind line for a ref attribute. */
509
656
  function emitRef(attr, varName) {
510
- if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return;
511
- const expr = attr.initializer.expression;
512
- if (!expr) return;
513
- if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) bindLines.push(`(${sliceExpr(expr)})(${varName})`);
657
+ if (!attr.value || attr.value.type !== "JSXExpressionContainer") return;
658
+ const expr = attr.value.expression;
659
+ if (!expr || expr.type === "JSXEmptyExpression") return;
660
+ if (expr.type === "ArrowFunctionExpression" || expr.type === "FunctionExpression") bindLines.push(`(${sliceExpr(expr)})(${varName})`);
514
661
  else bindLines.push(`{ const __r = ${sliceExpr(expr)}; if (typeof __r === "function") __r(${varName}); else if (__r) __r.current = ${varName} }`);
515
662
  }
516
- /** Emit event handler bind line — delegated (expando) or addEventListener. */
517
663
  function emitEventListener(attr, attrName, varName) {
518
664
  const eventName = (attrName[2] ?? "").toLowerCase() + attrName.slice(3);
519
- if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return;
520
- if (!attr.initializer.expression) return;
521
- const handler = sliceExpr(attr.initializer.expression);
665
+ if (!attr.value || attr.value.type !== "JSXExpressionContainer") return;
666
+ const expr = attr.value.expression;
667
+ if (!expr || expr.type === "JSXEmptyExpression") return;
668
+ const handler = sliceExpr(expr);
522
669
  if (DELEGATED_EVENTS.has(eventName)) bindLines.push(`${varName}.__ev_${eventName} = ${handler}`);
523
670
  else bindLines.push(`${varName}.addEventListener("${eventName}", ${handler})`);
524
671
  }
525
- /** Return HTML string for a static attribute expression, or null if not static. */
526
672
  function staticAttrToHtml(exprNode, htmlAttrName) {
527
673
  if (!isStatic(exprNode)) return null;
528
- if (ts.isStringLiteral(exprNode)) return ` ${htmlAttrName}="${escapeHtmlAttr(exprNode.text)}"`;
529
- if (ts.isNumericLiteral(exprNode)) return ` ${htmlAttrName}="${exprNode.text}"`;
530
- if (exprNode.kind === ts.SyntaxKind.TrueKeyword) return ` ${htmlAttrName}`;
674
+ if ((exprNode.type === "Literal" || exprNode.type === "StringLiteral") && typeof exprNode.value === "string") return ` ${htmlAttrName}="${escapeHtmlAttr(exprNode.value)}"`;
675
+ if ((exprNode.type === "Literal" || exprNode.type === "NumericLiteral") && typeof exprNode.value === "number") return ` ${htmlAttrName}="${exprNode.value}"`;
676
+ if ((exprNode.type === "Literal" || exprNode.type === "BooleanLiteral") && exprNode.value === true) return ` ${htmlAttrName}`;
531
677
  return "";
532
678
  }
533
- /**
534
- * Try to extract a direct signal reference from an expression.
535
- * Returns the callee text (e.g. "count" or "row.label") if the expression
536
- * is a single call with no arguments, otherwise null.
537
- */
538
679
  function tryDirectSignalRef(exprNode) {
539
680
  let inner = exprNode;
540
- if (ts.isArrowFunction(inner) && !ts.isBlock(inner.body)) inner = inner.body;
541
- if (!ts.isCallExpression(inner)) return null;
542
- if (inner.arguments.length > 0) return null;
543
- const callee = inner.expression;
544
- if (ts.isIdentifier(callee)) return sliceExpr(callee);
681
+ if (inner.type === "ArrowFunctionExpression" && inner.body?.type !== "BlockStatement") inner = inner.body;
682
+ if (inner.type !== "CallExpression") return null;
683
+ if ((inner.arguments?.length ?? 0) > 0) return null;
684
+ const callee = inner.callee;
685
+ if (callee?.type === "Identifier") return sliceExpr(callee);
545
686
  return null;
546
687
  }
547
- /** Unwrap a reactive accessor expression for use inside _bind(). */
548
688
  function unwrapAccessor(exprNode) {
549
- if (ts.isArrowFunction(exprNode) && !ts.isBlock(exprNode.body)) return {
689
+ if (exprNode.type === "ArrowFunctionExpression" && exprNode.body?.type !== "BlockStatement") return {
550
690
  expr: sliceExpr(exprNode.body),
551
691
  isReactive: true
552
692
  };
553
- if (ts.isArrowFunction(exprNode) || ts.isFunctionExpression(exprNode)) return {
693
+ if (exprNode.type === "ArrowFunctionExpression" || exprNode.type === "FunctionExpression") return {
554
694
  expr: `(${sliceExpr(exprNode)})()`,
555
695
  isReactive: true
556
696
  };
@@ -559,13 +699,11 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
559
699
  isReactive: isDynamic(exprNode)
560
700
  };
561
701
  }
562
- /** Build a setter expression for an attribute. */
563
702
  function attrSetter(htmlAttrName, varName, expr) {
564
703
  if (htmlAttrName === "class") return `${varName}.className = ${expr}`;
565
704
  if (htmlAttrName === "style") return `${varName}.style.cssText = ${expr}`;
566
705
  return `${varName}.setAttribute("${htmlAttrName}", ${expr})`;
567
706
  }
568
- /** Emit bind line for a dynamic (non-static) attribute. */
569
707
  function emitDynamicAttr(_expr, exprNode, htmlAttrName, varName) {
570
708
  const { expr, isReactive } = unwrapAccessor(exprNode);
571
709
  if (!isReactive) {
@@ -582,18 +720,16 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
582
720
  }
583
721
  reactiveBindExprs.push(attrSetter(htmlAttrName, varName, expr));
584
722
  }
585
- /** Emit bind line or HTML for an expression attribute value. */
586
723
  function emitAttrExpression(exprNode, htmlAttrName, varName) {
587
724
  const staticHtml = staticAttrToHtml(exprNode, htmlAttrName);
588
725
  if (staticHtml !== null) return staticHtml;
589
- if (htmlAttrName === "style" && ts.isObjectLiteralExpression(exprNode)) {
726
+ if (htmlAttrName === "style" && exprNode.type === "ObjectExpression") {
590
727
  bindLines.push(`Object.assign(${varName}.style, ${sliceExpr(exprNode)})`);
591
728
  return "";
592
729
  }
593
730
  emitDynamicAttr(sliceExpr(exprNode), exprNode, htmlAttrName, varName);
594
731
  return "";
595
732
  }
596
- /** Emit side-effects for special attrs (ref, event). Returns true if handled. */
597
733
  function tryEmitSpecialAttr(attr, attrName, varName) {
598
734
  if (attrName === "ref") {
599
735
  emitRef(attr, varName);
@@ -605,35 +741,34 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
605
741
  }
606
742
  return false;
607
743
  }
608
- /** Convert an attribute initializer to HTML. Returns empty string for side-effect-only attrs. */
609
744
  function attrInitializerToHtml(attr, htmlAttrName, varName) {
610
- if (!attr.initializer) return ` ${htmlAttrName}`;
611
- if (ts.isStringLiteral(attr.initializer)) return ` ${htmlAttrName}="${escapeHtmlAttr(attr.initializer.text)}"`;
612
- if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression) return emitAttrExpression(attr.initializer.expression, htmlAttrName, varName);
745
+ if (!attr.value) return ` ${htmlAttrName}`;
746
+ if (attr.value.type === "StringLiteral" || attr.value.type === "Literal" && typeof attr.value.value === "string") return ` ${htmlAttrName}="${escapeHtmlAttr(attr.value.value)}"`;
747
+ if (attr.value.type === "JSXExpressionContainer") {
748
+ const expr = attr.value.expression;
749
+ if (expr && expr.type !== "JSXEmptyExpression") return emitAttrExpression(expr, htmlAttrName, varName);
750
+ }
613
751
  return "";
614
752
  }
615
- /** Process a single attribute, returning HTML to append. */
616
753
  function processOneAttr(attr, varName) {
617
- if (ts.isJsxSpreadAttribute(attr)) {
618
- const expr = sliceExpr(attr.expression);
754
+ if (attr.type === "JSXSpreadAttribute") {
755
+ const expr = sliceExpr(attr.argument);
619
756
  needsApplyPropsImport = true;
620
- if (isDynamic(attr.expression)) reactiveBindExprs.push(`_applyProps(${varName}, ${expr})`);
757
+ if (isDynamic(attr.argument)) reactiveBindExprs.push(`_applyProps(${varName}, ${expr})`);
621
758
  else bindLines.push(`_applyProps(${varName}, ${expr})`);
622
759
  return "";
623
760
  }
624
- if (!ts.isJsxAttribute(attr)) return "";
625
- const attrName = ts.isIdentifier(attr.name) ? attr.name.text : "";
761
+ if (attr.type !== "JSXAttribute") return "";
762
+ const attrName = attr.name?.type === "JSXIdentifier" ? attr.name.name : "";
626
763
  if (attrName === "key") return "";
627
764
  if (tryEmitSpecialAttr(attr, attrName, varName)) return "";
628
765
  return attrInitializerToHtml(attr, JSX_TO_HTML_ATTR[attrName] ?? attrName, varName);
629
766
  }
630
- /** Process all attributes on an element, returning the HTML attribute string. */
631
767
  function processAttrs(el, varName) {
632
768
  let htmlAttrs = "";
633
769
  for (const attr of jsxAttrs(el)) htmlAttrs += processOneAttr(attr, varName);
634
770
  return htmlAttrs;
635
771
  }
636
- /** Emit bind lines for a reactive text expression child. */
637
772
  function emitReactiveTextChild(expr, exprNode, varName, parentRef, childNodeIdx, needsPlaceholder) {
638
773
  const tVar = nextTextVar();
639
774
  bindLines.push(`const ${tVar} = document.createTextNode("")`);
@@ -651,7 +786,6 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
651
786
  }
652
787
  return needsPlaceholder ? "<!>" : "";
653
788
  }
654
- /** Emit bind lines for a static text expression child. */
655
789
  function emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder) {
656
790
  if (needsPlaceholder) {
657
791
  const tVar = nextTextVar();
@@ -662,7 +796,65 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
662
796
  bindLines.push(`${varName}.textContent = ${expr}`);
663
797
  return "";
664
798
  }
665
- /** Process a single flat child, returning the HTML contribution or null on failure. */
799
+ function classifyJsxChild(child, out, elemIdxRef, recurse) {
800
+ if (child.type === "JSXText") {
801
+ const trimmed = (child.value ?? child.raw ?? "").replace(/\n\s*/g, "").trim();
802
+ if (trimmed) out.push({
803
+ kind: "text",
804
+ text: trimmed
805
+ });
806
+ return;
807
+ }
808
+ if (child.type === "JSXElement") {
809
+ out.push({
810
+ kind: "element",
811
+ node: child,
812
+ elemIdx: elemIdxRef.value++
813
+ });
814
+ return;
815
+ }
816
+ if (child.type === "JSXExpressionContainer") {
817
+ const expr = child.expression;
818
+ if (expr && expr.type !== "JSXEmptyExpression") out.push({
819
+ kind: "expression",
820
+ expression: expr
821
+ });
822
+ return;
823
+ }
824
+ if (child.type === "JSXFragment") recurse(jsxChildren(child));
825
+ }
826
+ function flattenChildren(children) {
827
+ const flatList = [];
828
+ const elemIdxRef = { value: 0 };
829
+ function addChildren(kids) {
830
+ for (const child of kids) classifyJsxChild(child, flatList, elemIdxRef, addChildren);
831
+ }
832
+ addChildren(children);
833
+ return flatList;
834
+ }
835
+ function analyzeChildren(flatChildren) {
836
+ const hasElem = flatChildren.some((c) => c.kind === "element");
837
+ const hasNonElem = flatChildren.some((c) => c.kind !== "element");
838
+ const exprCount = flatChildren.filter((c) => c.kind === "expression").length;
839
+ return {
840
+ useMixed: hasElem && hasNonElem,
841
+ useMultiExpr: exprCount > 1
842
+ };
843
+ }
844
+ function attrIsDynamic(attr) {
845
+ if (attr.type !== "JSXAttribute") return false;
846
+ const name = attr.name?.type === "JSXIdentifier" ? attr.name.name : "";
847
+ if (name === "ref") return true;
848
+ if (EVENT_RE.test(name)) return true;
849
+ if (!attr.value || attr.value.type !== "JSXExpressionContainer") return false;
850
+ const expr = attr.value.expression;
851
+ return expr && expr.type !== "JSXEmptyExpression" ? !isStatic(expr) : false;
852
+ }
853
+ function elementHasDynamic(node) {
854
+ if (jsxAttrs(node).some(attrIsDynamic)) return true;
855
+ if (!isSelfClosing(node)) return jsxChildren(node).some((c) => c.type === "JSXExpressionContainer" && c.expression && c.expression.type !== "JSXEmptyExpression");
856
+ return false;
857
+ }
666
858
  function processOneChild(child, varName, parentRef, useMixed, useMultiExpr, childNodeIdx) {
667
859
  if (child.kind === "text") return escapeHtmlText(child.text);
668
860
  if (child.kind === "element") {
@@ -681,9 +873,8 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
681
873
  if (isReactive) return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder);
682
874
  return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder);
683
875
  }
684
- /** Process children of a JsxElement, returning the children HTML. */
685
876
  function processChildren(el, varName, accessor) {
686
- const flatChildren = flattenChildren(el.children);
877
+ const flatChildren = flattenChildren(jsxChildren(el));
687
878
  const { useMixed, useMultiExpr } = analyzeChildren(flatChildren);
688
879
  const parentRef = accessor === "__root" ? "__root" : varName;
689
880
  let html = "";
@@ -696,13 +887,12 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
696
887
  }
697
888
  return html;
698
889
  }
699
- /** Process a single DOM element for template emission. Returns the HTML string or null. */
700
890
  function processElement(el, accessor) {
701
891
  const tag = jsxTagName(el);
702
892
  if (!tag) return null;
703
893
  const varName = resolveElementVar(accessor, elementHasDynamic(el));
704
894
  let html = `<${tag}${processAttrs(el, varName)}>`;
705
- if (ts.isJsxElement(el)) {
895
+ if (!isSelfClosing(el)) {
706
896
  const childHtml = processChildren(el, varName, accessor);
707
897
  if (childHtml === null) return null;
708
898
  html += childHtml;
@@ -729,90 +919,67 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
729
919
  else body += "\n return null";
730
920
  return `_tpl("${escaped}", (__root) => {\n${body}\n})`;
731
921
  }
732
- /** Classify a single JSX child into a FlatChild descriptor. */
733
- function classifyJsxChild(child, out, elemIdxRef, recurse) {
734
- if (ts.isJsxText(child)) {
735
- const trimmed = child.text.replace(/\n\s*/g, "").trim();
736
- if (trimmed) out.push({
737
- kind: "text",
738
- text: trimmed
739
- });
740
- return;
741
- }
742
- if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
743
- out.push({
744
- kind: "element",
745
- node: child,
746
- elemIdx: elemIdxRef.value++
747
- });
748
- return;
749
- }
750
- if (ts.isJsxExpression(child)) {
751
- if (child.expression) out.push({
752
- kind: "expression",
753
- expression: child.expression
754
- });
755
- return;
756
- }
757
- if (ts.isJsxFragment(child)) recurse(child.children);
758
- }
759
- /**
760
- * Flatten JSX children, inlining fragment children and stripping whitespace-only text.
761
- * Returns a flat array of child descriptors with element indices pre-computed.
762
- */
763
- function flattenChildren(children) {
764
- const flatList = [];
765
- const elemIdxRef = { value: 0 };
766
- function addChildren(kids) {
767
- for (const child of kids) classifyJsxChild(child, flatList, elemIdxRef, addChildren);
768
- }
769
- addChildren(children);
770
- return flatList;
771
- }
772
- /** Analyze flat children to determine indexing strategy. */
773
- function analyzeChildren(flatChildren) {
774
- const hasElem = flatChildren.some((c) => c.kind === "element");
775
- const hasNonElem = flatChildren.some((c) => c.kind !== "element");
776
- const exprCount = flatChildren.filter((c) => c.kind === "expression").length;
777
- return {
778
- useMixed: hasElem && hasNonElem,
779
- useMultiExpr: exprCount > 1
780
- };
781
- }
782
- /** Check if a single attribute is dynamic (has ref, event, or non-static expression). */
783
- function attrIsDynamic(attr) {
784
- if (!ts.isJsxAttribute(attr)) return false;
785
- const name = ts.isIdentifier(attr.name) ? attr.name.text : "";
786
- if (name === "ref") return true;
787
- if (EVENT_RE.test(name)) return true;
788
- if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return false;
789
- const expr = attr.initializer.expression;
790
- return expr ? !isStatic(expr) : false;
791
- }
792
- /** Check if an element has any dynamic attributes, events, ref, or expression children */
793
- function elementHasDynamic(node) {
794
- if (jsxAttrs(node).some(attrIsDynamic)) return true;
795
- if (ts.isJsxElement(node)) return node.children.some((c) => ts.isJsxExpression(c) && c.expression !== void 0);
796
- return false;
797
- }
798
- /** Slice expression source from the original code.
799
- * Resolves any prop-derived identifiers found anywhere in the expression
800
- * via AST transformation — handles template literals, ternaries, etc. */
801
922
  function sliceExpr(expr) {
923
+ let result;
802
924
  if (propDerivedVars.size > 0 && accessesProps(expr)) {
803
- const resolved = resolveExprTransitive(expr, /* @__PURE__ */ new Set(), expr);
804
- return printer.printNode(ts.EmitHint.Expression, resolved, sf);
925
+ const start = expr.start;
926
+ const end = expr.end;
927
+ result = resolveIdentifiersInText(code.slice(start, end), start, expr);
928
+ } else result = code.slice(expr.start, expr.end);
929
+ if (signalVars.size > 0 && signalVars.size > shadowedSignals.size && referencesSignalVar(expr)) result = autoCallSignals(result, expr);
930
+ return result;
931
+ }
932
+ /** Check if an expression references any tracked signal variable. */
933
+ function referencesSignalVar(node) {
934
+ if (node.type === "Identifier" && isActiveSignal(node.name)) {
935
+ const parent = findParent(node);
936
+ if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) return false;
937
+ if (parent && parent.type === "CallExpression" && parent.callee === node) return false;
938
+ return true;
805
939
  }
806
- return code.slice(expr.getStart(sf), expr.getEnd());
807
- }
808
- /** Get tag name string */
809
- function jsxTagName(node) {
810
- const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName;
811
- return ts.isIdentifier(tag) ? tag.text : "";
940
+ let found = false;
941
+ forEachChildFast(node, (child) => {
942
+ if (found) return;
943
+ if (child.type === "ArrowFunctionExpression" || child.type === "FunctionExpression") return;
944
+ if (referencesSignalVar(child)) found = true;
945
+ });
946
+ return found;
812
947
  }
813
- /** Get attribute list */
814
- function jsxAttrs(node) {
815
- return ts.isJsxElement(node) ? node.openingElement.attributes.properties : node.attributes.properties;
948
+ /** Auto-insert () after signal variable references in the expression source.
949
+ * Uses the AST to find exact Identifier positions — never scans raw text. */
950
+ function autoCallSignals(text, expr) {
951
+ const start = expr.start;
952
+ const idents = [];
953
+ function findSignalIdents(node) {
954
+ if (node.start >= start + text.length || node.end <= start) return;
955
+ if (node.type === "Identifier" && isActiveSignal(node.name)) {
956
+ const parent = findParent(node);
957
+ if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) return;
958
+ if (parent && parent.type === "CallExpression" && parent.callee === node) return;
959
+ if (parent && parent.type === "VariableDeclarator" && parent.id === node) return;
960
+ if (parent && (parent.type === "Property" || parent.type === "ObjectProperty")) {
961
+ if (parent.shorthand) return;
962
+ if (parent.key === node && !parent.computed) return;
963
+ }
964
+ idents.push({
965
+ start: node.start,
966
+ end: node.end
967
+ });
968
+ }
969
+ forEachChildFast(node, findSignalIdents);
970
+ }
971
+ findSignalIdents(expr);
972
+ if (idents.length === 0) return text;
973
+ idents.sort((a, b) => a.start - b.start);
974
+ const parts = [];
975
+ let lastPos = start;
976
+ for (const id of idents) {
977
+ parts.push(code.slice(lastPos, id.end));
978
+ parts.push("()");
979
+ lastPos = id.end;
980
+ }
981
+ parts.push(code.slice(lastPos, start + text.length));
982
+ return parts.join("");
816
983
  }
817
984
  }
818
985
  const VOID_ELEMENTS = new Set([
@@ -835,11 +1002,6 @@ const JSX_TO_HTML_ATTR = {
835
1002
  className: "class",
836
1003
  htmlFor: "for"
837
1004
  };
838
- /**
839
- * Detect if an expression is a stateful call that must NOT be inlined.
840
- * signal(), computed(), effect() etc. create state — inlining them would
841
- * create new instances at each use site instead of referencing the original.
842
- */
843
1005
  const STATEFUL_CALLS = new Set([
844
1006
  "signal",
845
1007
  "computed",
@@ -857,29 +1019,34 @@ const STATEFUL_CALLS = new Set([
857
1019
  "useStore"
858
1020
  ]);
859
1021
  function isStatefulCall(node) {
860
- if (!ts.isCallExpression(node)) return false;
861
- const callee = node.expression;
862
- if (ts.isIdentifier(callee)) return STATEFUL_CALLS.has(callee.text);
1022
+ if (node.type !== "CallExpression") return false;
1023
+ const callee = node.callee;
1024
+ if (callee?.type === "Identifier") return STATEFUL_CALLS.has(callee.name);
863
1025
  return false;
864
1026
  }
865
- /**
866
- * Detect if an expression accesses `.children` — these can contain VNodes
867
- * and must use _mountSlot instead of text node binding in templates.
868
- * Matches: props.children, own.children, x.children, or bare `children` identifier.
869
- */
1027
+ /** Check if a call expression creates a callable reactive value (`signal(...)` or `computed(...)`). */
1028
+ function isSignalCall(node) {
1029
+ if (node.type !== "CallExpression") return false;
1030
+ const callee = node.callee;
1031
+ return callee?.type === "Identifier" && (callee.name === "signal" || callee.name === "computed");
1032
+ }
870
1033
  function isChildrenExpression(node, expr) {
871
- if (ts.isPropertyAccessExpression(node) && node.name.text === "children") return true;
872
- if (ts.isIdentifier(node) && node.text === "children") return true;
1034
+ if (node.type === "MemberExpression" && !node.computed && node.property?.type === "Identifier" && node.property.name === "children") return true;
1035
+ if (node.type === "Identifier" && node.name === "children") return true;
873
1036
  if (expr.endsWith(".children") || expr === "children") return true;
874
1037
  return false;
875
1038
  }
876
1039
  function isLowerCase(s) {
877
1040
  return s.length > 0 && s[0] === s[0]?.toLowerCase();
878
1041
  }
879
- /** Check if an expression subtree contains JSX nodes */
880
1042
  function containsJSXInExpr(node) {
881
- if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true;
882
- return ts.forEachChild(node, containsJSXInExpr) ?? false;
1043
+ if (node.type === "JSXElement" || node.type === "JSXFragment") return true;
1044
+ let found = false;
1045
+ forEachChild(node, (child) => {
1046
+ if (found) return;
1047
+ if (containsJSXInExpr(child)) found = true;
1048
+ });
1049
+ return found;
883
1050
  }
884
1051
  function escapeHtmlAttr(s) {
885
1052
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
@@ -888,31 +1055,41 @@ function escapeHtmlText(s) {
888
1055
  return s.replace(/&(?!(?:#\d+|#x[\da-fA-F]+|[a-zA-Z]\w*);)/g, "&amp;").replace(/</g, "&lt;");
889
1056
  }
890
1057
  function isStaticJSXNode(node) {
891
- if (ts.isJsxSelfClosingElement(node)) return isStaticAttrs(node.attributes);
892
- if (ts.isJsxFragment(node)) return node.children.every(isStaticChild);
893
- return isStaticAttrs(node.openingElement.attributes) && node.children.every(isStaticChild);
1058
+ if (node.type === "JSXElement" && node.openingElement?.selfClosing) return isStaticAttrs(node.openingElement.attributes ?? []);
1059
+ if (node.type === "JSXFragment") return (node.children ?? []).every(isStaticChild);
1060
+ if (node.type === "JSXElement") return isStaticAttrs(node.openingElement?.attributes ?? []) && (node.children ?? []).every(isStaticChild);
1061
+ return false;
894
1062
  }
895
1063
  function isStaticAttrs(attrs) {
896
- return attrs.properties.every((prop) => {
897
- if (!ts.isJsxAttribute(prop)) return false;
898
- if (!prop.initializer) return true;
899
- if (ts.isStringLiteral(prop.initializer)) return true;
900
- const expr = prop.initializer.expression;
901
- return expr ? isStatic(expr) : true;
1064
+ return attrs.every((prop) => {
1065
+ if (prop.type !== "JSXAttribute") return false;
1066
+ if (!prop.value) return true;
1067
+ if (prop.value.type === "StringLiteral" || prop.value.type === "Literal" && typeof prop.value.value === "string") return true;
1068
+ if (prop.value.type === "JSXExpressionContainer") {
1069
+ const expr = prop.value.expression;
1070
+ if (!expr || expr.type === "JSXEmptyExpression") return true;
1071
+ return isStatic(expr);
1072
+ }
1073
+ return false;
902
1074
  });
903
1075
  }
904
1076
  function isStaticChild(child) {
905
- if (ts.isJsxText(child)) return true;
906
- if (ts.isJsxSelfClosingElement(child)) return isStaticJSXNode(child);
907
- if (ts.isJsxElement(child)) return isStaticJSXNode(child);
908
- if (ts.isJsxFragment(child)) return isStaticJSXNode(child);
909
- const expr = child.expression;
910
- return expr ? isStatic(expr) : true;
1077
+ if (child.type === "JSXText") return true;
1078
+ if (child.type === "JSXElement") return isStaticJSXNode(child);
1079
+ if (child.type === "JSXFragment") return isStaticJSXNode(child);
1080
+ if (child.type === "JSXExpressionContainer") {
1081
+ const expr = child.expression;
1082
+ if (!expr || expr.type === "JSXEmptyExpression") return true;
1083
+ return isStatic(expr);
1084
+ }
1085
+ return false;
911
1086
  }
912
1087
  function isStatic(node) {
913
- return ts.isStringLiteral(node) || ts.isNumericLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword || node.kind === ts.SyntaxKind.UndefinedKeyword;
1088
+ if (node.type === "Literal") return true;
1089
+ if (node.type === "StringLiteral" || node.type === "NumericLiteral" || node.type === "BooleanLiteral" || node.type === "NullLiteral") return true;
1090
+ if (node.type === "TemplateLiteral" && (node.expressions?.length ?? 0) === 0) return true;
1091
+ return false;
914
1092
  }
915
- /** Known pure global functions that don't read signals. */
916
1093
  const PURE_CALLS = new Set([
917
1094
  "Math.max",
918
1095
  "Math.min",
@@ -952,23 +1129,13 @@ const PURE_CALLS = new Set([
952
1129
  "decodeURI",
953
1130
  "Date.now"
954
1131
  ]);
955
- /** Check if a call expression calls a known pure function with static args. */
956
1132
  function isPureStaticCall(node) {
957
- const callee = node.expression;
1133
+ const callee = node.callee;
958
1134
  let name = "";
959
- if (ts.isIdentifier(callee)) name = callee.text;
960
- else if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) name = `${callee.expression.text}.${callee.name.text}`;
1135
+ if (callee?.type === "Identifier") name = callee.name;
1136
+ else if (callee?.type === "MemberExpression" && !callee.computed && callee.object?.type === "Identifier" && callee.property?.type === "Identifier") name = `${callee.object.name}.${callee.property.name}`;
961
1137
  if (!PURE_CALLS.has(name)) return false;
962
- return node.arguments.every((arg) => !ts.isSpreadElement(arg) && isStatic(arg));
963
- }
964
- function containsCall(node) {
965
- if (ts.isCallExpression(node)) {
966
- if (isPureStaticCall(node)) return false;
967
- return true;
968
- }
969
- if (ts.isTaggedTemplateExpression(node)) return true;
970
- if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false;
971
- return ts.forEachChild(node, containsCall) ?? false;
1138
+ return (node.arguments ?? []).every((arg) => arg.type !== "SpreadElement" && isStatic(arg));
972
1139
  }
973
1140
 
974
1141
  //#endregion
@@ -1339,7 +1506,7 @@ function detectJsxAttributes(ctx, node) {
1339
1506
  if (attrName === "onChange") {
1340
1507
  const jsxElement = findParentJsxElement(node);
1341
1508
  if (jsxElement) {
1342
- const tagName = getJsxTagName(jsxElement);
1509
+ const tagName = getJsxTagName$1(jsxElement);
1343
1510
  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);
1344
1511
  }
1345
1512
  }
@@ -1562,7 +1729,7 @@ function migrateJsxAttributes(ctx, node) {
1562
1729
  if (attrName === "onChange") {
1563
1730
  const jsxElement = findParentJsxElement(node);
1564
1731
  if (jsxElement) {
1565
- const tagName = getJsxTagName(jsxElement);
1732
+ const tagName = getJsxTagName$1(jsxElement);
1566
1733
  if (tagName === "input" || tagName === "textarea" || tagName === "select") {
1567
1734
  ctx.replacements.push({
1568
1735
  start: node.name.getStart(ctx.sf),
@@ -1694,7 +1861,7 @@ function findParentJsxElement(node) {
1694
1861
  }
1695
1862
  return null;
1696
1863
  }
1697
- function getJsxTagName(node) {
1864
+ function getJsxTagName$1(node) {
1698
1865
  const tagName = node.tagName;
1699
1866
  if (ts.isIdentifier(tagName)) return tagName.text;
1700
1867
  return "";
@@ -1805,5 +1972,545 @@ function diagnoseError(error) {
1805
1972
  }
1806
1973
 
1807
1974
  //#endregion
1808
- export { detectReactPatterns, diagnoseError, generateContext, hasReactPatterns, migrateReactCode, transformJSX };
1975
+ //#region src/pyreon-intercept.ts
1976
+ /**
1977
+ * Pyreon Pattern Interceptor — detects Pyreon-specific anti-patterns in
1978
+ * code that has ALREADY committed to the framework (imports are Pyreon,
1979
+ * not React). Complements `react-intercept.ts` — the React detector
1980
+ * catches "coming from React" mistakes; this one catches "using Pyreon
1981
+ * wrong" mistakes.
1982
+ *
1983
+ * Catalog of detected patterns (grounded in `.claude/rules/anti-patterns.md`):
1984
+ *
1985
+ * - `for-missing-by` — `<For each={...}>` without a `by` prop
1986
+ * - `for-with-key` — `<For key={...}>` (JSX reserves `key`; the keying
1987
+ * prop is `by` in Pyreon)
1988
+ * - `props-destructured` — `({ foo }: Props) => <JSX />` destructures at
1989
+ * the component signature; reading is captured once
1990
+ * and loses reactivity. Access `props.foo` instead
1991
+ * or use `splitProps(props, [...])`.
1992
+ * - `process-dev-gate` — `typeof process !== 'undefined' &&
1993
+ * process.env.NODE_ENV !== 'production'` is dead
1994
+ * code in real Vite browser bundles. Use
1995
+ * `import.meta.env?.DEV` instead.
1996
+ * - `empty-theme` — `.theme({})` chain is a no-op; remove it.
1997
+ * - `raw-add-event-listener` — raw `addEventListener(...)` in a component
1998
+ * or hook body. Use `useEventListener(...)` from
1999
+ * `@pyreon/hooks` for auto-cleanup.
2000
+ * - `raw-remove-event-listener` — same, for removeEventListener.
2001
+ * - `date-math-random-id` — `Date.now() + Math.random()` / template-concat
2002
+ * variants. Under rapid operations (paste, clone)
2003
+ * collision probability is non-trivial. Use a
2004
+ * monotonic counter.
2005
+ * - `on-click-undefined` — `onClick={undefined}` explicitly; the runtime
2006
+ * used to crash on this pattern. Omit the prop.
2007
+ *
2008
+ * Two-mode surface mirrors `react-intercept.ts`:
2009
+ * - `detectPyreonPatterns(code)` — diagnostics only
2010
+ * - `hasPyreonPatterns(code)` — fast regex pre-filter
2011
+ *
2012
+ * ## fixable: false (invariant)
2013
+ *
2014
+ * Every Pyreon diagnostic reports `fixable: false` — no exceptions.
2015
+ * The `migrate_react` MCP tool only knows React mappings, so claiming
2016
+ * a Pyreon code is auto-fixable would mislead a consumer who wires
2017
+ * their UX off the flag and finds nothing applies the fix. Flip to
2018
+ * `true` ONLY when a companion `migrate_pyreon` tool ships in a
2019
+ * subsequent PR. The invariant is locked in
2020
+ * `tests/pyreon-intercept.test.ts` under "fixable contract".
2021
+ *
2022
+ * Designed for three consumers:
2023
+ * 1. Compiler pre-pass warnings during build
2024
+ * 2. CLI `pyreon doctor`
2025
+ * 3. MCP server `validate` tool
2026
+ */
2027
+ function getNodeText(ctx, node) {
2028
+ return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
2029
+ }
2030
+ function pushDiag(ctx, node, code, message, current, suggested, fixable) {
2031
+ const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf));
2032
+ ctx.diagnostics.push({
2033
+ code,
2034
+ message,
2035
+ line: line + 1,
2036
+ column: character,
2037
+ current: current.trim(),
2038
+ suggested: suggested.trim(),
2039
+ fixable
2040
+ });
2041
+ }
2042
+ function getJsxTagName(node) {
2043
+ const t = node.tagName;
2044
+ if (ts.isIdentifier(t)) return t.text;
2045
+ return "";
2046
+ }
2047
+ function findJsxAttribute(node, name) {
2048
+ for (const attr of node.attributes.properties) if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === name) return attr;
2049
+ }
2050
+ function detectForKeying(ctx, node) {
2051
+ if (getJsxTagName(node) !== "For") return;
2052
+ const keyAttr = findJsxAttribute(node, "key");
2053
+ 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);
2054
+ const eachAttr = findJsxAttribute(node, "each");
2055
+ const byAttr = findJsxAttribute(node, "by");
2056
+ 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);
2057
+ }
2058
+ function containsJsx(node) {
2059
+ let found = false;
2060
+ function walk(n) {
2061
+ if (found) return;
2062
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n) || ts.isJsxOpeningElement(n)) {
2063
+ found = true;
2064
+ return;
2065
+ }
2066
+ ts.forEachChild(n, walk);
2067
+ }
2068
+ ts.forEachChild(node, walk);
2069
+ if (!found) {
2070
+ if (ts.isArrowFunction(node) && !ts.isBlock(node.body) && (ts.isJsxElement(node.body) || ts.isJsxSelfClosingElement(node.body) || ts.isJsxFragment(node.body))) found = true;
2071
+ }
2072
+ return found;
2073
+ }
2074
+ function detectPropsDestructured(ctx, node) {
2075
+ if (!node.parameters.length) return;
2076
+ const first = node.parameters[0];
2077
+ if (!first || !ts.isObjectBindingPattern(first.name)) return;
2078
+ if (first.name.elements.length === 0) return;
2079
+ if (!containsJsx(node)) return;
2080
+ 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);
2081
+ }
2082
+ function isTypeofProcess(node) {
2083
+ if (!ts.isBinaryExpression(node)) return false;
2084
+ if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false;
2085
+ if (!ts.isTypeOfExpression(node.left)) return false;
2086
+ if (!ts.isIdentifier(node.left.expression) || node.left.expression.text !== "process") return false;
2087
+ return ts.isStringLiteral(node.right) && node.right.text === "undefined";
2088
+ }
2089
+ function isProcessNodeEnvProdGuard(node) {
2090
+ if (!ts.isBinaryExpression(node)) return false;
2091
+ if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false;
2092
+ const left = node.left;
2093
+ if (!ts.isPropertyAccessExpression(left)) return false;
2094
+ if (!ts.isIdentifier(left.name) || left.name.text !== "NODE_ENV") return false;
2095
+ if (!ts.isPropertyAccessExpression(left.expression)) return false;
2096
+ if (!ts.isIdentifier(left.expression.name) || left.expression.name.text !== "env") return false;
2097
+ if (!ts.isIdentifier(left.expression.expression)) return false;
2098
+ if (left.expression.expression.text !== "process") return false;
2099
+ return ts.isStringLiteral(node.right) && node.right.text === "production";
2100
+ }
2101
+ function detectProcessDevGate(ctx, node) {
2102
+ if (node.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) return;
2103
+ if (!(isTypeofProcess(node.left) && isProcessNodeEnvProdGuard(node.right) || isTypeofProcess(node.right) && isProcessNodeEnvProdGuard(node.left))) return;
2104
+ 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);
2105
+ }
2106
+ function detectEmptyTheme(ctx, node) {
2107
+ const callee = node.expression;
2108
+ if (!ts.isPropertyAccessExpression(callee)) return;
2109
+ if (!ts.isIdentifier(callee.name) || callee.name.text !== "theme") return;
2110
+ if (node.arguments.length !== 1) return;
2111
+ const arg = node.arguments[0];
2112
+ if (!arg || !ts.isObjectLiteralExpression(arg)) return;
2113
+ if (arg.properties.length !== 0) return;
2114
+ 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);
2115
+ }
2116
+ function detectRawEventListener(ctx, node) {
2117
+ const callee = node.expression;
2118
+ if (!ts.isPropertyAccessExpression(callee)) return;
2119
+ if (!ts.isIdentifier(callee.name)) return;
2120
+ const method = callee.name.text;
2121
+ if (method !== "addEventListener" && method !== "removeEventListener") return;
2122
+ const target = callee.expression;
2123
+ const targetName = ts.isIdentifier(target) ? target.text : ts.isPropertyAccessExpression(target) && ts.isIdentifier(target.name) ? target.name.text : "";
2124
+ if (!new Set([
2125
+ "window",
2126
+ "document",
2127
+ "body",
2128
+ "el",
2129
+ "element",
2130
+ "node",
2131
+ "target"
2132
+ ]).has(targetName)) return;
2133
+ 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);
2134
+ 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);
2135
+ }
2136
+ function isCallTo(node, object, method) {
2137
+ 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;
2138
+ }
2139
+ function subtreeHas(node, predicate) {
2140
+ let found = false;
2141
+ function walk(n) {
2142
+ if (found) return;
2143
+ if (predicate(n)) {
2144
+ found = true;
2145
+ return;
2146
+ }
2147
+ ts.forEachChild(n, walk);
2148
+ }
2149
+ walk(node);
2150
+ return found;
2151
+ }
2152
+ function detectDateMathRandomId(ctx, node) {
2153
+ if (!subtreeHas(node, (n) => isCallTo(n, "Date", "now"))) return;
2154
+ if (!subtreeHas(node, (n) => isCallTo(n, "Math", "random"))) return;
2155
+ 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);
2156
+ }
2157
+ function detectOnClickUndefined(ctx, node) {
2158
+ if (!ts.isIdentifier(node.name)) return;
2159
+ const attrName = node.name.text;
2160
+ if (!attrName.startsWith("on") || attrName.length < 3) return;
2161
+ if (!node.initializer || !ts.isJsxExpression(node.initializer)) return;
2162
+ const expr = node.initializer.expression;
2163
+ if (!expr) return;
2164
+ if (!(ts.isIdentifier(expr) && expr.text === "undefined" || expr.kind === ts.SyntaxKind.VoidExpression)) return;
2165
+ 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);
2166
+ }
2167
+ function visitNode(ctx, node) {
2168
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) detectForKeying(ctx, node);
2169
+ if (ts.isArrowFunction(node) || ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) detectPropsDestructured(ctx, node);
2170
+ if (ts.isBinaryExpression(node)) {
2171
+ detectProcessDevGate(ctx, node);
2172
+ detectDateMathRandomId(ctx, node);
2173
+ }
2174
+ if (ts.isTemplateExpression(node)) detectDateMathRandomId(ctx, node);
2175
+ if (ts.isCallExpression(node)) {
2176
+ detectEmptyTheme(ctx, node);
2177
+ detectRawEventListener(ctx, node);
2178
+ }
2179
+ if (ts.isJsxAttribute(node)) detectOnClickUndefined(ctx, node);
2180
+ }
2181
+ function visit(ctx, node) {
2182
+ ts.forEachChild(node, (child) => {
2183
+ visitNode(ctx, child);
2184
+ visit(ctx, child);
2185
+ });
2186
+ }
2187
+ function detectPyreonPatterns(code, filename = "input.tsx") {
2188
+ const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
2189
+ const ctx = {
2190
+ sf,
2191
+ code,
2192
+ diagnostics: []
2193
+ };
2194
+ visit(ctx, sf);
2195
+ ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column);
2196
+ return ctx.diagnostics;
2197
+ }
2198
+ /** Fast regex pre-filter — returns true if the code is worth a full AST walk. */
2199
+ function hasPyreonPatterns(code) {
2200
+ 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);
2201
+ }
2202
+
2203
+ //#endregion
2204
+ //#region src/test-audit.ts
2205
+ /**
2206
+ * Test-environment audit for the `audit_test_environment` MCP tool (T2.5.7).
2207
+ *
2208
+ * Scans `*.test.ts` / `*.test.tsx` files under the `packages` tree
2209
+ * for **mock-vnode patterns** — tests that construct `{ type, props,
2210
+ * children }` object literals (or a custom `vnode(...)` helper) in
2211
+ * place of going through the real `h()` from `@pyreon/core`. This
2212
+ * class of pattern silently drops rocketstyle / compiler / attrs
2213
+ * work from the pipeline, letting bugs through that production
2214
+ * would hit immediately (see PR #197 silent metadata drop).
2215
+ *
2216
+ * The scanner does NOT run the tests or parse TypeScript — a fast
2217
+ * regex pass is intentional. Accuracy trades for speed: the false-
2218
+ * positive rate is low because the `{ type: ..., props: ...,
2219
+ * children: ... }` shape is unusual outside of vnode construction.
2220
+ *
2221
+ * Output classification:
2222
+ * HIGH — mock patterns present, no real `h()` calls and no `h`
2223
+ * import from `@pyreon/core`. Most at risk: the file has
2224
+ * no pathway to exercise the real pipeline.
2225
+ * MEDIUM — mock patterns present, some real `h()` usage — but the
2226
+ * mock count is still notable, so a parallel real-`h()`
2227
+ * test may be missing for specific scenarios.
2228
+ * LOW — either no mocks, or mock count is dwarfed by real usage.
2229
+ *
2230
+ * Companion to the `validate` and `get_anti_patterns` tools: those
2231
+ * tell an agent what to write; this one tells an agent which existing
2232
+ * tests need strengthening.
2233
+ */
2234
+ function findMonorepoRoot(startDir) {
2235
+ let dir = resolve(startDir);
2236
+ for (let i = 0; i < 30; i++) {
2237
+ try {
2238
+ if (statSync(join(dir, "packages")).isDirectory()) return dir;
2239
+ } catch {}
2240
+ const parent = dirname(dir);
2241
+ if (parent === dir) return null;
2242
+ dir = parent;
2243
+ }
2244
+ return null;
2245
+ }
2246
+ function walkTestFiles(dir, out, depth = 0) {
2247
+ if (depth > 10) return;
2248
+ let entries;
2249
+ try {
2250
+ entries = readdirSync(dir);
2251
+ } catch {
2252
+ return;
2253
+ }
2254
+ for (const name of entries) {
2255
+ if (name.startsWith(".")) continue;
2256
+ if (name === "node_modules" || name === "lib" || name === "dist") continue;
2257
+ const full = join(dir, name);
2258
+ let isDir = false;
2259
+ try {
2260
+ isDir = statSync(full).isDirectory();
2261
+ } catch {
2262
+ continue;
2263
+ }
2264
+ if (isDir) {
2265
+ walkTestFiles(full, out, depth + 1);
2266
+ continue;
2267
+ }
2268
+ if (/\.test\.(ts|tsx)$/.test(name)) out.push(full);
2269
+ }
2270
+ }
2271
+ /**
2272
+ * Matches an object literal carrying `type`, `props`, AND `children`
2273
+ * keys — the canonical mock-vnode shape. The `s` flag spans newlines
2274
+ * because vnode literals often wrap across multiple lines.
2275
+ */
2276
+ const MOCK_VNODE_LITERAL_PATTERN = /\{\s*type\s*:[^{}]*?(?:(?:\{[^{}]*\})[^{}]*?)*?props\s*:[^{}]*?(?:(?:\{[^{}]*\})[^{}]*?)*?children\s*:[^{}]*?(?:(?:\{[^{}]*\})[^{}]*?)*?\}/gs;
2277
+ /**
2278
+ * Matches a helper definition that produces a mock vnode. Recognises:
2279
+ * const vnode = (...) => ({ type, props, children })
2280
+ * const mockVNode = ({ type, props, children })
2281
+ * function createVNode(type, props, children)
2282
+ *
2283
+ * Does NOT match bindings that merely STORE a real VNode with a
2284
+ * `vnode`-like name, which are common in component tests:
2285
+ * const vnode = defaultRender(...) // real render result
2286
+ * const vnode = <span>cell content</span> // real JSX expression
2287
+ * const vnode = h('div', null, 'x') // real h() call
2288
+ *
2289
+ * Distinguisher: a mock helper definition either
2290
+ * (a) starts an arrow function / function — RHS begins with `(` or the
2291
+ * keyword `function`, OR
2292
+ * (b) is itself an inline object literal — RHS begins with `{`.
2293
+ * `const vnode = <anything else>` is a binding, not a definition.
2294
+ */
2295
+ const MOCK_HELPER_PATTERN = /(?:(?:const|let)\s+(?:mockV[Nn]ode|vnode|createV[Nn]ode|V[Nn]odeMock|makeV[Nn]ode)\s*=\s*(?:\(|\{|function\b|async\s))|(?:function\s+(?:mockV[Nn]ode|vnode|createV[Nn]ode|V[Nn]odeMock|makeV[Nn]ode)\s*\()/g;
2296
+ /**
2297
+ * Matches CALLS to a known mock-helper name:
2298
+ * vnode('div', props, children)
2299
+ * mockVNode(Component, props)
2300
+ * createVNode(...)
2301
+ *
2302
+ * Non-word boundary before the name avoids hits inside other
2303
+ * identifiers (`hasVNode`, `myVnodeImpl`). The helper-def pattern
2304
+ * above ALSO matches definitions' own `<name>(` arg list, so the
2305
+ * caller should subtract definition count from call count to get
2306
+ * usage-only density — but for risk classification, the combined
2307
+ * signal (any mock-helper activity) is what we want.
2308
+ */
2309
+ const MOCK_HELPER_CALL_PATTERN = /(?:^|[^a-zA-Z0-9_])(?:mockV[Nn]ode|vnode|createV[Nn]ode|V[Nn]odeMock|makeV[Nn]ode)\s*\(/g;
2310
+ /**
2311
+ * Matches calls to `h(…)` where the first arg is an uppercase
2312
+ * identifier (component) or a lowercase string tag — the two real
2313
+ * shapes. Avoids matching:
2314
+ * hasSomething(...) — h followed by [a-z]
2315
+ * ch() — single h as substring of another name
2316
+ * hash() — same
2317
+ * The `(?:^|\W)` boundary plus `[A-Z'"\s]` arg requirement handles both.
2318
+ */
2319
+ const REAL_H_CALL_PATTERN = /(?:^|\W)h\s*\(\s*["'A-Z]/g;
2320
+ const IMPORT_H_PATTERN = /import\s*(?:type\s*)?\{[^}]*\bh\b[^}]*\}\s*from\s*['"]@pyreon\/core['"]/;
2321
+ /**
2322
+ * Predicate: does the `{type, props, children}` literal at this
2323
+ * position appear as an argument to a type-guard-like call
2324
+ * (`isDocNode(...)`, `hasVNode(...)`, `assertVNode(...)`, etc.)?
2325
+ *
2326
+ * Type guards take any object shape and return boolean — passing a
2327
+ * `{type, props, children}` literal there is testing the guard's
2328
+ * duck-typing, not building a mock vnode for a rendering pipeline.
2329
+ * False-positive coverage for `utils-coverage.test.ts` and similar.
2330
+ */
2331
+ function isLiteralInsideTypeGuardCall(source, literalStart) {
2332
+ const window = source.slice(Math.max(0, literalStart - 60), literalStart);
2333
+ let unmatched = 0;
2334
+ let openAt = -1;
2335
+ for (let i = window.length - 1; i >= 0; i--) {
2336
+ const ch = window[i];
2337
+ if (ch === ")") unmatched++;
2338
+ else if (ch === "(") {
2339
+ if (unmatched === 0) {
2340
+ openAt = i;
2341
+ break;
2342
+ }
2343
+ unmatched--;
2344
+ }
2345
+ }
2346
+ if (openAt < 0) return false;
2347
+ const head = window.slice(0, openAt);
2348
+ return /\b(?:is|has|assert|validate|check)[A-Z]\w*\s*$/.test(head);
2349
+ }
2350
+ /**
2351
+ * Mask the inside of every backtick-delimited template-literal with
2352
+ * spaces. Preserves length so positions/lines/columns stay aligned.
2353
+ * Used to keep the literal scanner from counting `{type,props,children}`
2354
+ * patterns that live inside test FIXTURE strings (the `cli/doctor.test.ts`
2355
+ * case — those are fixtures for the audit tool itself, not actual code).
2356
+ *
2357
+ * Limitations: doesn't parse `${...}` interpolations precisely. If a
2358
+ * fixture contains a balanced `${ ... }` with code we'd want scanned,
2359
+ * the surrounding template string still masks it. In practice, mock-
2360
+ * vnode literals are never interpolation expressions, so this is fine.
2361
+ */
2362
+ function maskTemplateStrings(source) {
2363
+ return source.replace(/`(?:\\.|[^`\\])*`/g, (m) => `\`${" ".repeat(m.length - 2)}\``);
2364
+ }
2365
+ function countMatches(source, pattern) {
2366
+ let count = 0;
2367
+ pattern.lastIndex = 0;
2368
+ while (pattern.exec(source) !== null) count++;
2369
+ pattern.lastIndex = 0;
2370
+ return count;
2371
+ }
2372
+ /**
2373
+ * Counts `{type, props, children}` literals, skipping those that
2374
+ * appear inside a type-guard-looking call OR inside a template-literal
2375
+ * (which is fixture text, not code). Dedicated because the existing
2376
+ * `countMatches` helper has no context-aware skip.
2377
+ */
2378
+ function countMockVNodeLiterals(source) {
2379
+ const masked = maskTemplateStrings(source);
2380
+ const pattern = MOCK_VNODE_LITERAL_PATTERN;
2381
+ let count = 0;
2382
+ pattern.lastIndex = 0;
2383
+ let m;
2384
+ while (true) {
2385
+ m = pattern.exec(masked);
2386
+ if (m === null) break;
2387
+ if (!isLiteralInsideTypeGuardCall(masked, m.index)) count++;
2388
+ }
2389
+ pattern.lastIndex = 0;
2390
+ return count;
2391
+ }
2392
+ function classifyRisk(entry) {
2393
+ const mocks = entry.mockVNodeLiteralCount + entry.mockHelperCount + entry.mockHelperCallCount;
2394
+ if (mocks === 0) return "low";
2395
+ if (!entry.importsH && entry.realHCallCount === 0) return "high";
2396
+ if (entry.realHCallCount >= mocks) return "low";
2397
+ return "medium";
2398
+ }
2399
+ function auditTestEnvironment(startDir) {
2400
+ const root = findMonorepoRoot(startDir);
2401
+ if (!root) return {
2402
+ root: null,
2403
+ entries: [],
2404
+ totalScanned: 0
2405
+ };
2406
+ const files = [];
2407
+ walkTestFiles(join(root, "packages"), files);
2408
+ const entries = [];
2409
+ for (const path of files) {
2410
+ let source;
2411
+ try {
2412
+ source = readFileSync(path, "utf8");
2413
+ } catch {
2414
+ continue;
2415
+ }
2416
+ if (path.includes("test-audit.test.ts") || path.includes("test-audit-fixture")) continue;
2417
+ const masked = maskTemplateStrings(source);
2418
+ const mockVNodeLiteralCount = countMockVNodeLiterals(source);
2419
+ const mockHelperCount = countMatches(masked, MOCK_HELPER_PATTERN);
2420
+ const mockHelperCallCount = countMatches(masked, MOCK_HELPER_CALL_PATTERN);
2421
+ const realHCallCount = countMatches(masked, REAL_H_CALL_PATTERN);
2422
+ const importsH = IMPORT_H_PATTERN.test(masked);
2423
+ const base = {
2424
+ path,
2425
+ relPath: relative(root, path),
2426
+ mockVNodeLiteralCount,
2427
+ mockHelperCount,
2428
+ mockHelperCallCount,
2429
+ realHCallCount,
2430
+ importsH
2431
+ };
2432
+ entries.push({
2433
+ ...base,
2434
+ risk: classifyRisk(base)
2435
+ });
2436
+ }
2437
+ const riskRank = {
2438
+ high: 0,
2439
+ medium: 1,
2440
+ low: 2
2441
+ };
2442
+ entries.sort((a, b) => {
2443
+ const cmp = riskRank[a.risk] - riskRank[b.risk];
2444
+ if (cmp !== 0) return cmp;
2445
+ return a.relPath.localeCompare(b.relPath);
2446
+ });
2447
+ return {
2448
+ root,
2449
+ entries,
2450
+ totalScanned: files.length
2451
+ };
2452
+ }
2453
+ function riskAtOrAbove(risk, min) {
2454
+ const rank = {
2455
+ high: 0,
2456
+ medium: 1,
2457
+ low: 2
2458
+ };
2459
+ return rank[risk] <= rank[min];
2460
+ }
2461
+ function formatTestAudit(result, { minRisk = "medium", limit = 20 } = {}) {
2462
+ if (!result.root) return "No monorepo root found. This tool scans `packages/**/*.test.{ts,tsx}` for mock-vnode patterns. Run the MCP from the Pyreon repo root to get useful output.";
2463
+ const relevant = result.entries.filter((e) => riskAtOrAbove(e.risk, minRisk));
2464
+ const counts = {
2465
+ high: result.entries.filter((e) => e.risk === "high").length,
2466
+ medium: result.entries.filter((e) => e.risk === "medium").length,
2467
+ low: result.entries.filter((e) => e.risk === "low").length
2468
+ };
2469
+ const withMocks = result.entries.filter((e) => e.mockVNodeLiteralCount + e.mockHelperCount > 0).length;
2470
+ const parts = [];
2471
+ parts.push(`# Test environment audit — ${result.totalScanned} test files scanned`);
2472
+ parts.push("");
2473
+ parts.push(`**Mock-vnode exposure**: ${withMocks} / ${result.totalScanned} files construct \`{ type, props, children }\` literals or a custom \`vnode()\` helper instead of going through the real \`h()\` from \`@pyreon/core\`. This is the bug class that caused PR #197's silent metadata drop — mock-only tests pass while the real pipeline (rocketstyle attrs, compiler transforms, props forwarding) stays unexercised.`);
2474
+ parts.push("");
2475
+ parts.push(`**Risk counts**: ${counts.high} high · ${counts.medium} medium · ${counts.low} low`);
2476
+ parts.push("");
2477
+ if (relevant.length === 0) {
2478
+ parts.push(`No files at risk level "${minRisk}" or above. Every test file either avoids mocks entirely or pairs them with real-\`h()\` coverage.`);
2479
+ return parts.join("\n");
2480
+ }
2481
+ const byRisk = /* @__PURE__ */ new Map();
2482
+ for (const entry of relevant) {
2483
+ if (!byRisk.has(entry.risk)) byRisk.set(entry.risk, []);
2484
+ byRisk.get(entry.risk).push(entry);
2485
+ }
2486
+ for (const [risk, group] of byRisk) {
2487
+ const shown = group.slice(0, limit);
2488
+ parts.push(`## ${risk.toUpperCase()} — ${group.length} file${group.length === 1 ? "" : "s"}${shown.length < group.length ? ` (showing ${shown.length})` : ""}`);
2489
+ parts.push("");
2490
+ parts.push(describeRisk(risk));
2491
+ parts.push("");
2492
+ for (const entry of shown) {
2493
+ const mocks = entry.mockVNodeLiteralCount + entry.mockHelperCount + entry.mockHelperCallCount;
2494
+ const breakdown = [];
2495
+ if (entry.mockVNodeLiteralCount > 0) breakdown.push(`${entry.mockVNodeLiteralCount} literal${entry.mockVNodeLiteralCount === 1 ? "" : "s"}`);
2496
+ if (entry.mockHelperCount > 0) breakdown.push(`${entry.mockHelperCount} helper${entry.mockHelperCount === 1 ? "" : "s"}`);
2497
+ if (entry.mockHelperCallCount > 0) breakdown.push(`${entry.mockHelperCallCount} helper call${entry.mockHelperCallCount === 1 ? "" : "s"}`);
2498
+ const hSide = entry.realHCallCount > 0 ? `${entry.realHCallCount} real h() call${entry.realHCallCount === 1 ? "" : "s"}` : entry.importsH ? `imports h but 0 calls found` : `no h import`;
2499
+ parts.push(`- ${entry.relPath} — ${mocks} mock signal${mocks === 1 ? "" : "s"} (${breakdown.join(" + ")}), ${hSide}`);
2500
+ }
2501
+ parts.push("");
2502
+ }
2503
+ parts.push("---");
2504
+ parts.push("");
2505
+ parts.push("Fix: for each HIGH file, add at least one test that imports `h` from `@pyreon/core` and renders the actual component through `h(RealComponent, props)`. The mock version can stay for speed — it is the LACK of a real-`h()` parallel that blocks bug surfacing.");
2506
+ return parts.join("\n");
2507
+ }
2508
+ function describeRisk(risk) {
2509
+ if (risk === "high") return "Mock patterns present, no real `h()` calls, and no `h` import from `@pyreon/core`. The file has no pathway to exercise the real pipeline — bugs like PR #197 would slip through.";
2510
+ if (risk === "medium") return "Mock patterns present AND some real `h()` usage — but mocks outnumber real calls, so specific scenarios may be mock-only. Spot-check that each contract the tests assert on goes through at least one real-`h()` path.";
2511
+ return "Mocks dwarfed by real usage OR no mocks at all — low risk.";
2512
+ }
2513
+
2514
+ //#endregion
2515
+ export { auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformJSX, transformJSX_JS };
1809
2516
  //# sourceMappingURL=index.js.map