@pyreon/compiler 0.13.1 → 0.15.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/README.md +14 -4
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1330 -409
- package/lib/types/index.d.ts +152 -14
- package/package.json +12 -1
- package/src/event-names.ts +65 -0
- package/src/index.ts +10 -1
- package/src/jsx.ts +974 -784
- package/src/pyreon-intercept.ts +728 -0
- package/src/test-audit.ts +435 -0
- package/src/tests/depth-stress.test.ts +16 -0
- package/src/tests/detector-tag-consistency.test.ts +86 -0
- package/src/tests/jsx.test.ts +1170 -4
- package/src/tests/native-equivalence.test.ts +731 -0
- package/src/tests/project-scanner.test.ts +30 -0
- package/src/tests/pyreon-intercept.test.ts +486 -0
- package/src/tests/react-intercept.test.ts +354 -0
- package/src/tests/runtime/control-flow.test.ts +159 -0
- package/src/tests/runtime/dom-properties.test.ts +138 -0
- package/src/tests/runtime/events.test.ts +301 -0
- package/src/tests/runtime/harness.ts +94 -0
- package/src/tests/runtime/pr-352-shapes.test.ts +121 -0
- package/src/tests/runtime/reactive-props.test.ts +81 -0
- package/src/tests/runtime/signals.test.ts +129 -0
- package/src/tests/runtime/whitespace.test.ts +106 -0
- package/src/tests/test-audit.test.ts +549 -0
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/lib/index.js
CHANGED
|
@@ -1,7 +1,62 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
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
|
|
|
10
|
+
//#region src/event-names.ts
|
|
11
|
+
/**
|
|
12
|
+
* React-style → DOM event-name remap.
|
|
13
|
+
*
|
|
14
|
+
* The compiler translates JSX event handler attributes (`onClick`,
|
|
15
|
+
* `onMouseEnter`, ...) to DOM event names by stripping the `on` prefix
|
|
16
|
+
* and lowercasing. That rule covers MOST React event-name conventions
|
|
17
|
+
* because the underlying DOM event name happens to be the lowercased
|
|
18
|
+
* multi-word form (e.g. `onKeyDown` → `keydown`, `onMouseEnter` →
|
|
19
|
+
* `mouseenter`, `onPointerLeave` → `pointerleave`,
|
|
20
|
+
* `onAnimationStart` → `animationstart`, `onContextMenu` → `contextmenu`).
|
|
21
|
+
*
|
|
22
|
+
* **The exceptions** — where lowercasing produces the WRONG DOM event
|
|
23
|
+
* name — are listed in `REACT_EVENT_REMAP` below. Each entry maps the
|
|
24
|
+
* lowercased React form to the actual DOM event name.
|
|
25
|
+
*
|
|
26
|
+
* Today there is exactly ONE remap: `doubleclick → dblclick`. React
|
|
27
|
+
* inherits this mismatch from the DOM spec — `dblclick` is the canonical
|
|
28
|
+
* event name (RFC at `https://dom.spec.whatwg.org/#interface-mouseevent`),
|
|
29
|
+
* while React's component-prop convention is the `onDoubleClick` shape.
|
|
30
|
+
*
|
|
31
|
+
* **Audit completeness.** The full React event-prop list from
|
|
32
|
+
* `https://react.dev/reference/react-dom/components/common` was checked
|
|
33
|
+
* against canonical DOM event names. Every multi-word event other than
|
|
34
|
+
* `onDoubleClick` lowercases correctly:
|
|
35
|
+
* - Pointer family: `onPointerDown` → `pointerdown`, `onGotPointerCapture` → `gotpointercapture`, …
|
|
36
|
+
* - Mouse family: `onMouseEnter` → `mouseenter`, `onMouseLeave` → `mouseleave`, …
|
|
37
|
+
* - Drag family: `onDragStart` → `dragstart`, `onDragEnd` → `dragend`, …
|
|
38
|
+
* - Touch family: `onTouchStart` → `touchstart`, `onTouchEnd` → `touchend`, …
|
|
39
|
+
* - Composition family: `onCompositionEnd` → `compositionend`, …
|
|
40
|
+
* - Animation/transition: `onAnimationStart` → `animationstart`, `onTransitionEnd` → `transitionend`, …
|
|
41
|
+
* - Media family: `onCanPlayThrough` → `canplaythrough`, `onLoadedData` → `loadeddata`, `onTimeUpdate` → `timeupdate`, `onVolumeChange` → `volumechange`, …
|
|
42
|
+
* - Form family: `onContextMenu` → `contextmenu`, `onBeforeInput` → `beforeinput`, …
|
|
43
|
+
*
|
|
44
|
+
* If a future React release adds a new event-prop with a non-trivial
|
|
45
|
+
* mismatch, append the entry here. Both compiler backends (JS and Rust)
|
|
46
|
+
* read the same shape — the Rust port lives in `native/src/lib.rs` next
|
|
47
|
+
* to `emit_event_listener`. Keep them in sync.
|
|
48
|
+
*
|
|
49
|
+
* **Testing.** `packages/core/compiler/src/tests/runtime/events.test.ts`
|
|
50
|
+
* exercises this table end-to-end via a real-Chromium harness:
|
|
51
|
+
* - `onDoubleClick fires (multi-word + delegated)` — locks in the remap.
|
|
52
|
+
* - `onContextMenu fires (multi-word, lowercases to contextmenu)` —
|
|
53
|
+
* locks in the no-remap default for an adjacent multi-word event.
|
|
54
|
+
* - `event-name-remap-table sanity` — asserts that every entry in
|
|
55
|
+
* `REACT_EVENT_REMAP` has a corresponding runtime test.
|
|
56
|
+
*/
|
|
57
|
+
const REACT_EVENT_REMAP = Object.freeze({ doubleclick: "dblclick" });
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
5
60
|
//#region src/jsx.ts
|
|
6
61
|
/**
|
|
7
62
|
* JSX transform — wraps dynamic JSX expressions in `() =>` so the Pyreon runtime
|
|
@@ -22,21 +77,21 @@ import * as path from "node:path";
|
|
|
22
77
|
* values, and all children are text nodes or other static JSX nodes.
|
|
23
78
|
*
|
|
24
79
|
* Template emission:
|
|
25
|
-
* - JSX element trees with ≥
|
|
26
|
-
* are compiled to `_tpl(html, bindFn)` calls instead of nested
|
|
80
|
+
* - JSX element trees with ≥ 1 DOM elements (no components, no spread attrs on
|
|
81
|
+
* inner elements) are compiled to `_tpl(html, bindFn)` calls instead of nested
|
|
82
|
+
* `h()` calls.
|
|
27
83
|
* - The HTML string is parsed once via <template>.innerHTML, then cloneNode(true)
|
|
28
84
|
* for each instance (~5-10x faster than sequential createElement calls).
|
|
29
85
|
* - Static attributes are baked into the HTML string; dynamic attributes and
|
|
30
86
|
* text content use renderEffect in the bind function.
|
|
31
87
|
*
|
|
32
|
-
* Implementation:
|
|
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.
|
|
88
|
+
* Implementation: Rust native binary (napi-rs) when available, JS fallback via oxc-parser.
|
|
39
89
|
*/
|
|
90
|
+
let nativeTransformJsx = null;
|
|
91
|
+
try {
|
|
92
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
93
|
+
nativeTransformJsx = createRequire(import.meta.url)(join(__dirname, "..", "native", "pyreon-compiler.node")).transformJsx;
|
|
94
|
+
} catch {}
|
|
40
95
|
const SKIP_PROPS = new Set(["key", "ref"]);
|
|
41
96
|
const EVENT_RE = /^on[A-Z]/;
|
|
42
97
|
const DELEGATED_EVENTS = new Set([
|
|
@@ -64,21 +119,123 @@ const DELEGATED_EVENTS = new Set([
|
|
|
64
119
|
"touchmove",
|
|
65
120
|
"submit"
|
|
66
121
|
]);
|
|
122
|
+
function getLang(filename) {
|
|
123
|
+
if (filename.endsWith(".jsx")) return "jsx";
|
|
124
|
+
return "tsx";
|
|
125
|
+
}
|
|
126
|
+
/** Binary search for line/column from byte offset. */
|
|
127
|
+
function makeLineIndex(code) {
|
|
128
|
+
const lineStarts = [0];
|
|
129
|
+
for (let i = 0; i < code.length; i++) if (code[i] === "\n") lineStarts.push(i + 1);
|
|
130
|
+
return (offset) => {
|
|
131
|
+
let lo = 0;
|
|
132
|
+
let hi = lineStarts.length - 1;
|
|
133
|
+
while (lo <= hi) {
|
|
134
|
+
const mid = lo + hi >>> 1;
|
|
135
|
+
if (lineStarts[mid] <= offset) lo = mid + 1;
|
|
136
|
+
else hi = mid - 1;
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
line: lo,
|
|
140
|
+
column: offset - lineStarts[lo - 1]
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** Iterate all direct children of an ESTree node via known property keys. */
|
|
145
|
+
function forEachChild(node, cb) {
|
|
146
|
+
if (!node || typeof node !== "object") return;
|
|
147
|
+
const keys = Object.keys(node);
|
|
148
|
+
for (let i = 0; i < keys.length; i++) {
|
|
149
|
+
const key = keys[i];
|
|
150
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
151
|
+
const val = node[key];
|
|
152
|
+
if (Array.isArray(val)) for (let j = 0; j < val.length; j++) {
|
|
153
|
+
const item = val[j];
|
|
154
|
+
if (item && typeof item === "object" && item.type) cb(item);
|
|
155
|
+
}
|
|
156
|
+
else if (val && typeof val === "object" && val.type) cb(val);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function jsxTagName(node) {
|
|
160
|
+
const opening = node.openingElement;
|
|
161
|
+
if (!opening) return "";
|
|
162
|
+
const name = opening.name;
|
|
163
|
+
return name?.type === "JSXIdentifier" ? name.name : "";
|
|
164
|
+
}
|
|
165
|
+
function isSelfClosing(node) {
|
|
166
|
+
return node.type === "JSXElement" && node.openingElement?.selfClosing === true;
|
|
167
|
+
}
|
|
168
|
+
function jsxAttrs(node) {
|
|
169
|
+
return node.openingElement?.attributes ?? [];
|
|
170
|
+
}
|
|
171
|
+
function jsxChildren(node) {
|
|
172
|
+
return node.children ?? [];
|
|
173
|
+
}
|
|
67
174
|
function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
175
|
+
if (nativeTransformJsx) try {
|
|
176
|
+
return nativeTransformJsx(code, filename, options.ssr === true, options.knownSignals ?? null);
|
|
177
|
+
} catch {}
|
|
178
|
+
return transformJSX_JS(code, filename, options);
|
|
179
|
+
}
|
|
180
|
+
/** JS fallback implementation — used when the native binary isn't available. */
|
|
181
|
+
function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
68
182
|
const ssr = options.ssr === true;
|
|
69
|
-
|
|
70
|
-
|
|
183
|
+
let program;
|
|
184
|
+
try {
|
|
185
|
+
program = parseSync(filename, code, {
|
|
186
|
+
sourceType: "module",
|
|
187
|
+
lang: getLang(filename)
|
|
188
|
+
}).program;
|
|
189
|
+
} catch {
|
|
190
|
+
return {
|
|
191
|
+
code,
|
|
192
|
+
warnings: []
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const locate = makeLineIndex(code);
|
|
71
196
|
const replacements = [];
|
|
72
197
|
const warnings = [];
|
|
73
198
|
function warn(node, message, warnCode) {
|
|
74
|
-
const { line,
|
|
199
|
+
const { line, column } = locate(node.start);
|
|
75
200
|
warnings.push({
|
|
76
201
|
message,
|
|
77
|
-
line
|
|
78
|
-
column
|
|
202
|
+
line,
|
|
203
|
+
column,
|
|
79
204
|
code: warnCode
|
|
80
205
|
});
|
|
81
206
|
}
|
|
207
|
+
const parentMap = /* @__PURE__ */ new WeakMap();
|
|
208
|
+
const childrenMap = /* @__PURE__ */ new WeakMap();
|
|
209
|
+
/** Build parent pointers + cached children arrays for the entire AST. */
|
|
210
|
+
function buildMaps(node) {
|
|
211
|
+
const kids = [];
|
|
212
|
+
const keys = Object.keys(node);
|
|
213
|
+
for (let i = 0; i < keys.length; i++) {
|
|
214
|
+
const key = keys[i];
|
|
215
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
216
|
+
const val = node[key];
|
|
217
|
+
if (Array.isArray(val)) for (let j = 0; j < val.length; j++) {
|
|
218
|
+
const item = val[j];
|
|
219
|
+
if (item && typeof item === "object" && item.type) kids.push(item);
|
|
220
|
+
}
|
|
221
|
+
else if (val && typeof val === "object" && val.type) kids.push(val);
|
|
222
|
+
}
|
|
223
|
+
childrenMap.set(node, kids);
|
|
224
|
+
for (let i = 0; i < kids.length; i++) {
|
|
225
|
+
parentMap.set(kids[i], node);
|
|
226
|
+
buildMaps(kids[i]);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
buildMaps(program);
|
|
230
|
+
function findParent(node) {
|
|
231
|
+
return parentMap.get(node);
|
|
232
|
+
}
|
|
233
|
+
/** Fast child iteration using pre-computed children array. */
|
|
234
|
+
function forEachChildFast(node, cb) {
|
|
235
|
+
const kids = childrenMap.get(node);
|
|
236
|
+
if (!kids) return;
|
|
237
|
+
for (let i = 0; i < kids.length; i++) cb(kids[i]);
|
|
238
|
+
}
|
|
82
239
|
const hoists = [];
|
|
83
240
|
let hoistIdx = 0;
|
|
84
241
|
let needsTplImport = false;
|
|
@@ -88,14 +245,10 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
88
245
|
let needsBindImportGlobal = false;
|
|
89
246
|
let needsApplyPropsImportGlobal = false;
|
|
90
247
|
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
248
|
function maybeHoist(node) {
|
|
96
|
-
if ((
|
|
249
|
+
if ((node.type === "JSXElement" || node.type === "JSXFragment") && isStaticJSXNode(node)) {
|
|
97
250
|
const name = `_$h${hoistIdx++}`;
|
|
98
|
-
const text = code.slice(node.
|
|
251
|
+
const text = code.slice(node.start, node.end);
|
|
99
252
|
hoists.push({
|
|
100
253
|
name,
|
|
101
254
|
text
|
|
@@ -105,36 +258,35 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
105
258
|
return null;
|
|
106
259
|
}
|
|
107
260
|
function wrap(expr) {
|
|
108
|
-
const start = expr.
|
|
109
|
-
const end = expr.
|
|
261
|
+
const start = expr.start;
|
|
262
|
+
const end = expr.end;
|
|
110
263
|
const sliced = sliceExpr(expr);
|
|
111
|
-
const text =
|
|
264
|
+
const text = expr.type === "ObjectExpression" ? `() => (${sliced})` : `() => ${sliced}`;
|
|
112
265
|
replacements.push({
|
|
113
266
|
start,
|
|
114
267
|
end,
|
|
115
268
|
text
|
|
116
269
|
});
|
|
117
270
|
}
|
|
118
|
-
/** Try to hoist or wrap an expression, pushing a replacement if needed. */
|
|
119
271
|
function hoistOrWrap(expr) {
|
|
120
272
|
const hoistName = maybeHoist(expr);
|
|
121
273
|
if (hoistName) replacements.push({
|
|
122
|
-
start: expr.
|
|
123
|
-
end: expr.
|
|
274
|
+
start: expr.start,
|
|
275
|
+
end: expr.end,
|
|
124
276
|
text: hoistName
|
|
125
277
|
});
|
|
126
278
|
else if (shouldWrap(expr)) wrap(expr);
|
|
127
279
|
}
|
|
128
|
-
/** Try to emit a template for a JsxElement. Returns true if handled. */
|
|
129
280
|
function tryTemplateEmit(node) {
|
|
130
281
|
if (ssr) return false;
|
|
282
|
+
if (isSelfClosing(node)) return false;
|
|
131
283
|
if (templateElementCount(node, true) < 1) return false;
|
|
132
284
|
const tplCall = buildTemplateCall(node);
|
|
133
285
|
if (!tplCall) return false;
|
|
134
|
-
const start = node.
|
|
135
|
-
const end = node.
|
|
136
|
-
const parent = node
|
|
137
|
-
const needsBraces = parent && (
|
|
286
|
+
const start = node.start;
|
|
287
|
+
const end = node.end;
|
|
288
|
+
const parent = findParent(node);
|
|
289
|
+
const needsBraces = parent && (parent.type === "JSXElement" || parent.type === "JSXFragment");
|
|
138
290
|
replacements.push({
|
|
139
291
|
start,
|
|
140
292
|
end,
|
|
@@ -143,46 +295,33 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
143
295
|
needsTplImport = true;
|
|
144
296
|
return true;
|
|
145
297
|
}
|
|
146
|
-
/** Emit warnings for common JSX mistakes (e.g. <For> without by). */
|
|
147
298
|
function checkForWarnings(node) {
|
|
148
|
-
|
|
149
|
-
if ((
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 : "";
|
|
299
|
+
if (jsxTagName(node) !== "For") return;
|
|
300
|
+
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");
|
|
301
|
+
}
|
|
302
|
+
function handleJsxAttribute(node, parentElement) {
|
|
303
|
+
const name = node.name?.type === "JSXIdentifier" ? node.name.name : "";
|
|
164
304
|
if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return;
|
|
165
|
-
if (!node.
|
|
166
|
-
const expr = node.
|
|
167
|
-
if (!expr) return;
|
|
168
|
-
const
|
|
169
|
-
const tagName = ts.isIdentifier(openingEl.tagName) ? openingEl.tagName.text : "";
|
|
305
|
+
if (!node.value || node.value.type !== "JSXExpressionContainer") return;
|
|
306
|
+
const expr = node.value.expression;
|
|
307
|
+
if (!expr || expr.type === "JSXEmptyExpression") return;
|
|
308
|
+
const tagName = jsxTagName(parentElement);
|
|
170
309
|
if (tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()) {
|
|
171
|
-
if (
|
|
172
|
-
|
|
310
|
+
if (expr.type === "JSXElement" || expr.type === "JSXFragment") {
|
|
311
|
+
walkNode(expr);
|
|
173
312
|
return;
|
|
174
313
|
}
|
|
175
314
|
const hoistName = maybeHoist(expr);
|
|
176
315
|
if (hoistName) replacements.push({
|
|
177
|
-
start: expr.
|
|
178
|
-
end: expr.
|
|
316
|
+
start: expr.start,
|
|
317
|
+
end: expr.end,
|
|
179
318
|
text: hoistName
|
|
180
319
|
});
|
|
181
320
|
else if (shouldWrap(expr)) {
|
|
182
|
-
const start = expr.
|
|
183
|
-
const end = expr.
|
|
321
|
+
const start = expr.start;
|
|
322
|
+
const end = expr.end;
|
|
184
323
|
const sliced = sliceExpr(expr);
|
|
185
|
-
const inner =
|
|
324
|
+
const inner = expr.type === "ObjectExpression" ? `(${sliced})` : sliced;
|
|
186
325
|
replacements.push({
|
|
187
326
|
start,
|
|
188
327
|
end,
|
|
@@ -192,15 +331,14 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
192
331
|
}
|
|
193
332
|
} else hoistOrWrap(expr);
|
|
194
333
|
}
|
|
195
|
-
/** Handle a JSX expression in child position — wrap, hoist, or recurse. */
|
|
196
334
|
function handleJsxExpression(node) {
|
|
197
335
|
const expr = node.expression;
|
|
198
|
-
if (!expr) return;
|
|
336
|
+
if (!expr || expr.type === "JSXEmptyExpression") return;
|
|
199
337
|
const hoistName = maybeHoist(expr);
|
|
200
338
|
if (hoistName) {
|
|
201
339
|
replacements.push({
|
|
202
|
-
start: expr.
|
|
203
|
-
end: expr.
|
|
340
|
+
start: expr.start,
|
|
341
|
+
end: expr.end,
|
|
204
342
|
text: hoistName
|
|
205
343
|
});
|
|
206
344
|
return;
|
|
@@ -209,180 +347,259 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
209
347
|
wrap(expr);
|
|
210
348
|
return;
|
|
211
349
|
}
|
|
212
|
-
|
|
350
|
+
walkNode(expr);
|
|
213
351
|
}
|
|
214
|
-
/** Names that refer to the props object or splitProps results. */
|
|
215
352
|
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
353
|
const propDerivedVars = /* @__PURE__ */ new Map();
|
|
219
|
-
|
|
354
|
+
const signalVars = new Set(options.knownSignals);
|
|
355
|
+
const shadowedSignals = /* @__PURE__ */ new Set();
|
|
356
|
+
/** Check if an identifier name is an active (non-shadowed) signal variable. */
|
|
357
|
+
function isActiveSignal(name) {
|
|
358
|
+
return signalVars.has(name) && !shadowedSignals.has(name);
|
|
359
|
+
}
|
|
360
|
+
/** Find variable declarations and parameters in a function that shadow signal names. */
|
|
361
|
+
function findShadowingNames(node) {
|
|
362
|
+
const shadows = [];
|
|
363
|
+
for (const param of node.params ?? []) {
|
|
364
|
+
if (param.type === "Identifier" && signalVars.has(param.name)) shadows.push(param.name);
|
|
365
|
+
if (param.type === "ObjectPattern") for (const prop of param.properties ?? []) {
|
|
366
|
+
const val = prop.value ?? prop.key;
|
|
367
|
+
if (val?.type === "Identifier" && signalVars.has(val.name)) shadows.push(val.name);
|
|
368
|
+
}
|
|
369
|
+
if (param.type === "ArrayPattern") {
|
|
370
|
+
for (const el of param.elements ?? []) if (el?.type === "Identifier" && signalVars.has(el.name)) shadows.push(el.name);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const body = node.body;
|
|
374
|
+
const stmts = body?.body ?? body?.statements;
|
|
375
|
+
if (!Array.isArray(stmts)) return shadows;
|
|
376
|
+
for (const stmt of stmts) if (stmt.type === "VariableDeclaration") {
|
|
377
|
+
for (const decl of stmt.declarations ?? []) if (decl.id?.type === "Identifier" && signalVars.has(decl.id.name)) {
|
|
378
|
+
if (!decl.init || !isSignalCall(decl.init)) shadows.push(decl.id.name);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return shadows;
|
|
382
|
+
}
|
|
220
383
|
function readsFromProps(node) {
|
|
221
|
-
if (
|
|
222
|
-
|
|
384
|
+
if (node.type === "MemberExpression" && node.object?.type === "Identifier") {
|
|
385
|
+
if (propsNames.has(node.object.name)) return true;
|
|
386
|
+
}
|
|
223
387
|
let found = false;
|
|
224
|
-
|
|
388
|
+
forEachChildFast(node, (child) => {
|
|
225
389
|
if (found) return;
|
|
226
390
|
if (readsFromProps(child)) found = true;
|
|
227
391
|
});
|
|
228
392
|
return found;
|
|
229
393
|
}
|
|
230
|
-
/**
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
}
|
|
394
|
+
/** Check if an expression references any prop-derived variable. */
|
|
395
|
+
function referencesPropDerived(node) {
|
|
396
|
+
if (node.type === "Identifier" && propDerivedVars.has(node.name)) {
|
|
397
|
+
const p = findParent(node);
|
|
398
|
+
if (p && p.type === "MemberExpression" && p.property === node && !p.computed) return false;
|
|
399
|
+
return true;
|
|
243
400
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
401
|
+
let found = false;
|
|
402
|
+
forEachChildFast(node, (child) => {
|
|
403
|
+
if (found) return;
|
|
404
|
+
if (referencesPropDerived(child)) found = true;
|
|
405
|
+
});
|
|
406
|
+
return found;
|
|
407
|
+
}
|
|
408
|
+
/** Collect prop-derived variable info from a VariableDeclaration node.
|
|
409
|
+
* Called inline during the single-pass walk when we encounter a declaration. */
|
|
410
|
+
function collectPropDerivedFromDecl(node, callbackDepth) {
|
|
411
|
+
if (node.type !== "VariableDeclaration") return;
|
|
412
|
+
for (const decl of node.declarations ?? []) {
|
|
413
|
+
if (decl.id?.type === "ArrayPattern" && decl.init?.type === "CallExpression") {
|
|
414
|
+
const callee = decl.init.callee;
|
|
415
|
+
if (callee?.type === "Identifier" && callee.name === "splitProps") {
|
|
416
|
+
for (const el of decl.id.elements ?? []) if (el?.type === "Identifier") propsNames.add(el.name);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (node.kind !== "const") continue;
|
|
420
|
+
if (callbackDepth > 0) continue;
|
|
421
|
+
if (decl.id?.type === "Identifier" && decl.init) {
|
|
422
|
+
if (isStatefulCall(decl.init)) {
|
|
423
|
+
if (isSignalCall(decl.init)) signalVars.add(decl.id.name);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (readsFromProps(decl.init) || referencesPropDerived(decl.init)) propDerivedVars.set(decl.id.name, {
|
|
427
|
+
start: decl.init.start,
|
|
428
|
+
end: decl.init.end
|
|
429
|
+
});
|
|
249
430
|
}
|
|
250
|
-
|
|
251
|
-
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/** Detect component functions and register their first param as a props name.
|
|
434
|
+
* Called inline during the walk when entering a function. */
|
|
435
|
+
function maybeRegisterComponentProps(node) {
|
|
436
|
+
if ((node.type === "FunctionDeclaration" || node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") && (node.params?.length ?? 0) > 0) {
|
|
437
|
+
const parent = findParent(node);
|
|
438
|
+
if (parent && parent.type === "CallExpression" && (parent.arguments ?? []).includes(node)) return;
|
|
439
|
+
const firstParam = node.params[0];
|
|
440
|
+
if (firstParam?.type === "Identifier") {
|
|
252
441
|
let hasJSX = false;
|
|
253
|
-
|
|
442
|
+
function checkJSX(n) {
|
|
254
443
|
if (hasJSX) return;
|
|
255
|
-
if (
|
|
444
|
+
if (n.type === "JSXElement" || n.type === "JSXFragment") {
|
|
256
445
|
hasJSX = true;
|
|
257
446
|
return;
|
|
258
447
|
}
|
|
259
|
-
|
|
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);
|
|
448
|
+
forEachChildFast(n, checkJSX);
|
|
269
449
|
}
|
|
270
|
-
|
|
271
|
-
|
|
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);
|
|
450
|
+
forEachChildFast(node, checkJSX);
|
|
451
|
+
if (hasJSX) propsNames.add(firstParam.name);
|
|
276
452
|
}
|
|
277
453
|
}
|
|
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
454
|
}
|
|
455
|
+
const resolvedCache = /* @__PURE__ */ new Map();
|
|
456
|
+
const resolving = /* @__PURE__ */ new Set();
|
|
311
457
|
const warnedCycles = /* @__PURE__ */ new Set();
|
|
312
|
-
function
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
458
|
+
function resolveVarToString(varName, sourceNode) {
|
|
459
|
+
if (resolvedCache.has(varName)) return resolvedCache.get(varName);
|
|
460
|
+
if (resolving.has(varName)) {
|
|
461
|
+
const cycleKey = [...resolving, varName].sort().join(",");
|
|
462
|
+
if (!warnedCycles.has(cycleKey)) {
|
|
463
|
+
warnedCycles.add(cycleKey);
|
|
464
|
+
const chain = [...resolving, varName].join(" → ");
|
|
465
|
+
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");
|
|
466
|
+
}
|
|
467
|
+
return varName;
|
|
468
|
+
}
|
|
469
|
+
resolving.add(varName);
|
|
470
|
+
const span = propDerivedVars.get(varName);
|
|
471
|
+
const resolved = resolveIdentifiersInText(code.slice(span.start, span.end), span.start, sourceNode);
|
|
472
|
+
resolving.delete(varName);
|
|
473
|
+
resolvedCache.set(varName, resolved);
|
|
474
|
+
return resolved;
|
|
475
|
+
}
|
|
476
|
+
function resolveIdentifiersInText(text, baseOffset, sourceNode) {
|
|
477
|
+
const endOffset = baseOffset + text.length;
|
|
478
|
+
const idents = [];
|
|
479
|
+
function findIdents(node, parent) {
|
|
480
|
+
const nodeStart = node.start;
|
|
481
|
+
const nodeEnd = node.end;
|
|
482
|
+
if (nodeStart >= endOffset || nodeEnd <= baseOffset) return;
|
|
483
|
+
if (node.type === "Identifier" && propDerivedVars.has(node.name)) {
|
|
316
484
|
if (parent) {
|
|
317
|
-
if ("
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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));
|
|
485
|
+
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({
|
|
486
|
+
start: nodeStart,
|
|
487
|
+
end: nodeEnd,
|
|
488
|
+
name: node.name
|
|
489
|
+
});
|
|
490
|
+
} else if (nodeStart >= baseOffset && nodeEnd <= endOffset) idents.push({
|
|
491
|
+
start: nodeStart,
|
|
492
|
+
end: nodeEnd,
|
|
493
|
+
name: node.name
|
|
494
|
+
});
|
|
333
495
|
}
|
|
334
|
-
|
|
335
|
-
}
|
|
496
|
+
forEachChildFast(node, (child) => findIdents(child, node));
|
|
497
|
+
}
|
|
498
|
+
findIdents(program, null);
|
|
499
|
+
if (idents.length === 0) return text;
|
|
500
|
+
idents.sort((a, b) => a.start - b.start);
|
|
501
|
+
const parts = [];
|
|
502
|
+
let lastPos = baseOffset;
|
|
503
|
+
for (const id of idents) {
|
|
504
|
+
parts.push(code.slice(lastPos, id.start));
|
|
505
|
+
parts.push(`(${resolveVarToString(id.name, sourceNode)})`);
|
|
506
|
+
lastPos = id.end;
|
|
507
|
+
}
|
|
508
|
+
parts.push(code.slice(lastPos, endOffset));
|
|
509
|
+
return parts.join("");
|
|
336
510
|
}
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
*/
|
|
511
|
+
const _isDynamicCache = /* @__PURE__ */ new Map();
|
|
512
|
+
/** Fused isDynamic: checks both containsCall and accessesProps in one traversal. */
|
|
344
513
|
function isDynamic(node) {
|
|
345
|
-
|
|
346
|
-
|
|
514
|
+
const key = node.start;
|
|
515
|
+
const cached = _isDynamicCache.get(key);
|
|
516
|
+
if (cached !== void 0) return cached;
|
|
517
|
+
const result = _isDynamicImpl(node);
|
|
518
|
+
_isDynamicCache.set(key, result);
|
|
519
|
+
return result;
|
|
520
|
+
}
|
|
521
|
+
function _isDynamicImpl(node) {
|
|
522
|
+
if (node.type === "CallExpression") {
|
|
523
|
+
if (!isPureStaticCall(node)) return true;
|
|
524
|
+
}
|
|
525
|
+
if (node.type === "TaggedTemplateExpression") return true;
|
|
526
|
+
if (node.type === "MemberExpression" && !node.computed && node.object?.type === "Identifier") {
|
|
527
|
+
if (propsNames.has(node.object.name)) return true;
|
|
528
|
+
}
|
|
529
|
+
if (node.type === "Identifier" && propDerivedVars.has(node.name)) {
|
|
530
|
+
const parent = findParent(node);
|
|
531
|
+
if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) {} else return true;
|
|
532
|
+
}
|
|
533
|
+
if (node.type === "Identifier" && isActiveSignal(node.name)) {
|
|
534
|
+
const parent = findParent(node);
|
|
535
|
+
if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) {} else if (parent && parent.type === "CallExpression" && parent.callee === node) {} else return true;
|
|
536
|
+
}
|
|
537
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") return false;
|
|
538
|
+
let found = false;
|
|
539
|
+
forEachChildFast(node, (child) => {
|
|
540
|
+
if (found) return;
|
|
541
|
+
if (isDynamic(child)) found = true;
|
|
542
|
+
});
|
|
543
|
+
return found;
|
|
347
544
|
}
|
|
348
|
-
/**
|
|
545
|
+
/** accessesProps — kept for sliceExpr's quick check (does this need resolution?) */
|
|
349
546
|
function accessesProps(node) {
|
|
350
|
-
if (
|
|
351
|
-
if (propsNames.has(node.
|
|
547
|
+
if (node.type === "MemberExpression" && !node.computed && node.object?.type === "Identifier") {
|
|
548
|
+
if (propsNames.has(node.object.name)) return true;
|
|
352
549
|
}
|
|
353
|
-
if (
|
|
354
|
-
const parent = node
|
|
355
|
-
if (parent &&
|
|
550
|
+
if (node.type === "Identifier" && propDerivedVars.has(node.name)) {
|
|
551
|
+
const parent = findParent(node);
|
|
552
|
+
if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) return false;
|
|
356
553
|
return true;
|
|
357
554
|
}
|
|
358
555
|
let found = false;
|
|
359
|
-
|
|
556
|
+
forEachChildFast(node, (child) => {
|
|
360
557
|
if (found) return;
|
|
361
|
-
if (
|
|
558
|
+
if (child.type === "ArrowFunctionExpression" || child.type === "FunctionExpression") return;
|
|
362
559
|
if (accessesProps(child)) found = true;
|
|
363
560
|
});
|
|
364
561
|
return found;
|
|
365
562
|
}
|
|
366
563
|
function shouldWrap(node) {
|
|
367
|
-
if (
|
|
564
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") return false;
|
|
368
565
|
if (isStatic(node)) return false;
|
|
369
|
-
if (
|
|
566
|
+
if (node.type === "CallExpression" && isPureStaticCall(node)) return false;
|
|
370
567
|
return isDynamic(node);
|
|
371
568
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
569
|
+
let _callbackDepth = 0;
|
|
570
|
+
function walkNode(node) {
|
|
571
|
+
const isFunction = node.type === "FunctionDeclaration" || node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
|
|
572
|
+
let scopeShadows = null;
|
|
573
|
+
if (isFunction) {
|
|
574
|
+
const parent = findParent(node);
|
|
575
|
+
if (parent && parent.type === "CallExpression" && (parent.arguments ?? []).includes(node)) _callbackDepth++;
|
|
576
|
+
maybeRegisterComponentProps(node);
|
|
577
|
+
if (signalVars.size > 0) {
|
|
578
|
+
scopeShadows = findShadowingNames(node);
|
|
579
|
+
for (const name of scopeShadows) shadowedSignals.add(name);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (node.type === "VariableDeclaration") collectPropDerivedFromDecl(node, _callbackDepth);
|
|
583
|
+
if (node.type === "JSXElement") {
|
|
584
|
+
if (!isSelfClosing(node) && tryTemplateEmit(node)) return;
|
|
585
|
+
checkForWarnings(node);
|
|
586
|
+
for (const attr of jsxAttrs(node)) if (attr.type === "JSXAttribute") handleJsxAttribute(attr, node);
|
|
587
|
+
for (const child of jsxChildren(node)) if (child.type === "JSXExpressionContainer") handleJsxExpression(child);
|
|
588
|
+
else walkNode(child);
|
|
377
589
|
return;
|
|
378
590
|
}
|
|
379
|
-
if (
|
|
591
|
+
if (node.type === "JSXExpressionContainer") {
|
|
380
592
|
handleJsxExpression(node);
|
|
381
593
|
return;
|
|
382
594
|
}
|
|
383
|
-
|
|
595
|
+
forEachChildFast(node, walkNode);
|
|
596
|
+
if (isFunction) {
|
|
597
|
+
const parent = findParent(node);
|
|
598
|
+
if (parent && parent.type === "CallExpression" && (parent.arguments ?? []).includes(node)) _callbackDepth--;
|
|
599
|
+
}
|
|
600
|
+
if (scopeShadows) for (const name of scopeShadows) shadowedSignals.delete(name);
|
|
384
601
|
}
|
|
385
|
-
|
|
602
|
+
walkNode(program);
|
|
386
603
|
if (replacements.length === 0 && hoists.length === 0) return {
|
|
387
604
|
code,
|
|
388
605
|
warnings
|
|
@@ -413,67 +630,49 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
413
630
|
usesTemplates: needsTplImport,
|
|
414
631
|
warnings
|
|
415
632
|
};
|
|
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
633
|
function hasBailAttr(node, isRoot = false) {
|
|
423
634
|
for (const attr of jsxAttrs(node)) {
|
|
424
|
-
if (
|
|
635
|
+
if (attr.type === "JSXSpreadAttribute") {
|
|
425
636
|
if (isRoot) continue;
|
|
426
637
|
return true;
|
|
427
638
|
}
|
|
428
|
-
if (
|
|
639
|
+
if (attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === "key") return true;
|
|
429
640
|
}
|
|
430
641
|
return false;
|
|
431
642
|
}
|
|
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
643
|
function countChildForTemplate(child) {
|
|
437
|
-
if (
|
|
438
|
-
if (
|
|
439
|
-
if (
|
|
440
|
-
|
|
441
|
-
|
|
644
|
+
if (child.type === "JSXText") return 0;
|
|
645
|
+
if (child.type === "JSXElement") return templateElementCount(child);
|
|
646
|
+
if (child.type === "JSXExpressionContainer") {
|
|
647
|
+
const expr = child.expression;
|
|
648
|
+
if (!expr || expr.type === "JSXEmptyExpression") return 0;
|
|
649
|
+
return containsJSXInExpr(expr) ? -1 : 0;
|
|
442
650
|
}
|
|
443
|
-
if (
|
|
651
|
+
if (child.type === "JSXFragment") return templateFragmentCount(child);
|
|
444
652
|
return -1;
|
|
445
653
|
}
|
|
446
|
-
/**
|
|
447
|
-
* Count DOM elements in a JSX subtree. Returns -1 if the tree is not
|
|
448
|
-
* eligible for template emission.
|
|
449
|
-
*/
|
|
450
654
|
function templateElementCount(node, isRoot = false) {
|
|
451
655
|
const tag = jsxTagName(node);
|
|
452
656
|
if (!tag || !isLowerCase(tag)) return -1;
|
|
453
657
|
if (hasBailAttr(node, isRoot)) return -1;
|
|
454
|
-
if (
|
|
658
|
+
if (isSelfClosing(node)) return 1;
|
|
455
659
|
let count = 1;
|
|
456
|
-
for (const child of node
|
|
660
|
+
for (const child of jsxChildren(node)) {
|
|
457
661
|
const c = countChildForTemplate(child);
|
|
458
662
|
if (c === -1) return -1;
|
|
459
663
|
count += c;
|
|
460
664
|
}
|
|
461
665
|
return count;
|
|
462
666
|
}
|
|
463
|
-
/** Count template-eligible elements inside a fragment. */
|
|
464
667
|
function templateFragmentCount(frag) {
|
|
465
668
|
let count = 0;
|
|
466
|
-
for (const child of frag
|
|
669
|
+
for (const child of jsxChildren(frag)) {
|
|
467
670
|
const c = countChildForTemplate(child);
|
|
468
671
|
if (c === -1) return -1;
|
|
469
672
|
count += c;
|
|
470
673
|
}
|
|
471
674
|
return count;
|
|
472
675
|
}
|
|
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
676
|
function buildTemplateCall(node) {
|
|
478
677
|
const bindLines = [];
|
|
479
678
|
const disposerNames = [];
|
|
@@ -495,7 +694,6 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
495
694
|
function nextTextVar() {
|
|
496
695
|
return `__t${varIdx++}`;
|
|
497
696
|
}
|
|
498
|
-
/** Resolve the variable name for an element given its accessor path. */
|
|
499
697
|
function resolveElementVar(accessor, hasDynamic) {
|
|
500
698
|
if (accessor === "__root") return "__root";
|
|
501
699
|
if (hasDynamic) {
|
|
@@ -505,52 +703,45 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
505
703
|
}
|
|
506
704
|
return accessor;
|
|
507
705
|
}
|
|
508
|
-
/** Emit bind line for a ref attribute. */
|
|
509
706
|
function emitRef(attr, varName) {
|
|
510
|
-
if (!attr.
|
|
511
|
-
const expr = attr.
|
|
512
|
-
if (!expr) return;
|
|
513
|
-
if (
|
|
707
|
+
if (!attr.value || attr.value.type !== "JSXExpressionContainer") return;
|
|
708
|
+
const expr = attr.value.expression;
|
|
709
|
+
if (!expr || expr.type === "JSXEmptyExpression") return;
|
|
710
|
+
if (expr.type === "ArrowFunctionExpression" || expr.type === "FunctionExpression") bindLines.push(`(${sliceExpr(expr)})(${varName})`);
|
|
514
711
|
else bindLines.push(`{ const __r = ${sliceExpr(expr)}; if (typeof __r === "function") __r(${varName}); else if (__r) __r.current = ${varName} }`);
|
|
515
712
|
}
|
|
516
|
-
/** Emit event handler bind line — delegated (expando) or addEventListener. */
|
|
517
713
|
function emitEventListener(attr, attrName, varName) {
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
if (!attr.
|
|
521
|
-
const
|
|
714
|
+
const lowered = attrName.slice(2).toLowerCase();
|
|
715
|
+
const eventName = REACT_EVENT_REMAP[lowered] ?? lowered;
|
|
716
|
+
if (!attr.value || attr.value.type !== "JSXExpressionContainer") return;
|
|
717
|
+
const expr = attr.value.expression;
|
|
718
|
+
if (!expr || expr.type === "JSXEmptyExpression") return;
|
|
719
|
+
const handler = sliceExpr(expr);
|
|
522
720
|
if (DELEGATED_EVENTS.has(eventName)) bindLines.push(`${varName}.__ev_${eventName} = ${handler}`);
|
|
523
721
|
else bindLines.push(`${varName}.addEventListener("${eventName}", ${handler})`);
|
|
524
722
|
}
|
|
525
|
-
/** Return HTML string for a static attribute expression, or null if not static. */
|
|
526
723
|
function staticAttrToHtml(exprNode, htmlAttrName) {
|
|
527
724
|
if (!isStatic(exprNode)) return null;
|
|
528
|
-
if (
|
|
529
|
-
if (
|
|
530
|
-
if (exprNode.
|
|
725
|
+
if ((exprNode.type === "Literal" || exprNode.type === "StringLiteral") && typeof exprNode.value === "string") return ` ${htmlAttrName}="${escapeHtmlAttr(exprNode.value)}"`;
|
|
726
|
+
if ((exprNode.type === "Literal" || exprNode.type === "NumericLiteral") && typeof exprNode.value === "number") return ` ${htmlAttrName}="${exprNode.value}"`;
|
|
727
|
+
if ((exprNode.type === "Literal" || exprNode.type === "BooleanLiteral") && exprNode.value === true) return ` ${htmlAttrName}`;
|
|
531
728
|
return "";
|
|
532
729
|
}
|
|
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
730
|
function tryDirectSignalRef(exprNode) {
|
|
539
731
|
let inner = exprNode;
|
|
540
|
-
if (
|
|
541
|
-
if (
|
|
542
|
-
if (inner.arguments
|
|
543
|
-
const callee = inner.
|
|
544
|
-
if (
|
|
732
|
+
if (inner.type === "ArrowFunctionExpression" && inner.body?.type !== "BlockStatement") inner = inner.body;
|
|
733
|
+
if (inner.type !== "CallExpression") return null;
|
|
734
|
+
if ((inner.arguments?.length ?? 0) > 0) return null;
|
|
735
|
+
const callee = inner.callee;
|
|
736
|
+
if (callee?.type === "Identifier") return sliceExpr(callee);
|
|
545
737
|
return null;
|
|
546
738
|
}
|
|
547
|
-
/** Unwrap a reactive accessor expression for use inside _bind(). */
|
|
548
739
|
function unwrapAccessor(exprNode) {
|
|
549
|
-
if (
|
|
740
|
+
if (exprNode.type === "ArrowFunctionExpression" && exprNode.body?.type !== "BlockStatement") return {
|
|
550
741
|
expr: sliceExpr(exprNode.body),
|
|
551
742
|
isReactive: true
|
|
552
743
|
};
|
|
553
|
-
if (
|
|
744
|
+
if (exprNode.type === "ArrowFunctionExpression" || exprNode.type === "FunctionExpression") return {
|
|
554
745
|
expr: `(${sliceExpr(exprNode)})()`,
|
|
555
746
|
isReactive: true
|
|
556
747
|
};
|
|
@@ -559,13 +750,12 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
559
750
|
isReactive: isDynamic(exprNode)
|
|
560
751
|
};
|
|
561
752
|
}
|
|
562
|
-
/** Build a setter expression for an attribute. */
|
|
563
753
|
function attrSetter(htmlAttrName, varName, expr) {
|
|
564
754
|
if (htmlAttrName === "class") return `${varName}.className = ${expr}`;
|
|
565
755
|
if (htmlAttrName === "style") return `${varName}.style.cssText = ${expr}`;
|
|
756
|
+
if (DOM_PROPS.has(htmlAttrName)) return `${varName}.${htmlAttrName} = ${expr}`;
|
|
566
757
|
return `${varName}.setAttribute("${htmlAttrName}", ${expr})`;
|
|
567
758
|
}
|
|
568
|
-
/** Emit bind line for a dynamic (non-static) attribute. */
|
|
569
759
|
function emitDynamicAttr(_expr, exprNode, htmlAttrName, varName) {
|
|
570
760
|
const { expr, isReactive } = unwrapAccessor(exprNode);
|
|
571
761
|
if (!isReactive) {
|
|
@@ -576,24 +766,22 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
576
766
|
if (directRef) {
|
|
577
767
|
needsBindDirectImport = true;
|
|
578
768
|
const d = nextDisp();
|
|
579
|
-
const updater = htmlAttrName === "class" ? `(v) => { ${varName}.className = v == null ? "" : String(v) }` : htmlAttrName === "style" ? `(v) => { if (typeof v === "string") ${varName}.style.cssText = v; else if (v) Object.assign(${varName}.style, v) }` : `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`;
|
|
769
|
+
const updater = htmlAttrName === "class" ? `(v) => { ${varName}.className = v == null ? "" : String(v) }` : htmlAttrName === "style" ? `(v) => { if (typeof v === "string") ${varName}.style.cssText = v; else if (v) Object.assign(${varName}.style, v) }` : DOM_PROPS.has(htmlAttrName) ? `(v) => { ${varName}.${htmlAttrName} = v }` : `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`;
|
|
580
770
|
bindLines.push(`const ${d} = _bindDirect(${directRef}, ${updater})`);
|
|
581
771
|
return;
|
|
582
772
|
}
|
|
583
773
|
reactiveBindExprs.push(attrSetter(htmlAttrName, varName, expr));
|
|
584
774
|
}
|
|
585
|
-
/** Emit bind line or HTML for an expression attribute value. */
|
|
586
775
|
function emitAttrExpression(exprNode, htmlAttrName, varName) {
|
|
587
776
|
const staticHtml = staticAttrToHtml(exprNode, htmlAttrName);
|
|
588
777
|
if (staticHtml !== null) return staticHtml;
|
|
589
|
-
if (htmlAttrName === "style" &&
|
|
778
|
+
if (htmlAttrName === "style" && exprNode.type === "ObjectExpression") {
|
|
590
779
|
bindLines.push(`Object.assign(${varName}.style, ${sliceExpr(exprNode)})`);
|
|
591
780
|
return "";
|
|
592
781
|
}
|
|
593
782
|
emitDynamicAttr(sliceExpr(exprNode), exprNode, htmlAttrName, varName);
|
|
594
783
|
return "";
|
|
595
784
|
}
|
|
596
|
-
/** Emit side-effects for special attrs (ref, event). Returns true if handled. */
|
|
597
785
|
function tryEmitSpecialAttr(attr, attrName, varName) {
|
|
598
786
|
if (attrName === "ref") {
|
|
599
787
|
emitRef(attr, varName);
|
|
@@ -605,35 +793,34 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
605
793
|
}
|
|
606
794
|
return false;
|
|
607
795
|
}
|
|
608
|
-
/** Convert an attribute initializer to HTML. Returns empty string for side-effect-only attrs. */
|
|
609
796
|
function attrInitializerToHtml(attr, htmlAttrName, varName) {
|
|
610
|
-
if (!attr.
|
|
611
|
-
if (
|
|
612
|
-
if (
|
|
797
|
+
if (!attr.value) return ` ${htmlAttrName}`;
|
|
798
|
+
if (attr.value.type === "StringLiteral" || attr.value.type === "Literal" && typeof attr.value.value === "string") return ` ${htmlAttrName}="${escapeHtmlAttr(attr.value.value)}"`;
|
|
799
|
+
if (attr.value.type === "JSXExpressionContainer") {
|
|
800
|
+
const expr = attr.value.expression;
|
|
801
|
+
if (expr && expr.type !== "JSXEmptyExpression") return emitAttrExpression(expr, htmlAttrName, varName);
|
|
802
|
+
}
|
|
613
803
|
return "";
|
|
614
804
|
}
|
|
615
|
-
/** Process a single attribute, returning HTML to append. */
|
|
616
805
|
function processOneAttr(attr, varName) {
|
|
617
|
-
if (
|
|
618
|
-
const expr = sliceExpr(attr.
|
|
806
|
+
if (attr.type === "JSXSpreadAttribute") {
|
|
807
|
+
const expr = sliceExpr(attr.argument);
|
|
619
808
|
needsApplyPropsImport = true;
|
|
620
|
-
if (isDynamic(attr.
|
|
809
|
+
if (isDynamic(attr.argument)) reactiveBindExprs.push(`_applyProps(${varName}, ${expr})`);
|
|
621
810
|
else bindLines.push(`_applyProps(${varName}, ${expr})`);
|
|
622
811
|
return "";
|
|
623
812
|
}
|
|
624
|
-
if (
|
|
625
|
-
const attrName =
|
|
813
|
+
if (attr.type !== "JSXAttribute") return "";
|
|
814
|
+
const attrName = attr.name?.type === "JSXIdentifier" ? attr.name.name : "";
|
|
626
815
|
if (attrName === "key") return "";
|
|
627
816
|
if (tryEmitSpecialAttr(attr, attrName, varName)) return "";
|
|
628
817
|
return attrInitializerToHtml(attr, JSX_TO_HTML_ATTR[attrName] ?? attrName, varName);
|
|
629
818
|
}
|
|
630
|
-
/** Process all attributes on an element, returning the HTML attribute string. */
|
|
631
819
|
function processAttrs(el, varName) {
|
|
632
820
|
let htmlAttrs = "";
|
|
633
821
|
for (const attr of jsxAttrs(el)) htmlAttrs += processOneAttr(attr, varName);
|
|
634
822
|
return htmlAttrs;
|
|
635
823
|
}
|
|
636
|
-
/** Emit bind lines for a reactive text expression child. */
|
|
637
824
|
function emitReactiveTextChild(expr, exprNode, varName, parentRef, childNodeIdx, needsPlaceholder) {
|
|
638
825
|
const tVar = nextTextVar();
|
|
639
826
|
bindLines.push(`const ${tVar} = document.createTextNode("")`);
|
|
@@ -651,7 +838,6 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
651
838
|
}
|
|
652
839
|
return needsPlaceholder ? "<!>" : "";
|
|
653
840
|
}
|
|
654
|
-
/** Emit bind lines for a static text expression child. */
|
|
655
841
|
function emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder) {
|
|
656
842
|
if (needsPlaceholder) {
|
|
657
843
|
const tVar = nextTextVar();
|
|
@@ -662,7 +848,65 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
662
848
|
bindLines.push(`${varName}.textContent = ${expr}`);
|
|
663
849
|
return "";
|
|
664
850
|
}
|
|
665
|
-
|
|
851
|
+
function classifyJsxChild(child, out, elemIdxRef, recurse) {
|
|
852
|
+
if (child.type === "JSXText") {
|
|
853
|
+
const cleaned = cleanJsxText(child.value ?? child.raw ?? "");
|
|
854
|
+
if (cleaned) out.push({
|
|
855
|
+
kind: "text",
|
|
856
|
+
text: cleaned
|
|
857
|
+
});
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (child.type === "JSXElement") {
|
|
861
|
+
out.push({
|
|
862
|
+
kind: "element",
|
|
863
|
+
node: child,
|
|
864
|
+
elemIdx: elemIdxRef.value++
|
|
865
|
+
});
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (child.type === "JSXExpressionContainer") {
|
|
869
|
+
const expr = child.expression;
|
|
870
|
+
if (expr && expr.type !== "JSXEmptyExpression") out.push({
|
|
871
|
+
kind: "expression",
|
|
872
|
+
expression: expr
|
|
873
|
+
});
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (child.type === "JSXFragment") recurse(jsxChildren(child));
|
|
877
|
+
}
|
|
878
|
+
function flattenChildren(children) {
|
|
879
|
+
const flatList = [];
|
|
880
|
+
const elemIdxRef = { value: 0 };
|
|
881
|
+
function addChildren(kids) {
|
|
882
|
+
for (const child of kids) classifyJsxChild(child, flatList, elemIdxRef, addChildren);
|
|
883
|
+
}
|
|
884
|
+
addChildren(children);
|
|
885
|
+
return flatList;
|
|
886
|
+
}
|
|
887
|
+
function analyzeChildren(flatChildren) {
|
|
888
|
+
const hasElem = flatChildren.some((c) => c.kind === "element");
|
|
889
|
+
const hasText = flatChildren.some((c) => c.kind === "text");
|
|
890
|
+
const exprCount = flatChildren.filter((c) => c.kind === "expression").length;
|
|
891
|
+
return {
|
|
892
|
+
useMixed: (hasElem ? 1 : 0) + (hasText ? 1 : 0) + (exprCount > 0 ? 1 : 0) > 1,
|
|
893
|
+
useMultiExpr: exprCount > 1
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
function attrIsDynamic(attr) {
|
|
897
|
+
if (attr.type !== "JSXAttribute") return false;
|
|
898
|
+
const name = attr.name?.type === "JSXIdentifier" ? attr.name.name : "";
|
|
899
|
+
if (name === "ref") return true;
|
|
900
|
+
if (EVENT_RE.test(name)) return true;
|
|
901
|
+
if (!attr.value || attr.value.type !== "JSXExpressionContainer") return false;
|
|
902
|
+
const expr = attr.value.expression;
|
|
903
|
+
return expr && expr.type !== "JSXEmptyExpression" ? !isStatic(expr) : false;
|
|
904
|
+
}
|
|
905
|
+
function elementHasDynamic(node) {
|
|
906
|
+
if (jsxAttrs(node).some(attrIsDynamic)) return true;
|
|
907
|
+
if (!isSelfClosing(node)) return jsxChildren(node).some((c) => c.type === "JSXExpressionContainer" && c.expression && c.expression.type !== "JSXEmptyExpression");
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
666
910
|
function processOneChild(child, varName, parentRef, useMixed, useMultiExpr, childNodeIdx) {
|
|
667
911
|
if (child.kind === "text") return escapeHtmlText(child.text);
|
|
668
912
|
if (child.kind === "element") {
|
|
@@ -681,9 +925,8 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
681
925
|
if (isReactive) return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder);
|
|
682
926
|
return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder);
|
|
683
927
|
}
|
|
684
|
-
/** Process children of a JsxElement, returning the children HTML. */
|
|
685
928
|
function processChildren(el, varName, accessor) {
|
|
686
|
-
const flatChildren = flattenChildren(el
|
|
929
|
+
const flatChildren = flattenChildren(jsxChildren(el));
|
|
687
930
|
const { useMixed, useMultiExpr } = analyzeChildren(flatChildren);
|
|
688
931
|
const parentRef = accessor === "__root" ? "__root" : varName;
|
|
689
932
|
let html = "";
|
|
@@ -696,13 +939,12 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
696
939
|
}
|
|
697
940
|
return html;
|
|
698
941
|
}
|
|
699
|
-
/** Process a single DOM element for template emission. Returns the HTML string or null. */
|
|
700
942
|
function processElement(el, accessor) {
|
|
701
943
|
const tag = jsxTagName(el);
|
|
702
944
|
if (!tag) return null;
|
|
703
945
|
const varName = resolveElementVar(accessor, elementHasDynamic(el));
|
|
704
946
|
let html = `<${tag}${processAttrs(el, varName)}>`;
|
|
705
|
-
if (
|
|
947
|
+
if (!isSelfClosing(el)) {
|
|
706
948
|
const childHtml = processChildren(el, varName, accessor);
|
|
707
949
|
if (childHtml === null) return null;
|
|
708
950
|
html += childHtml;
|
|
@@ -724,95 +966,80 @@ function transformJSX(code, filename = "input.tsx", options = {}) {
|
|
|
724
966
|
bindLines.push(`const ${combinedName} = _bind(() => { ${combinedBody} })`);
|
|
725
967
|
}
|
|
726
968
|
if (bindLines.length === 0 && disposerNames.length === 0) return `_tpl("${escaped}", () => null)`;
|
|
727
|
-
let body = bindLines.map((l) => ` ${l}
|
|
969
|
+
let body = bindLines.map((l) => ` ${l};`).join("\n");
|
|
728
970
|
if (disposerNames.length > 0) body += `\n return () => { ${disposerNames.map((d) => `${d}()`).join("; ")} }`;
|
|
729
971
|
else body += "\n return null";
|
|
730
972
|
return `_tpl("${escaped}", (__root) => {\n${body}\n})`;
|
|
731
973
|
}
|
|
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
974
|
function sliceExpr(expr) {
|
|
975
|
+
let result;
|
|
802
976
|
if (propDerivedVars.size > 0 && accessesProps(expr)) {
|
|
803
|
-
const
|
|
804
|
-
|
|
977
|
+
const start = expr.start;
|
|
978
|
+
const end = expr.end;
|
|
979
|
+
result = resolveIdentifiersInText(code.slice(start, end), start, expr);
|
|
980
|
+
} else result = code.slice(expr.start, expr.end);
|
|
981
|
+
if (signalVars.size > 0 && signalVars.size > shadowedSignals.size && referencesSignalVar(expr)) result = autoCallSignals(result, expr);
|
|
982
|
+
return result;
|
|
983
|
+
}
|
|
984
|
+
/** Check if an expression references any tracked signal variable. */
|
|
985
|
+
function referencesSignalVar(node) {
|
|
986
|
+
if (node.type === "Identifier" && isActiveSignal(node.name)) {
|
|
987
|
+
const parent = findParent(node);
|
|
988
|
+
if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) return false;
|
|
989
|
+
if (parent && parent.type === "MemberExpression" && parent.object === node) {
|
|
990
|
+
const grand = findParent(parent);
|
|
991
|
+
if (grand && grand.type === "CallExpression" && grand.callee === parent) return false;
|
|
992
|
+
}
|
|
993
|
+
if (parent && parent.type === "CallExpression" && parent.callee === node) return false;
|
|
994
|
+
return true;
|
|
805
995
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
996
|
+
let found = false;
|
|
997
|
+
forEachChildFast(node, (child) => {
|
|
998
|
+
if (found) return;
|
|
999
|
+
if (child.type === "ArrowFunctionExpression" || child.type === "FunctionExpression") return;
|
|
1000
|
+
if (referencesSignalVar(child)) found = true;
|
|
1001
|
+
});
|
|
1002
|
+
return found;
|
|
812
1003
|
}
|
|
813
|
-
/**
|
|
814
|
-
|
|
815
|
-
|
|
1004
|
+
/** Auto-insert () after signal variable references in the expression source.
|
|
1005
|
+
* Uses the AST to find exact Identifier positions — never scans raw text. */
|
|
1006
|
+
function autoCallSignals(text, expr) {
|
|
1007
|
+
const start = expr.start;
|
|
1008
|
+
const idents = [];
|
|
1009
|
+
function findSignalIdents(node) {
|
|
1010
|
+
if (node.start >= start + text.length || node.end <= start) return;
|
|
1011
|
+
if (node.type === "Identifier" && isActiveSignal(node.name)) {
|
|
1012
|
+
const parent = findParent(node);
|
|
1013
|
+
if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) return;
|
|
1014
|
+
if (parent && parent.type === "MemberExpression" && parent.object === node) {
|
|
1015
|
+
const grand = findParent(parent);
|
|
1016
|
+
if (grand && grand.type === "CallExpression" && grand.callee === parent) return;
|
|
1017
|
+
}
|
|
1018
|
+
if (parent && parent.type === "CallExpression" && parent.callee === node) return;
|
|
1019
|
+
if (parent && parent.type === "VariableDeclarator" && parent.id === node) return;
|
|
1020
|
+
if (parent && (parent.type === "Property" || parent.type === "ObjectProperty")) {
|
|
1021
|
+
if (parent.shorthand) return;
|
|
1022
|
+
if (parent.key === node && !parent.computed) return;
|
|
1023
|
+
}
|
|
1024
|
+
idents.push({
|
|
1025
|
+
start: node.start,
|
|
1026
|
+
end: node.end
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
forEachChildFast(node, findSignalIdents);
|
|
1030
|
+
}
|
|
1031
|
+
findSignalIdents(expr);
|
|
1032
|
+
if (idents.length === 0) return text;
|
|
1033
|
+
idents.sort((a, b) => a.start - b.start);
|
|
1034
|
+
const parts = [];
|
|
1035
|
+
let lastPos = start;
|
|
1036
|
+
for (const id of idents) {
|
|
1037
|
+
parts.push(code.slice(lastPos, id.end));
|
|
1038
|
+
parts.push("()");
|
|
1039
|
+
lastPos = id.end;
|
|
1040
|
+
}
|
|
1041
|
+
parts.push(code.slice(lastPos, start + text.length));
|
|
1042
|
+
return parts.join("");
|
|
816
1043
|
}
|
|
817
1044
|
}
|
|
818
1045
|
const VOID_ELEMENTS = new Set([
|
|
@@ -835,11 +1062,15 @@ const JSX_TO_HTML_ATTR = {
|
|
|
835
1062
|
className: "class",
|
|
836
1063
|
htmlFor: "for"
|
|
837
1064
|
};
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1065
|
+
const DOM_PROPS = new Set([
|
|
1066
|
+
"value",
|
|
1067
|
+
"checked",
|
|
1068
|
+
"selected",
|
|
1069
|
+
"disabled",
|
|
1070
|
+
"multiple",
|
|
1071
|
+
"readOnly",
|
|
1072
|
+
"indeterminate"
|
|
1073
|
+
]);
|
|
843
1074
|
const STATEFUL_CALLS = new Set([
|
|
844
1075
|
"signal",
|
|
845
1076
|
"computed",
|
|
@@ -857,29 +1088,34 @@ const STATEFUL_CALLS = new Set([
|
|
|
857
1088
|
"useStore"
|
|
858
1089
|
]);
|
|
859
1090
|
function isStatefulCall(node) {
|
|
860
|
-
if (
|
|
861
|
-
const callee = node.
|
|
862
|
-
if (
|
|
1091
|
+
if (node.type !== "CallExpression") return false;
|
|
1092
|
+
const callee = node.callee;
|
|
1093
|
+
if (callee?.type === "Identifier") return STATEFUL_CALLS.has(callee.name);
|
|
863
1094
|
return false;
|
|
864
1095
|
}
|
|
865
|
-
/**
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1096
|
+
/** Check if a call expression creates a callable reactive value (`signal(...)` or `computed(...)`). */
|
|
1097
|
+
function isSignalCall(node) {
|
|
1098
|
+
if (node.type !== "CallExpression") return false;
|
|
1099
|
+
const callee = node.callee;
|
|
1100
|
+
return callee?.type === "Identifier" && (callee.name === "signal" || callee.name === "computed");
|
|
1101
|
+
}
|
|
870
1102
|
function isChildrenExpression(node, expr) {
|
|
871
|
-
if (
|
|
872
|
-
if (
|
|
1103
|
+
if (node.type === "MemberExpression" && !node.computed && node.property?.type === "Identifier" && node.property.name === "children") return true;
|
|
1104
|
+
if (node.type === "Identifier" && node.name === "children") return true;
|
|
873
1105
|
if (expr.endsWith(".children") || expr === "children") return true;
|
|
874
1106
|
return false;
|
|
875
1107
|
}
|
|
876
1108
|
function isLowerCase(s) {
|
|
877
1109
|
return s.length > 0 && s[0] === s[0]?.toLowerCase();
|
|
878
1110
|
}
|
|
879
|
-
/** Check if an expression subtree contains JSX nodes */
|
|
880
1111
|
function containsJSXInExpr(node) {
|
|
881
|
-
if (
|
|
882
|
-
|
|
1112
|
+
if (node.type === "JSXElement" || node.type === "JSXFragment") return true;
|
|
1113
|
+
let found = false;
|
|
1114
|
+
forEachChild(node, (child) => {
|
|
1115
|
+
if (found) return;
|
|
1116
|
+
if (containsJSXInExpr(child)) found = true;
|
|
1117
|
+
});
|
|
1118
|
+
return found;
|
|
883
1119
|
}
|
|
884
1120
|
function escapeHtmlAttr(s) {
|
|
885
1121
|
return s.replace(/&/g, "&").replace(/"/g, """);
|
|
@@ -887,32 +1123,59 @@ function escapeHtmlAttr(s) {
|
|
|
887
1123
|
function escapeHtmlText(s) {
|
|
888
1124
|
return s.replace(/&(?!(?:#\d+|#x[\da-fA-F]+|[a-zA-Z]\w*);)/g, "&").replace(/</g, "<");
|
|
889
1125
|
}
|
|
1126
|
+
function cleanJsxText(raw) {
|
|
1127
|
+
if (!raw.includes("\n") && !raw.includes("\r")) return raw;
|
|
1128
|
+
const lines = raw.split(/\r\n|\n|\r/);
|
|
1129
|
+
let lastNonEmpty = -1;
|
|
1130
|
+
for (let i = 0; i < lines.length; i++) if (/[^ \t]/.test(lines[i] ?? "")) lastNonEmpty = i;
|
|
1131
|
+
let str = "";
|
|
1132
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1133
|
+
let line = (lines[i] ?? "").replace(/\t/g, " ");
|
|
1134
|
+
if (i !== 0) line = line.replace(/^ +/, "");
|
|
1135
|
+
if (i !== lines.length - 1) line = line.replace(/ +$/, "");
|
|
1136
|
+
if (line) {
|
|
1137
|
+
if (i !== lastNonEmpty) line += " ";
|
|
1138
|
+
str += line;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
return str;
|
|
1142
|
+
}
|
|
890
1143
|
function isStaticJSXNode(node) {
|
|
891
|
-
if (
|
|
892
|
-
if (
|
|
893
|
-
return isStaticAttrs(node.openingElement
|
|
1144
|
+
if (node.type === "JSXElement" && node.openingElement?.selfClosing) return isStaticAttrs(node.openingElement.attributes ?? []);
|
|
1145
|
+
if (node.type === "JSXFragment") return (node.children ?? []).every(isStaticChild);
|
|
1146
|
+
if (node.type === "JSXElement") return isStaticAttrs(node.openingElement?.attributes ?? []) && (node.children ?? []).every(isStaticChild);
|
|
1147
|
+
return false;
|
|
894
1148
|
}
|
|
895
1149
|
function isStaticAttrs(attrs) {
|
|
896
|
-
return attrs.
|
|
897
|
-
if (
|
|
898
|
-
if (!prop.
|
|
899
|
-
if (
|
|
900
|
-
|
|
901
|
-
|
|
1150
|
+
return attrs.every((prop) => {
|
|
1151
|
+
if (prop.type !== "JSXAttribute") return false;
|
|
1152
|
+
if (!prop.value) return true;
|
|
1153
|
+
if (prop.value.type === "StringLiteral" || prop.value.type === "Literal" && typeof prop.value.value === "string") return true;
|
|
1154
|
+
if (prop.value.type === "JSXExpressionContainer") {
|
|
1155
|
+
const expr = prop.value.expression;
|
|
1156
|
+
if (!expr || expr.type === "JSXEmptyExpression") return true;
|
|
1157
|
+
return isStatic(expr);
|
|
1158
|
+
}
|
|
1159
|
+
return false;
|
|
902
1160
|
});
|
|
903
1161
|
}
|
|
904
1162
|
function isStaticChild(child) {
|
|
905
|
-
if (
|
|
906
|
-
if (
|
|
907
|
-
if (
|
|
908
|
-
if (
|
|
909
|
-
|
|
910
|
-
|
|
1163
|
+
if (child.type === "JSXText") return true;
|
|
1164
|
+
if (child.type === "JSXElement") return isStaticJSXNode(child);
|
|
1165
|
+
if (child.type === "JSXFragment") return isStaticJSXNode(child);
|
|
1166
|
+
if (child.type === "JSXExpressionContainer") {
|
|
1167
|
+
const expr = child.expression;
|
|
1168
|
+
if (!expr || expr.type === "JSXEmptyExpression") return true;
|
|
1169
|
+
return isStatic(expr);
|
|
1170
|
+
}
|
|
1171
|
+
return false;
|
|
911
1172
|
}
|
|
912
1173
|
function isStatic(node) {
|
|
913
|
-
|
|
1174
|
+
if (node.type === "Literal") return true;
|
|
1175
|
+
if (node.type === "StringLiteral" || node.type === "NumericLiteral" || node.type === "BooleanLiteral" || node.type === "NullLiteral") return true;
|
|
1176
|
+
if (node.type === "TemplateLiteral" && (node.expressions?.length ?? 0) === 0) return true;
|
|
1177
|
+
return false;
|
|
914
1178
|
}
|
|
915
|
-
/** Known pure global functions that don't read signals. */
|
|
916
1179
|
const PURE_CALLS = new Set([
|
|
917
1180
|
"Math.max",
|
|
918
1181
|
"Math.min",
|
|
@@ -952,23 +1215,13 @@ const PURE_CALLS = new Set([
|
|
|
952
1215
|
"decodeURI",
|
|
953
1216
|
"Date.now"
|
|
954
1217
|
]);
|
|
955
|
-
/** Check if a call expression calls a known pure function with static args. */
|
|
956
1218
|
function isPureStaticCall(node) {
|
|
957
|
-
const callee = node.
|
|
1219
|
+
const callee = node.callee;
|
|
958
1220
|
let name = "";
|
|
959
|
-
if (
|
|
960
|
-
else if (
|
|
1221
|
+
if (callee?.type === "Identifier") name = callee.name;
|
|
1222
|
+
else if (callee?.type === "MemberExpression" && !callee.computed && callee.object?.type === "Identifier" && callee.property?.type === "Identifier") name = `${callee.object.name}.${callee.property.name}`;
|
|
961
1223
|
if (!PURE_CALLS.has(name)) return false;
|
|
962
|
-
return node.arguments.every((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;
|
|
1224
|
+
return (node.arguments ?? []).every((arg) => arg.type !== "SpreadElement" && isStatic(arg));
|
|
972
1225
|
}
|
|
973
1226
|
|
|
974
1227
|
//#endregion
|
|
@@ -1339,7 +1592,7 @@ function detectJsxAttributes(ctx, node) {
|
|
|
1339
1592
|
if (attrName === "onChange") {
|
|
1340
1593
|
const jsxElement = findParentJsxElement(node);
|
|
1341
1594
|
if (jsxElement) {
|
|
1342
|
-
const tagName = getJsxTagName(jsxElement);
|
|
1595
|
+
const tagName = getJsxTagName$1(jsxElement);
|
|
1343
1596
|
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
1597
|
}
|
|
1345
1598
|
}
|
|
@@ -1562,7 +1815,7 @@ function migrateJsxAttributes(ctx, node) {
|
|
|
1562
1815
|
if (attrName === "onChange") {
|
|
1563
1816
|
const jsxElement = findParentJsxElement(node);
|
|
1564
1817
|
if (jsxElement) {
|
|
1565
|
-
const tagName = getJsxTagName(jsxElement);
|
|
1818
|
+
const tagName = getJsxTagName$1(jsxElement);
|
|
1566
1819
|
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
|
|
1567
1820
|
ctx.replacements.push({
|
|
1568
1821
|
start: node.name.getStart(ctx.sf),
|
|
@@ -1694,7 +1947,7 @@ function findParentJsxElement(node) {
|
|
|
1694
1947
|
}
|
|
1695
1948
|
return null;
|
|
1696
1949
|
}
|
|
1697
|
-
function getJsxTagName(node) {
|
|
1950
|
+
function getJsxTagName$1(node) {
|
|
1698
1951
|
const tagName = node.tagName;
|
|
1699
1952
|
if (ts.isIdentifier(tagName)) return tagName.text;
|
|
1700
1953
|
return "";
|
|
@@ -1805,5 +2058,673 @@ function diagnoseError(error) {
|
|
|
1805
2058
|
}
|
|
1806
2059
|
|
|
1807
2060
|
//#endregion
|
|
1808
|
-
|
|
2061
|
+
//#region src/pyreon-intercept.ts
|
|
2062
|
+
/**
|
|
2063
|
+
* Pyreon Pattern Interceptor — detects Pyreon-specific anti-patterns in
|
|
2064
|
+
* code that has ALREADY committed to the framework (imports are Pyreon,
|
|
2065
|
+
* not React). Complements `react-intercept.ts` — the React detector
|
|
2066
|
+
* catches "coming from React" mistakes; this one catches "using Pyreon
|
|
2067
|
+
* wrong" mistakes.
|
|
2068
|
+
*
|
|
2069
|
+
* Catalog of detected patterns (grounded in `.claude/rules/anti-patterns.md`):
|
|
2070
|
+
*
|
|
2071
|
+
* - `for-missing-by` — `<For each={...}>` without a `by` prop
|
|
2072
|
+
* - `for-with-key` — `<For key={...}>` (JSX reserves `key`; the keying
|
|
2073
|
+
* prop is `by` in Pyreon)
|
|
2074
|
+
* - `props-destructured` — `({ foo }: Props) => <JSX />` destructures at
|
|
2075
|
+
* the component signature; reading is captured once
|
|
2076
|
+
* and loses reactivity. Access `props.foo` instead
|
|
2077
|
+
* or use `splitProps(props, [...])`.
|
|
2078
|
+
* - `process-dev-gate` — `typeof process !== 'undefined' &&
|
|
2079
|
+
* process.env.NODE_ENV !== 'production'` is dead
|
|
2080
|
+
* code in real Vite browser bundles. Use
|
|
2081
|
+
* `import.meta.env?.DEV` instead.
|
|
2082
|
+
* - `empty-theme` — `.theme({})` chain is a no-op; remove it.
|
|
2083
|
+
* - `raw-add-event-listener` — raw `addEventListener(...)` in a component
|
|
2084
|
+
* or hook body. Use `useEventListener(...)` from
|
|
2085
|
+
* `@pyreon/hooks` for auto-cleanup.
|
|
2086
|
+
* - `raw-remove-event-listener` — same, for removeEventListener.
|
|
2087
|
+
* - `date-math-random-id` — `Date.now() + Math.random()` / template-concat
|
|
2088
|
+
* variants. Under rapid operations (paste, clone)
|
|
2089
|
+
* collision probability is non-trivial. Use a
|
|
2090
|
+
* monotonic counter.
|
|
2091
|
+
* - `on-click-undefined` — `onClick={undefined}` explicitly; the runtime
|
|
2092
|
+
* used to crash on this pattern. Omit the prop.
|
|
2093
|
+
* - `signal-write-as-call` — `sig(value)` is a no-op read that ignores
|
|
2094
|
+
* its argument; the runtime warns in dev. Static
|
|
2095
|
+
* detector spots it pre-runtime when `sig` was
|
|
2096
|
+
* declared as `const sig = signal(...)` /
|
|
2097
|
+
* `computed(...)` and called with ≥1 argument.
|
|
2098
|
+
* - `static-return-null-conditional` — `if (cond) return null` at the
|
|
2099
|
+
* top of a component body runs ONCE; signal changes
|
|
2100
|
+
* in `cond` never re-evaluate the early-return.
|
|
2101
|
+
* Wrap in a returned reactive accessor.
|
|
2102
|
+
* - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
|
|
2103
|
+
* cast on JSX returns is unnecessary (`JSX.Element`
|
|
2104
|
+
* is already assignable to `VNodeChild`).
|
|
2105
|
+
*
|
|
2106
|
+
* Two-mode surface mirrors `react-intercept.ts`:
|
|
2107
|
+
* - `detectPyreonPatterns(code)` — diagnostics only
|
|
2108
|
+
* - `hasPyreonPatterns(code)` — fast regex pre-filter
|
|
2109
|
+
*
|
|
2110
|
+
* ## fixable: false (invariant)
|
|
2111
|
+
*
|
|
2112
|
+
* Every Pyreon diagnostic reports `fixable: false` — no exceptions.
|
|
2113
|
+
* The `migrate_react` MCP tool only knows React mappings, so claiming
|
|
2114
|
+
* a Pyreon code is auto-fixable would mislead a consumer who wires
|
|
2115
|
+
* their UX off the flag and finds nothing applies the fix. Flip to
|
|
2116
|
+
* `true` ONLY when a companion `migrate_pyreon` tool ships in a
|
|
2117
|
+
* subsequent PR. The invariant is locked in
|
|
2118
|
+
* `tests/pyreon-intercept.test.ts` under "fixable contract".
|
|
2119
|
+
*
|
|
2120
|
+
* Designed for three consumers:
|
|
2121
|
+
* 1. Compiler pre-pass warnings during build
|
|
2122
|
+
* 2. CLI `pyreon doctor`
|
|
2123
|
+
* 3. MCP server `validate` tool
|
|
2124
|
+
*/
|
|
2125
|
+
function getNodeText(ctx, node) {
|
|
2126
|
+
return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
|
|
2127
|
+
}
|
|
2128
|
+
function pushDiag(ctx, node, code, message, current, suggested, fixable) {
|
|
2129
|
+
const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf));
|
|
2130
|
+
ctx.diagnostics.push({
|
|
2131
|
+
code,
|
|
2132
|
+
message,
|
|
2133
|
+
line: line + 1,
|
|
2134
|
+
column: character,
|
|
2135
|
+
current: current.trim(),
|
|
2136
|
+
suggested: suggested.trim(),
|
|
2137
|
+
fixable
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
function getJsxTagName(node) {
|
|
2141
|
+
const t = node.tagName;
|
|
2142
|
+
if (ts.isIdentifier(t)) return t.text;
|
|
2143
|
+
return "";
|
|
2144
|
+
}
|
|
2145
|
+
function findJsxAttribute(node, name) {
|
|
2146
|
+
for (const attr of node.attributes.properties) if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === name) return attr;
|
|
2147
|
+
}
|
|
2148
|
+
function detectForKeying(ctx, node) {
|
|
2149
|
+
if (getJsxTagName(node) !== "For") return;
|
|
2150
|
+
const keyAttr = findJsxAttribute(node, "key");
|
|
2151
|
+
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);
|
|
2152
|
+
const eachAttr = findJsxAttribute(node, "each");
|
|
2153
|
+
const byAttr = findJsxAttribute(node, "by");
|
|
2154
|
+
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);
|
|
2155
|
+
}
|
|
2156
|
+
function containsJsx(node) {
|
|
2157
|
+
let found = false;
|
|
2158
|
+
function walk(n) {
|
|
2159
|
+
if (found) return;
|
|
2160
|
+
if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n) || ts.isJsxOpeningElement(n)) {
|
|
2161
|
+
found = true;
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
ts.forEachChild(n, walk);
|
|
2165
|
+
}
|
|
2166
|
+
ts.forEachChild(node, walk);
|
|
2167
|
+
if (!found) {
|
|
2168
|
+
if (ts.isArrowFunction(node) && !ts.isBlock(node.body) && (ts.isJsxElement(node.body) || ts.isJsxSelfClosingElement(node.body) || ts.isJsxFragment(node.body))) found = true;
|
|
2169
|
+
}
|
|
2170
|
+
return found;
|
|
2171
|
+
}
|
|
2172
|
+
function detectPropsDestructured(ctx, node) {
|
|
2173
|
+
if (!node.parameters.length) return;
|
|
2174
|
+
const first = node.parameters[0];
|
|
2175
|
+
if (!first || !ts.isObjectBindingPattern(first.name)) return;
|
|
2176
|
+
if (first.name.elements.length === 0) return;
|
|
2177
|
+
if (!containsJsx(node)) return;
|
|
2178
|
+
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);
|
|
2179
|
+
}
|
|
2180
|
+
function isTypeofProcess(node) {
|
|
2181
|
+
if (!ts.isBinaryExpression(node)) return false;
|
|
2182
|
+
if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false;
|
|
2183
|
+
if (!ts.isTypeOfExpression(node.left)) return false;
|
|
2184
|
+
if (!ts.isIdentifier(node.left.expression) || node.left.expression.text !== "process") return false;
|
|
2185
|
+
return ts.isStringLiteral(node.right) && node.right.text === "undefined";
|
|
2186
|
+
}
|
|
2187
|
+
function isProcessNodeEnvProdGuard(node) {
|
|
2188
|
+
if (!ts.isBinaryExpression(node)) return false;
|
|
2189
|
+
if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false;
|
|
2190
|
+
const left = node.left;
|
|
2191
|
+
if (!ts.isPropertyAccessExpression(left)) return false;
|
|
2192
|
+
if (!ts.isIdentifier(left.name) || left.name.text !== "NODE_ENV") return false;
|
|
2193
|
+
if (!ts.isPropertyAccessExpression(left.expression)) return false;
|
|
2194
|
+
if (!ts.isIdentifier(left.expression.name) || left.expression.name.text !== "env") return false;
|
|
2195
|
+
if (!ts.isIdentifier(left.expression.expression)) return false;
|
|
2196
|
+
if (left.expression.expression.text !== "process") return false;
|
|
2197
|
+
return ts.isStringLiteral(node.right) && node.right.text === "production";
|
|
2198
|
+
}
|
|
2199
|
+
function detectProcessDevGate(ctx, node) {
|
|
2200
|
+
if (node.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) return;
|
|
2201
|
+
if (!(isTypeofProcess(node.left) && isProcessNodeEnvProdGuard(node.right) || isTypeofProcess(node.right) && isProcessNodeEnvProdGuard(node.left))) return;
|
|
2202
|
+
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);
|
|
2203
|
+
}
|
|
2204
|
+
function detectEmptyTheme(ctx, node) {
|
|
2205
|
+
const callee = node.expression;
|
|
2206
|
+
if (!ts.isPropertyAccessExpression(callee)) return;
|
|
2207
|
+
if (!ts.isIdentifier(callee.name) || callee.name.text !== "theme") return;
|
|
2208
|
+
if (node.arguments.length !== 1) return;
|
|
2209
|
+
const arg = node.arguments[0];
|
|
2210
|
+
if (!arg || !ts.isObjectLiteralExpression(arg)) return;
|
|
2211
|
+
if (arg.properties.length !== 0) return;
|
|
2212
|
+
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);
|
|
2213
|
+
}
|
|
2214
|
+
function detectRawEventListener(ctx, node) {
|
|
2215
|
+
const callee = node.expression;
|
|
2216
|
+
if (!ts.isPropertyAccessExpression(callee)) return;
|
|
2217
|
+
if (!ts.isIdentifier(callee.name)) return;
|
|
2218
|
+
const method = callee.name.text;
|
|
2219
|
+
if (method !== "addEventListener" && method !== "removeEventListener") return;
|
|
2220
|
+
const target = callee.expression;
|
|
2221
|
+
const targetName = ts.isIdentifier(target) ? target.text : ts.isPropertyAccessExpression(target) && ts.isIdentifier(target.name) ? target.name.text : "";
|
|
2222
|
+
if (!new Set([
|
|
2223
|
+
"window",
|
|
2224
|
+
"document",
|
|
2225
|
+
"body",
|
|
2226
|
+
"el",
|
|
2227
|
+
"element",
|
|
2228
|
+
"node",
|
|
2229
|
+
"target"
|
|
2230
|
+
]).has(targetName)) return;
|
|
2231
|
+
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);
|
|
2232
|
+
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);
|
|
2233
|
+
}
|
|
2234
|
+
function isCallTo(node, object, method) {
|
|
2235
|
+
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;
|
|
2236
|
+
}
|
|
2237
|
+
function subtreeHas(node, predicate) {
|
|
2238
|
+
let found = false;
|
|
2239
|
+
function walk(n) {
|
|
2240
|
+
if (found) return;
|
|
2241
|
+
if (predicate(n)) {
|
|
2242
|
+
found = true;
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
ts.forEachChild(n, walk);
|
|
2246
|
+
}
|
|
2247
|
+
walk(node);
|
|
2248
|
+
return found;
|
|
2249
|
+
}
|
|
2250
|
+
function detectDateMathRandomId(ctx, node) {
|
|
2251
|
+
if (!subtreeHas(node, (n) => isCallTo(n, "Date", "now"))) return;
|
|
2252
|
+
if (!subtreeHas(node, (n) => isCallTo(n, "Math", "random"))) return;
|
|
2253
|
+
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);
|
|
2254
|
+
}
|
|
2255
|
+
function detectOnClickUndefined(ctx, node) {
|
|
2256
|
+
if (!ts.isIdentifier(node.name)) return;
|
|
2257
|
+
const attrName = node.name.text;
|
|
2258
|
+
if (!attrName.startsWith("on") || attrName.length < 3) return;
|
|
2259
|
+
if (!node.initializer || !ts.isJsxExpression(node.initializer)) return;
|
|
2260
|
+
const expr = node.initializer.expression;
|
|
2261
|
+
if (!expr) return;
|
|
2262
|
+
if (!(ts.isIdentifier(expr) && expr.text === "undefined" || expr.kind === ts.SyntaxKind.VoidExpression)) return;
|
|
2263
|
+
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);
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Walks the file and collects every identifier bound to a `signal(...)` or
|
|
2267
|
+
* `computed(...)` call. Only `const` declarations are tracked — `let`/`var`
|
|
2268
|
+
* may be reassigned to non-signal values, so a use-site call wouldn't be a
|
|
2269
|
+
* reliable signal-write.
|
|
2270
|
+
*
|
|
2271
|
+
* The collection is intentionally scope-blind: a name shadowed in a nested
|
|
2272
|
+
* scope (`const x = signal(0); function f() { const x = 5; x(7) }`) would
|
|
2273
|
+
* produce a false positive on `x(7)`. That tradeoff is acceptable because
|
|
2274
|
+
* (1) shadowing a signal name with a non-signal is itself unusual and
|
|
2275
|
+
* (2) the detector message points at exactly the wrong-shape call so a
|
|
2276
|
+
* human reviewer can dismiss the rare false positive in seconds.
|
|
2277
|
+
*/
|
|
2278
|
+
function collectSignalBindings(sf) {
|
|
2279
|
+
const names = /* @__PURE__ */ new Set();
|
|
2280
|
+
function isSignalFactoryCall(init) {
|
|
2281
|
+
if (!init || !ts.isCallExpression(init)) return false;
|
|
2282
|
+
const callee = init.expression;
|
|
2283
|
+
if (!ts.isIdentifier(callee)) return false;
|
|
2284
|
+
return callee.text === "signal" || callee.text === "computed";
|
|
2285
|
+
}
|
|
2286
|
+
function walk(node) {
|
|
2287
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
2288
|
+
const list = node.parent;
|
|
2289
|
+
if (ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && isSignalFactoryCall(node.initializer)) names.add(node.name.text);
|
|
2290
|
+
}
|
|
2291
|
+
ts.forEachChild(node, walk);
|
|
2292
|
+
}
|
|
2293
|
+
walk(sf);
|
|
2294
|
+
return names;
|
|
2295
|
+
}
|
|
2296
|
+
function detectSignalWriteAsCall(ctx, node) {
|
|
2297
|
+
if (ctx.signalBindings.size === 0) return;
|
|
2298
|
+
const callee = node.expression;
|
|
2299
|
+
if (!ts.isIdentifier(callee)) return;
|
|
2300
|
+
if (!ctx.signalBindings.has(callee.text)) return;
|
|
2301
|
+
if (node.arguments.length === 0) return;
|
|
2302
|
+
pushDiag(ctx, node, "signal-write-as-call", `\`${callee.text}(value)\` does NOT write the signal — \`signal()\` is the read-only callable surface and ignores its arguments. Use \`${callee.text}.set(value)\` to assign or \`${callee.text}.update((prev) => …)\` to derive from the previous value. Pyreon's runtime warns about this pattern in dev, but the warning fires AFTER the silent no-op.`, getNodeText(ctx, node), `${callee.text}.set(${node.arguments.map((a) => getNodeText(ctx, a)).join(", ")})`, false);
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* `if (cond) return null` at the top of a component body runs ONCE — Pyreon
|
|
2306
|
+
* components mount and never re-execute their function bodies. A signal
|
|
2307
|
+
* change inside `cond` therefore never re-evaluates the condition; the
|
|
2308
|
+
* component is permanently stuck on whichever branch the first run picked.
|
|
2309
|
+
*
|
|
2310
|
+
* The fix is to wrap the conditional in a returned reactive accessor:
|
|
2311
|
+
* return (() => { if (!cond()) return null; return <div /> })
|
|
2312
|
+
*
|
|
2313
|
+
* Detection:
|
|
2314
|
+
* - The function contains JSX (i.e. it's a component)
|
|
2315
|
+
* - The function body has an `IfStatement` whose `thenStatement` is
|
|
2316
|
+
* `return null` (either bare `return null` or `{ return null }`)
|
|
2317
|
+
* - The `if` is at the function body's top level, NOT inside a returned
|
|
2318
|
+
* arrow / IIFE (those are reactive scopes — flagging them would be a
|
|
2319
|
+
* false positive)
|
|
2320
|
+
*/
|
|
2321
|
+
function returnsNullStatement(stmt) {
|
|
2322
|
+
if (ts.isReturnStatement(stmt)) {
|
|
2323
|
+
const expr = stmt.expression;
|
|
2324
|
+
return !!expr && expr.kind === ts.SyntaxKind.NullKeyword;
|
|
2325
|
+
}
|
|
2326
|
+
if (ts.isBlock(stmt)) return stmt.statements.length === 1 && returnsNullStatement(stmt.statements[0]);
|
|
2327
|
+
return false;
|
|
2328
|
+
}
|
|
2329
|
+
/**
|
|
2330
|
+
* Returns true if the function looks like a top-level component:
|
|
2331
|
+
* - `function PascalName(...) { ... }` (FunctionDeclaration with PascalCase id), OR
|
|
2332
|
+
* - `const PascalName = (...) => { ... }` (arrow inside a VariableDeclaration whose name is PascalCase).
|
|
2333
|
+
*
|
|
2334
|
+
* Anonymous nested arrows — most importantly the reactive accessor
|
|
2335
|
+
* `return (() => { if (!cond()) return null; return <div /> })` — are
|
|
2336
|
+
* NOT considered components here, even when they contain JSX. Without
|
|
2337
|
+
* this filter the detector would fire on the very pattern the
|
|
2338
|
+
* diagnostic recommends as the fix.
|
|
2339
|
+
*/
|
|
2340
|
+
function isComponentShapedFunction(node) {
|
|
2341
|
+
if (ts.isFunctionDeclaration(node)) return !!node.name && /^[A-Z]/.test(node.name.text);
|
|
2342
|
+
const parent = node.parent;
|
|
2343
|
+
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return /^[A-Z]/.test(parent.name.text);
|
|
2344
|
+
return false;
|
|
2345
|
+
}
|
|
2346
|
+
function detectStaticReturnNullConditional(ctx, node) {
|
|
2347
|
+
if (!isComponentShapedFunction(node)) return;
|
|
2348
|
+
if (!containsJsx(node)) return;
|
|
2349
|
+
const body = node.body;
|
|
2350
|
+
if (!body || !ts.isBlock(body)) return;
|
|
2351
|
+
for (const stmt of body.statements) {
|
|
2352
|
+
if (!ts.isIfStatement(stmt)) continue;
|
|
2353
|
+
if (!returnsNullStatement(stmt.thenStatement)) continue;
|
|
2354
|
+
pushDiag(ctx, stmt, "static-return-null-conditional", "Pyreon components run ONCE — `if (cond) return null` at the top of a component body is evaluated exactly once at mount. Reading a signal inside `cond` will NOT re-trigger the early return when the signal changes; the component is stuck on whichever branch the first run picked. Wrap the conditional in a returned reactive accessor: `return (() => { if (!cond()) return null; return <div /> })` — the accessor re-runs whenever its tracked signals change.", getNodeText(ctx, stmt), "return (() => { if (!cond()) return null; return <JSX /> })", false);
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
/**
|
|
2359
|
+
* `JSX.Element` (which is what JSX evaluates to) is already assignable to
|
|
2360
|
+
* `VNodeChild`. The `as unknown as VNodeChild` double-cast is unnecessary
|
|
2361
|
+
* — it's been showing up in `@pyreon/ui-primitives` as a defensive habit
|
|
2362
|
+
* carried over from earlier framework versions. The cast is never load-
|
|
2363
|
+
* bearing today; removing it never changes runtime behavior. Pure cosmetic
|
|
2364
|
+
* but a useful proxy for non-idiomatic Pyreon code in primitives.
|
|
2365
|
+
*/
|
|
2366
|
+
function detectAsUnknownAsVNodeChild(ctx, node) {
|
|
2367
|
+
const outerType = node.type;
|
|
2368
|
+
if (!ts.isTypeReferenceNode(outerType)) return;
|
|
2369
|
+
if (!ts.isIdentifier(outerType.typeName) || outerType.typeName.text !== "VNodeChild") return;
|
|
2370
|
+
const inner = node.expression;
|
|
2371
|
+
if (!ts.isAsExpression(inner)) return;
|
|
2372
|
+
if (inner.type.kind !== ts.SyntaxKind.UnknownKeyword) return;
|
|
2373
|
+
pushDiag(ctx, node, "as-unknown-as-vnodechild", "`as unknown as VNodeChild` is unnecessary — `JSX.Element` (the type produced by JSX) is already assignable to `VNodeChild`. Remove the double cast; it is pure noise that hides genuine type issues if they ever appear at this site.", getNodeText(ctx, node), getNodeText(ctx, inner.expression), false);
|
|
2374
|
+
}
|
|
2375
|
+
function visitNode(ctx, node) {
|
|
2376
|
+
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) detectForKeying(ctx, node);
|
|
2377
|
+
if (ts.isArrowFunction(node) || ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) {
|
|
2378
|
+
detectPropsDestructured(ctx, node);
|
|
2379
|
+
detectStaticReturnNullConditional(ctx, node);
|
|
2380
|
+
}
|
|
2381
|
+
if (ts.isBinaryExpression(node)) {
|
|
2382
|
+
detectProcessDevGate(ctx, node);
|
|
2383
|
+
detectDateMathRandomId(ctx, node);
|
|
2384
|
+
}
|
|
2385
|
+
if (ts.isTemplateExpression(node)) detectDateMathRandomId(ctx, node);
|
|
2386
|
+
if (ts.isCallExpression(node)) {
|
|
2387
|
+
detectEmptyTheme(ctx, node);
|
|
2388
|
+
detectRawEventListener(ctx, node);
|
|
2389
|
+
detectSignalWriteAsCall(ctx, node);
|
|
2390
|
+
}
|
|
2391
|
+
if (ts.isJsxAttribute(node)) detectOnClickUndefined(ctx, node);
|
|
2392
|
+
if (ts.isAsExpression(node)) detectAsUnknownAsVNodeChild(ctx, node);
|
|
2393
|
+
}
|
|
2394
|
+
function visit(ctx, node) {
|
|
2395
|
+
ts.forEachChild(node, (child) => {
|
|
2396
|
+
visitNode(ctx, child);
|
|
2397
|
+
visit(ctx, child);
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
function detectPyreonPatterns(code, filename = "input.tsx") {
|
|
2401
|
+
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
|
|
2402
|
+
const ctx = {
|
|
2403
|
+
sf,
|
|
2404
|
+
code,
|
|
2405
|
+
diagnostics: [],
|
|
2406
|
+
signalBindings: collectSignalBindings(sf)
|
|
2407
|
+
};
|
|
2408
|
+
visit(ctx, sf);
|
|
2409
|
+
ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
2410
|
+
return ctx.diagnostics;
|
|
2411
|
+
}
|
|
2412
|
+
/** Fast regex pre-filter — returns true if the code is worth a full AST walk. */
|
|
2413
|
+
function hasPyreonPatterns(code) {
|
|
2414
|
+
return /\bFor\b[^=]*\beach\s*=/.test(code) || /\btypeof\s+process\b/.test(code) || /\.theme\s*\(\s*\{\s*\}\s*\)/.test(code) || /\b(?:add|remove)EventListener\s*\(/.test(code) || /\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code) || /on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) || /=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code) || /\b(?:signal|computed)\s*[<(]/.test(code) || /\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) || /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code);
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
//#endregion
|
|
2418
|
+
//#region src/test-audit.ts
|
|
2419
|
+
/**
|
|
2420
|
+
* Test-environment audit for the `audit_test_environment` MCP tool (T2.5.7).
|
|
2421
|
+
*
|
|
2422
|
+
* Scans `*.test.ts` / `*.test.tsx` files under the `packages` tree
|
|
2423
|
+
* for **mock-vnode patterns** — tests that construct `{ type, props,
|
|
2424
|
+
* children }` object literals (or a custom `vnode(...)` helper) in
|
|
2425
|
+
* place of going through the real `h()` from `@pyreon/core`. This
|
|
2426
|
+
* class of pattern silently drops rocketstyle / compiler / attrs
|
|
2427
|
+
* work from the pipeline, letting bugs through that production
|
|
2428
|
+
* would hit immediately (see PR #197 silent metadata drop).
|
|
2429
|
+
*
|
|
2430
|
+
* The scanner does NOT run the tests or parse TypeScript — a fast
|
|
2431
|
+
* regex pass is intentional. Accuracy trades for speed: the false-
|
|
2432
|
+
* positive rate is low because the `{ type: ..., props: ...,
|
|
2433
|
+
* children: ... }` shape is unusual outside of vnode construction.
|
|
2434
|
+
*
|
|
2435
|
+
* Output classification:
|
|
2436
|
+
* HIGH — mock patterns present, no real `h()` calls and no `h`
|
|
2437
|
+
* import from `@pyreon/core`. Most at risk: the file has
|
|
2438
|
+
* no pathway to exercise the real pipeline.
|
|
2439
|
+
* MEDIUM — mock patterns present, some real `h()` usage — but the
|
|
2440
|
+
* mock count is still notable, so a parallel real-`h()`
|
|
2441
|
+
* test may be missing for specific scenarios.
|
|
2442
|
+
* LOW — either no mocks, or mock count is dwarfed by real usage.
|
|
2443
|
+
*
|
|
2444
|
+
* Companion to the `validate` and `get_anti_patterns` tools: those
|
|
2445
|
+
* tell an agent what to write; this one tells an agent which existing
|
|
2446
|
+
* tests need strengthening.
|
|
2447
|
+
*/
|
|
2448
|
+
function findMonorepoRoot(startDir) {
|
|
2449
|
+
let dir = resolve(startDir);
|
|
2450
|
+
for (let i = 0; i < 30; i++) {
|
|
2451
|
+
try {
|
|
2452
|
+
if (statSync(join(dir, "packages")).isDirectory()) return dir;
|
|
2453
|
+
} catch {}
|
|
2454
|
+
const parent = dirname(dir);
|
|
2455
|
+
if (parent === dir) return null;
|
|
2456
|
+
dir = parent;
|
|
2457
|
+
}
|
|
2458
|
+
return null;
|
|
2459
|
+
}
|
|
2460
|
+
function walkTestFiles(dir, out, depth = 0) {
|
|
2461
|
+
if (depth > 10) return;
|
|
2462
|
+
let entries;
|
|
2463
|
+
try {
|
|
2464
|
+
entries = readdirSync(dir);
|
|
2465
|
+
} catch {
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
for (const name of entries) {
|
|
2469
|
+
if (name.startsWith(".")) continue;
|
|
2470
|
+
if (name === "node_modules" || name === "lib" || name === "dist") continue;
|
|
2471
|
+
const full = join(dir, name);
|
|
2472
|
+
let isDir = false;
|
|
2473
|
+
try {
|
|
2474
|
+
isDir = statSync(full).isDirectory();
|
|
2475
|
+
} catch {
|
|
2476
|
+
continue;
|
|
2477
|
+
}
|
|
2478
|
+
if (isDir) {
|
|
2479
|
+
walkTestFiles(full, out, depth + 1);
|
|
2480
|
+
continue;
|
|
2481
|
+
}
|
|
2482
|
+
if (/\.test\.(ts|tsx)$/.test(name)) out.push(full);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* Matches an object literal carrying `type`, `props`, AND `children`
|
|
2487
|
+
* keys — the canonical mock-vnode shape. The `s` flag spans newlines
|
|
2488
|
+
* because vnode literals often wrap across multiple lines.
|
|
2489
|
+
*/
|
|
2490
|
+
const MOCK_VNODE_LITERAL_PATTERN = /\{\s*type\s*:[^{}]*?(?:(?:\{[^{}]*\})[^{}]*?)*?props\s*:[^{}]*?(?:(?:\{[^{}]*\})[^{}]*?)*?children\s*:[^{}]*?(?:(?:\{[^{}]*\})[^{}]*?)*?\}/gs;
|
|
2491
|
+
/**
|
|
2492
|
+
* Matches a helper definition that produces a mock vnode. Recognises:
|
|
2493
|
+
* const vnode = (...) => ({ type, props, children })
|
|
2494
|
+
* const mockVNode = ({ type, props, children })
|
|
2495
|
+
* function createVNode(type, props, children)
|
|
2496
|
+
*
|
|
2497
|
+
* Does NOT match bindings that merely STORE a real VNode with a
|
|
2498
|
+
* `vnode`-like name, which are common in component tests:
|
|
2499
|
+
* const vnode = defaultRender(...) // real render result
|
|
2500
|
+
* const vnode = <span>cell content</span> // real JSX expression
|
|
2501
|
+
* const vnode = h('div', null, 'x') // real h() call
|
|
2502
|
+
*
|
|
2503
|
+
* Distinguisher: a mock helper definition either
|
|
2504
|
+
* (a) starts an arrow function / function — RHS begins with `(` or the
|
|
2505
|
+
* keyword `function`, OR
|
|
2506
|
+
* (b) is itself an inline object literal — RHS begins with `{`.
|
|
2507
|
+
* `const vnode = <anything else>` is a binding, not a definition.
|
|
2508
|
+
*/
|
|
2509
|
+
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;
|
|
2510
|
+
/**
|
|
2511
|
+
* Matches CALLS to a known mock-helper name:
|
|
2512
|
+
* vnode('div', props, children)
|
|
2513
|
+
* mockVNode(Component, props)
|
|
2514
|
+
* createVNode(...)
|
|
2515
|
+
*
|
|
2516
|
+
* Non-word boundary before the name avoids hits inside other
|
|
2517
|
+
* identifiers (`hasVNode`, `myVnodeImpl`). The helper-def pattern
|
|
2518
|
+
* above ALSO matches definitions' own `<name>(` arg list, so the
|
|
2519
|
+
* caller should subtract definition count from call count to get
|
|
2520
|
+
* usage-only density — but for risk classification, the combined
|
|
2521
|
+
* signal (any mock-helper activity) is what we want.
|
|
2522
|
+
*/
|
|
2523
|
+
const MOCK_HELPER_CALL_PATTERN = /(?:^|[^a-zA-Z0-9_])(?:mockV[Nn]ode|vnode|createV[Nn]ode|V[Nn]odeMock|makeV[Nn]ode)\s*\(/g;
|
|
2524
|
+
/**
|
|
2525
|
+
* Matches calls to `h(…)` where the first arg is an uppercase
|
|
2526
|
+
* identifier (component) or a lowercase string tag — the two real
|
|
2527
|
+
* shapes. Avoids matching:
|
|
2528
|
+
* hasSomething(...) — h followed by [a-z]
|
|
2529
|
+
* ch() — single h as substring of another name
|
|
2530
|
+
* hash() — same
|
|
2531
|
+
* The `(?:^|\W)` boundary plus `[A-Z'"\s]` arg requirement handles both.
|
|
2532
|
+
*/
|
|
2533
|
+
const REAL_H_CALL_PATTERN = /(?:^|\W)h\s*\(\s*["'A-Z]/g;
|
|
2534
|
+
const IMPORT_H_PATTERN = /import\s*(?:type\s*)?\{[^}]*\bh\b[^}]*\}\s*from\s*['"]@pyreon\/core['"]/;
|
|
2535
|
+
/**
|
|
2536
|
+
* Predicate: does the `{type, props, children}` literal at this
|
|
2537
|
+
* position appear as an argument to a type-guard-like call
|
|
2538
|
+
* (`isDocNode(...)`, `hasVNode(...)`, `assertVNode(...)`, etc.)?
|
|
2539
|
+
*
|
|
2540
|
+
* Type guards take any object shape and return boolean — passing a
|
|
2541
|
+
* `{type, props, children}` literal there is testing the guard's
|
|
2542
|
+
* duck-typing, not building a mock vnode for a rendering pipeline.
|
|
2543
|
+
* False-positive coverage for `utils-coverage.test.ts` and similar.
|
|
2544
|
+
*/
|
|
2545
|
+
function isLiteralInsideTypeGuardCall(source, literalStart) {
|
|
2546
|
+
const window = source.slice(Math.max(0, literalStart - 60), literalStart);
|
|
2547
|
+
let unmatched = 0;
|
|
2548
|
+
let openAt = -1;
|
|
2549
|
+
for (let i = window.length - 1; i >= 0; i--) {
|
|
2550
|
+
const ch = window[i];
|
|
2551
|
+
if (ch === ")") unmatched++;
|
|
2552
|
+
else if (ch === "(") {
|
|
2553
|
+
if (unmatched === 0) {
|
|
2554
|
+
openAt = i;
|
|
2555
|
+
break;
|
|
2556
|
+
}
|
|
2557
|
+
unmatched--;
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
if (openAt < 0) return false;
|
|
2561
|
+
const head = window.slice(0, openAt);
|
|
2562
|
+
return /\b(?:is|has|assert|validate|check)[A-Z]\w*\s*$/.test(head);
|
|
2563
|
+
}
|
|
2564
|
+
/**
|
|
2565
|
+
* Mask the inside of every backtick-delimited template-literal with
|
|
2566
|
+
* spaces. Preserves length so positions/lines/columns stay aligned.
|
|
2567
|
+
* Used to keep the literal scanner from counting `{type,props,children}`
|
|
2568
|
+
* patterns that live inside test FIXTURE strings (the `cli/doctor.test.ts`
|
|
2569
|
+
* case — those are fixtures for the audit tool itself, not actual code).
|
|
2570
|
+
*
|
|
2571
|
+
* Limitations: doesn't parse `${...}` interpolations precisely. If a
|
|
2572
|
+
* fixture contains a balanced `${ ... }` with code we'd want scanned,
|
|
2573
|
+
* the surrounding template string still masks it. In practice, mock-
|
|
2574
|
+
* vnode literals are never interpolation expressions, so this is fine.
|
|
2575
|
+
*/
|
|
2576
|
+
function maskTemplateStrings(source) {
|
|
2577
|
+
return source.replace(/`(?:\\.|[^`\\])*`/g, (m) => `\`${" ".repeat(m.length - 2)}\``);
|
|
2578
|
+
}
|
|
2579
|
+
function countMatches(source, pattern) {
|
|
2580
|
+
let count = 0;
|
|
2581
|
+
pattern.lastIndex = 0;
|
|
2582
|
+
while (pattern.exec(source) !== null) count++;
|
|
2583
|
+
pattern.lastIndex = 0;
|
|
2584
|
+
return count;
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* Counts `{type, props, children}` literals, skipping those that
|
|
2588
|
+
* appear inside a type-guard-looking call OR inside a template-literal
|
|
2589
|
+
* (which is fixture text, not code). Dedicated because the existing
|
|
2590
|
+
* `countMatches` helper has no context-aware skip.
|
|
2591
|
+
*/
|
|
2592
|
+
function countMockVNodeLiterals(source) {
|
|
2593
|
+
const masked = maskTemplateStrings(source);
|
|
2594
|
+
const pattern = MOCK_VNODE_LITERAL_PATTERN;
|
|
2595
|
+
let count = 0;
|
|
2596
|
+
pattern.lastIndex = 0;
|
|
2597
|
+
let m;
|
|
2598
|
+
while (true) {
|
|
2599
|
+
m = pattern.exec(masked);
|
|
2600
|
+
if (m === null) break;
|
|
2601
|
+
if (!isLiteralInsideTypeGuardCall(masked, m.index)) count++;
|
|
2602
|
+
}
|
|
2603
|
+
pattern.lastIndex = 0;
|
|
2604
|
+
return count;
|
|
2605
|
+
}
|
|
2606
|
+
function classifyRisk(entry) {
|
|
2607
|
+
const mocks = entry.mockVNodeLiteralCount + entry.mockHelperCount + entry.mockHelperCallCount;
|
|
2608
|
+
if (mocks === 0) return "low";
|
|
2609
|
+
if (!entry.importsH && entry.realHCallCount === 0) return "high";
|
|
2610
|
+
if (entry.realHCallCount >= mocks) return "low";
|
|
2611
|
+
return "medium";
|
|
2612
|
+
}
|
|
2613
|
+
function auditTestEnvironment(startDir) {
|
|
2614
|
+
const root = findMonorepoRoot(startDir);
|
|
2615
|
+
if (!root) return {
|
|
2616
|
+
root: null,
|
|
2617
|
+
entries: [],
|
|
2618
|
+
totalScanned: 0
|
|
2619
|
+
};
|
|
2620
|
+
const files = [];
|
|
2621
|
+
walkTestFiles(join(root, "packages"), files);
|
|
2622
|
+
const entries = [];
|
|
2623
|
+
for (const path of files) {
|
|
2624
|
+
let source;
|
|
2625
|
+
try {
|
|
2626
|
+
source = readFileSync(path, "utf8");
|
|
2627
|
+
} catch {
|
|
2628
|
+
continue;
|
|
2629
|
+
}
|
|
2630
|
+
if (path.includes("test-audit.test.ts") || path.includes("test-audit-fixture")) continue;
|
|
2631
|
+
const masked = maskTemplateStrings(source);
|
|
2632
|
+
const mockVNodeLiteralCount = countMockVNodeLiterals(source);
|
|
2633
|
+
const mockHelperCount = countMatches(masked, MOCK_HELPER_PATTERN);
|
|
2634
|
+
const mockHelperCallCount = countMatches(masked, MOCK_HELPER_CALL_PATTERN);
|
|
2635
|
+
const realHCallCount = countMatches(masked, REAL_H_CALL_PATTERN);
|
|
2636
|
+
const importsH = IMPORT_H_PATTERN.test(masked);
|
|
2637
|
+
const base = {
|
|
2638
|
+
path,
|
|
2639
|
+
relPath: relative(root, path),
|
|
2640
|
+
mockVNodeLiteralCount,
|
|
2641
|
+
mockHelperCount,
|
|
2642
|
+
mockHelperCallCount,
|
|
2643
|
+
realHCallCount,
|
|
2644
|
+
importsH
|
|
2645
|
+
};
|
|
2646
|
+
entries.push({
|
|
2647
|
+
...base,
|
|
2648
|
+
risk: classifyRisk(base)
|
|
2649
|
+
});
|
|
2650
|
+
}
|
|
2651
|
+
const riskRank = {
|
|
2652
|
+
high: 0,
|
|
2653
|
+
medium: 1,
|
|
2654
|
+
low: 2
|
|
2655
|
+
};
|
|
2656
|
+
entries.sort((a, b) => {
|
|
2657
|
+
const cmp = riskRank[a.risk] - riskRank[b.risk];
|
|
2658
|
+
if (cmp !== 0) return cmp;
|
|
2659
|
+
return a.relPath.localeCompare(b.relPath);
|
|
2660
|
+
});
|
|
2661
|
+
return {
|
|
2662
|
+
root,
|
|
2663
|
+
entries,
|
|
2664
|
+
totalScanned: files.length
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
function riskAtOrAbove(risk, min) {
|
|
2668
|
+
const rank = {
|
|
2669
|
+
high: 0,
|
|
2670
|
+
medium: 1,
|
|
2671
|
+
low: 2
|
|
2672
|
+
};
|
|
2673
|
+
return rank[risk] <= rank[min];
|
|
2674
|
+
}
|
|
2675
|
+
function formatTestAudit(result, { minRisk = "medium", limit = 20 } = {}) {
|
|
2676
|
+
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.";
|
|
2677
|
+
const relevant = result.entries.filter((e) => riskAtOrAbove(e.risk, minRisk));
|
|
2678
|
+
const counts = {
|
|
2679
|
+
high: result.entries.filter((e) => e.risk === "high").length,
|
|
2680
|
+
medium: result.entries.filter((e) => e.risk === "medium").length,
|
|
2681
|
+
low: result.entries.filter((e) => e.risk === "low").length
|
|
2682
|
+
};
|
|
2683
|
+
const withMocks = result.entries.filter((e) => e.mockVNodeLiteralCount + e.mockHelperCount > 0).length;
|
|
2684
|
+
const parts = [];
|
|
2685
|
+
parts.push(`# Test environment audit — ${result.totalScanned} test files scanned`);
|
|
2686
|
+
parts.push("");
|
|
2687
|
+
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.`);
|
|
2688
|
+
parts.push("");
|
|
2689
|
+
parts.push(`**Risk counts**: ${counts.high} high · ${counts.medium} medium · ${counts.low} low`);
|
|
2690
|
+
parts.push("");
|
|
2691
|
+
if (relevant.length === 0) {
|
|
2692
|
+
parts.push(`No files at risk level "${minRisk}" or above. Every test file either avoids mocks entirely or pairs them with real-\`h()\` coverage.`);
|
|
2693
|
+
return parts.join("\n");
|
|
2694
|
+
}
|
|
2695
|
+
const byRisk = /* @__PURE__ */ new Map();
|
|
2696
|
+
for (const entry of relevant) {
|
|
2697
|
+
if (!byRisk.has(entry.risk)) byRisk.set(entry.risk, []);
|
|
2698
|
+
byRisk.get(entry.risk).push(entry);
|
|
2699
|
+
}
|
|
2700
|
+
for (const [risk, group] of byRisk) {
|
|
2701
|
+
const shown = group.slice(0, limit);
|
|
2702
|
+
parts.push(`## ${risk.toUpperCase()} — ${group.length} file${group.length === 1 ? "" : "s"}${shown.length < group.length ? ` (showing ${shown.length})` : ""}`);
|
|
2703
|
+
parts.push("");
|
|
2704
|
+
parts.push(describeRisk(risk));
|
|
2705
|
+
parts.push("");
|
|
2706
|
+
for (const entry of shown) {
|
|
2707
|
+
const mocks = entry.mockVNodeLiteralCount + entry.mockHelperCount + entry.mockHelperCallCount;
|
|
2708
|
+
const breakdown = [];
|
|
2709
|
+
if (entry.mockVNodeLiteralCount > 0) breakdown.push(`${entry.mockVNodeLiteralCount} literal${entry.mockVNodeLiteralCount === 1 ? "" : "s"}`);
|
|
2710
|
+
if (entry.mockHelperCount > 0) breakdown.push(`${entry.mockHelperCount} helper${entry.mockHelperCount === 1 ? "" : "s"}`);
|
|
2711
|
+
if (entry.mockHelperCallCount > 0) breakdown.push(`${entry.mockHelperCallCount} helper call${entry.mockHelperCallCount === 1 ? "" : "s"}`);
|
|
2712
|
+
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`;
|
|
2713
|
+
parts.push(`- ${entry.relPath} — ${mocks} mock signal${mocks === 1 ? "" : "s"} (${breakdown.join(" + ")}), ${hSide}`);
|
|
2714
|
+
}
|
|
2715
|
+
parts.push("");
|
|
2716
|
+
}
|
|
2717
|
+
parts.push("---");
|
|
2718
|
+
parts.push("");
|
|
2719
|
+
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.");
|
|
2720
|
+
return parts.join("\n");
|
|
2721
|
+
}
|
|
2722
|
+
function describeRisk(risk) {
|
|
2723
|
+
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.";
|
|
2724
|
+
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.";
|
|
2725
|
+
return "Mocks dwarfed by real usage OR no mocks at all — low risk.";
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
//#endregion
|
|
2729
|
+
export { auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformJSX, transformJSX_JS };
|
|
1809
2730
|
//# sourceMappingURL=index.js.map
|