@pyreon/compiler 0.14.0 → 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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +225 -11
- package/lib/types/index.d.ts +13 -1
- package/package.json +10 -2
- package/src/event-names.ts +65 -0
- package/src/jsx.ts +140 -7
- package/src/pyreon-intercept.ts +226 -2
- package/src/tests/detector-tag-consistency.test.ts +3 -0
- package/src/tests/jsx.test.ts +236 -4
- package/src/tests/native-equivalence.test.ts +77 -0
- package/src/tests/pyreon-intercept.test.ts +155 -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/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/jsx.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { parseSync } from 'oxc-parser'
|
|
|
32
32
|
import { createRequire } from 'node:module'
|
|
33
33
|
import { fileURLToPath } from 'node:url'
|
|
34
34
|
import { dirname, join } from 'node:path'
|
|
35
|
+
import { REACT_EVENT_REMAP } from './event-names'
|
|
35
36
|
|
|
36
37
|
// ─── Native binary auto-detection ────────────────────────────────────────────
|
|
37
38
|
// Try to load the Rust napi-rs binary for 3.7-8.2x faster transforms.
|
|
@@ -901,7 +902,26 @@ export function transformJSX_JS(
|
|
|
901
902
|
}
|
|
902
903
|
|
|
903
904
|
function emitEventListener(attr: N, attrName: string, varName: string): void {
|
|
904
|
-
|
|
905
|
+
// Translate the JSX-style React attribute name (e.g. `onKeyDown`,
|
|
906
|
+
// `onDoubleClick`) to the canonical DOM event name (`keydown`,
|
|
907
|
+
// `dblclick`).
|
|
908
|
+
//
|
|
909
|
+
// The default rule is "drop the `on` prefix and lowercase" —
|
|
910
|
+
// covers `onKeyDown` → `keydown`, `onMouseEnter` → `mouseenter`,
|
|
911
|
+
// `onPointerLeave` → `pointerleave`, `onAnimationStart` →
|
|
912
|
+
// `animationstart`, etc. Most React event names follow this rule
|
|
913
|
+
// because the underlying DOM event name is also the lowercased
|
|
914
|
+
// multi-word form.
|
|
915
|
+
//
|
|
916
|
+
// The exception list lives in `REACT_EVENT_REMAP` (event-names.ts).
|
|
917
|
+
// Every React event-prop in the official component-prop list was
|
|
918
|
+
// audited against canonical DOM event names — see the JSDoc on
|
|
919
|
+
// REACT_EVENT_REMAP for the audit. Today exactly one entry:
|
|
920
|
+
// `onDoubleClick` → `dblclick`
|
|
921
|
+
// The Rust native backend (`native/src/lib.rs:emit_event_listener`)
|
|
922
|
+
// mirrors the same table — keep them in sync if a new entry is added.
|
|
923
|
+
const lowered = attrName.slice(2).toLowerCase()
|
|
924
|
+
const eventName = REACT_EVENT_REMAP[lowered] ?? lowered
|
|
905
925
|
if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return
|
|
906
926
|
const expr = attr.value.expression
|
|
907
927
|
if (!expr || expr.type === 'JSXEmptyExpression') return
|
|
@@ -952,6 +972,7 @@ export function transformJSX_JS(
|
|
|
952
972
|
function attrSetter(htmlAttrName: string, varName: string, expr: string): string {
|
|
953
973
|
if (htmlAttrName === 'class') return `${varName}.className = ${expr}`
|
|
954
974
|
if (htmlAttrName === 'style') return `${varName}.style.cssText = ${expr}`
|
|
975
|
+
if (DOM_PROPS.has(htmlAttrName)) return `${varName}.${htmlAttrName} = ${expr}`
|
|
955
976
|
return `${varName}.setAttribute("${htmlAttrName}", ${expr})`
|
|
956
977
|
}
|
|
957
978
|
|
|
@@ -970,7 +991,9 @@ export function transformJSX_JS(
|
|
|
970
991
|
? `(v) => { ${varName}.className = v == null ? "" : String(v) }`
|
|
971
992
|
: htmlAttrName === 'style'
|
|
972
993
|
? `(v) => { if (typeof v === "string") ${varName}.style.cssText = v; else if (v) Object.assign(${varName}.style, v) }`
|
|
973
|
-
:
|
|
994
|
+
: DOM_PROPS.has(htmlAttrName)
|
|
995
|
+
? `(v) => { ${varName}.${htmlAttrName} = v }`
|
|
996
|
+
: `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`
|
|
974
997
|
bindLines.push(`const ${d} = _bindDirect(${directRef}, ${updater})`)
|
|
975
998
|
return
|
|
976
999
|
}
|
|
@@ -1080,8 +1103,8 @@ export function transformJSX_JS(
|
|
|
1080
1103
|
): void {
|
|
1081
1104
|
if (child.type === 'JSXText') {
|
|
1082
1105
|
const raw = child.value ?? child.raw ?? ''
|
|
1083
|
-
const
|
|
1084
|
-
if (
|
|
1106
|
+
const cleaned = cleanJsxText(raw)
|
|
1107
|
+
if (cleaned) out.push({ kind: 'text', text: cleaned })
|
|
1085
1108
|
return
|
|
1086
1109
|
}
|
|
1087
1110
|
if (child.type === 'JSXElement') {
|
|
@@ -1108,9 +1131,19 @@ export function transformJSX_JS(
|
|
|
1108
1131
|
|
|
1109
1132
|
function analyzeChildren(flatChildren: FlatChild[]): { useMixed: boolean; useMultiExpr: boolean } {
|
|
1110
1133
|
const hasElem = flatChildren.some((c) => c.kind === 'element')
|
|
1111
|
-
const
|
|
1134
|
+
const hasText = flatChildren.some((c) => c.kind === 'text')
|
|
1112
1135
|
const exprCount = flatChildren.filter((c) => c.kind === 'expression').length
|
|
1113
|
-
|
|
1136
|
+
// `useMixed` triggers placeholder-based positional mounting (each
|
|
1137
|
+
// dynamic child gets a `<!>` comment slot in the template that
|
|
1138
|
+
// `replaceChild`-replaces at mount). It must fire whenever ≥2 of
|
|
1139
|
+
// {element, text, expression} are interleaved — otherwise dynamic
|
|
1140
|
+
// text nodes added via `appendChild` land after all static
|
|
1141
|
+
// template content, breaking source-order rendering for shapes
|
|
1142
|
+
// like `<p>foo {x()} bar</p>` (rendered "foo barX" instead of
|
|
1143
|
+
// "foo X bar"). Discovered by Phase B2's whitespace tests.
|
|
1144
|
+
const present =
|
|
1145
|
+
(hasElem ? 1 : 0) + (hasText ? 1 : 0) + (exprCount > 0 ? 1 : 0)
|
|
1146
|
+
return { useMixed: present > 1, useMultiExpr: exprCount > 1 }
|
|
1114
1147
|
}
|
|
1115
1148
|
|
|
1116
1149
|
function attrIsDynamic(attr: N): boolean {
|
|
@@ -1210,7 +1243,31 @@ export function transformJSX_JS(
|
|
|
1210
1243
|
return `_tpl("${escaped}", () => null)`
|
|
1211
1244
|
}
|
|
1212
1245
|
|
|
1213
|
-
|
|
1246
|
+
// Append `;` to every bind line so ASI can't merge consecutive
|
|
1247
|
+
// statements when the next line starts with `(`, `[`, etc.
|
|
1248
|
+
// Concrete bug shape (pre-fix): a child element with `hasDynamic=true`
|
|
1249
|
+
// emits `const __e0 = __root.children[N]` followed by a ref-callback
|
|
1250
|
+
// line `((el) => { x = el })(__e0)`. JS does NOT insert ASI here
|
|
1251
|
+
// because `__root.children[N]((el) => ...)` is a valid expression,
|
|
1252
|
+
// so the parser merges them into a single function call:
|
|
1253
|
+
// `const __e0 = __root.children[N]((el) => ...)(__e0)`
|
|
1254
|
+
// — calling `children[N]` as a function with the arrow as argument,
|
|
1255
|
+
// and self-referencing `__e0` before assignment. Adding the `;`
|
|
1256
|
+
// terminates each statement deterministically. Trailing `;` after
|
|
1257
|
+
// a `{...}` block is a harmless empty statement.
|
|
1258
|
+
// Append `;` to every bind line so ASI can't merge consecutive
|
|
1259
|
+
// statements when the next line starts with `(`, `[`, etc.
|
|
1260
|
+
// Concrete bug shape (pre-fix): a child element with `hasDynamic=true`
|
|
1261
|
+
// emits `const __e0 = __root.children[N]` followed by a ref-callback
|
|
1262
|
+
// line `((el) => { x = el })(__e0)`. JS does NOT insert ASI here
|
|
1263
|
+
// because `__root.children[N]((el) => ...)` is a valid expression,
|
|
1264
|
+
// so the parser merges them into a single function call:
|
|
1265
|
+
// `const __e0 = __root.children[N]((el) => ...)(__e0)`
|
|
1266
|
+
// — calling `children[N]` as a function with the arrow as argument,
|
|
1267
|
+
// and self-referencing `__e0` before assignment. Adding the `;`
|
|
1268
|
+
// terminates each statement deterministically. Trailing `;` after
|
|
1269
|
+
// a `{...}` block is a harmless empty statement.
|
|
1270
|
+
let body = bindLines.map((l) => ` ${l};`).join('\n')
|
|
1214
1271
|
if (disposerNames.length > 0) {
|
|
1215
1272
|
body += `\n return () => { ${disposerNames.map((d) => `${d}()`).join('; ')} }`
|
|
1216
1273
|
} else {
|
|
@@ -1244,6 +1301,16 @@ export function transformJSX_JS(
|
|
|
1244
1301
|
if (node.type === 'Identifier' && isActiveSignal(node.name)) {
|
|
1245
1302
|
const parent = findParent(node)
|
|
1246
1303
|
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return false
|
|
1304
|
+
// signal.X(...) — operating on the signal object (calling a method).
|
|
1305
|
+
// Mirrors the same narrow skip in findSignalIdents below.
|
|
1306
|
+
if (
|
|
1307
|
+
parent &&
|
|
1308
|
+
parent.type === 'MemberExpression' &&
|
|
1309
|
+
parent.object === node
|
|
1310
|
+
) {
|
|
1311
|
+
const grand = findParent(parent)
|
|
1312
|
+
if (grand && grand.type === 'CallExpression' && grand.callee === parent) return false
|
|
1313
|
+
}
|
|
1247
1314
|
if (parent && parent.type === 'CallExpression' && parent.callee === node) return false // already called
|
|
1248
1315
|
return true
|
|
1249
1316
|
}
|
|
@@ -1269,6 +1336,27 @@ export function transformJSX_JS(
|
|
|
1269
1336
|
const parent = findParent(node)
|
|
1270
1337
|
// Skip property name positions (obj.name)
|
|
1271
1338
|
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return
|
|
1339
|
+
// Skip when the identifier is the OBJECT of a member access AND
|
|
1340
|
+
// the result is being CALLED (signal.set(...), signal.peek(),
|
|
1341
|
+
// signal.update(...)). The user is invoking a method on the
|
|
1342
|
+
// signal OBJECT — auto-calling would produce `signal().set(...)`
|
|
1343
|
+
// which calls the signal, gets its value (string/number/etc),
|
|
1344
|
+
// then `.set` on the value is undefined → TypeError. Every event
|
|
1345
|
+
// handler that did `signal.set(x)` was silently broken.
|
|
1346
|
+
//
|
|
1347
|
+
// Note: bare `signal.value` (member access NOT followed by call)
|
|
1348
|
+
// STILL auto-calls — keeps the existing convention where
|
|
1349
|
+
// `signal({a:1})` followed by `signal.a` reads the signal's
|
|
1350
|
+
// value's property (see "signal as member expression object IS
|
|
1351
|
+
// auto-called" test).
|
|
1352
|
+
if (
|
|
1353
|
+
parent &&
|
|
1354
|
+
parent.type === 'MemberExpression' &&
|
|
1355
|
+
parent.object === node
|
|
1356
|
+
) {
|
|
1357
|
+
const grand = findParent(parent)
|
|
1358
|
+
if (grand && grand.type === 'CallExpression' && grand.callee === parent) return
|
|
1359
|
+
}
|
|
1272
1360
|
// Skip if already being called: signal()
|
|
1273
1361
|
if (parent && parent.type === 'CallExpression' && parent.callee === node) return
|
|
1274
1362
|
// Skip declaration positions
|
|
@@ -1313,6 +1401,24 @@ const JSX_TO_HTML_ATTR: Record<string, string> = {
|
|
|
1313
1401
|
htmlFor: 'for',
|
|
1314
1402
|
}
|
|
1315
1403
|
|
|
1404
|
+
// DOM properties whose live value diverges from the content attribute.
|
|
1405
|
+
// For these, emit property assignment (`el.value = v`) instead of
|
|
1406
|
+
// `setAttribute("value", v)`. Otherwise the property and attribute drift
|
|
1407
|
+
// apart in user-driven flows: typing in a controlled <input> updates the
|
|
1408
|
+
// .value property, but `input.set('')` clearing the signal only resets
|
|
1409
|
+
// the attribute — the stale typed text stays visible. Same for `checked`
|
|
1410
|
+
// on checkboxes (presence of the attribute means checked regardless of
|
|
1411
|
+
// value: `setAttribute("checked", "false")` still checks the box).
|
|
1412
|
+
const DOM_PROPS = new Set([
|
|
1413
|
+
'value',
|
|
1414
|
+
'checked',
|
|
1415
|
+
'selected',
|
|
1416
|
+
'disabled',
|
|
1417
|
+
'multiple',
|
|
1418
|
+
'readOnly',
|
|
1419
|
+
'indeterminate',
|
|
1420
|
+
])
|
|
1421
|
+
|
|
1316
1422
|
const STATEFUL_CALLS = new Set([
|
|
1317
1423
|
'signal', 'computed', 'effect', 'batch',
|
|
1318
1424
|
'createContext', 'createReactiveContext',
|
|
@@ -1364,6 +1470,33 @@ function escapeHtmlText(s: string): string {
|
|
|
1364
1470
|
return s.replace(/&(?!(?:#\d+|#x[\da-fA-F]+|[a-zA-Z]\w*);)/g, '&').replace(/</g, '<')
|
|
1365
1471
|
}
|
|
1366
1472
|
|
|
1473
|
+
// React/Babel JSX whitespace algorithm (cleanJSXElementLiteralChild).
|
|
1474
|
+
// Same-line text is preserved verbatim so adjacent expressions keep their
|
|
1475
|
+
// spacing (`<p>doubled: {x}</p>` keeps the trailing space). Multi-line text
|
|
1476
|
+
// strips leading whitespace from non-first lines and trailing whitespace
|
|
1477
|
+
// from non-last lines, drops fully-empty lines, and joins the survivors
|
|
1478
|
+
// with a single space — collapsing JSX indentation without losing
|
|
1479
|
+
// intentional inline spacing.
|
|
1480
|
+
function cleanJsxText(raw: string): string {
|
|
1481
|
+
if (!raw.includes('\n') && !raw.includes('\r')) return raw
|
|
1482
|
+
const lines = raw.split(/\r\n|\n|\r/)
|
|
1483
|
+
let lastNonEmpty = -1
|
|
1484
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1485
|
+
if (/[^ \t]/.test(lines[i] ?? '')) lastNonEmpty = i
|
|
1486
|
+
}
|
|
1487
|
+
let str = ''
|
|
1488
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1489
|
+
let line = (lines[i] ?? '').replace(/\t/g, ' ')
|
|
1490
|
+
if (i !== 0) line = line.replace(/^ +/, '')
|
|
1491
|
+
if (i !== lines.length - 1) line = line.replace(/ +$/, '')
|
|
1492
|
+
if (line) {
|
|
1493
|
+
if (i !== lastNonEmpty) line += ' '
|
|
1494
|
+
str += line
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
return str
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1367
1500
|
function isStaticJSXNode(node: N): boolean {
|
|
1368
1501
|
if (node.type === 'JSXElement' && node.openingElement?.selfClosing) {
|
|
1369
1502
|
return isStaticAttrs(node.openingElement.attributes ?? [])
|
package/src/pyreon-intercept.ts
CHANGED
|
@@ -29,6 +29,18 @@
|
|
|
29
29
|
* monotonic counter.
|
|
30
30
|
* - `on-click-undefined` — `onClick={undefined}` explicitly; the runtime
|
|
31
31
|
* used to crash on this pattern. Omit the prop.
|
|
32
|
+
* - `signal-write-as-call` — `sig(value)` is a no-op read that ignores
|
|
33
|
+
* its argument; the runtime warns in dev. Static
|
|
34
|
+
* detector spots it pre-runtime when `sig` was
|
|
35
|
+
* declared as `const sig = signal(...)` /
|
|
36
|
+
* `computed(...)` and called with ≥1 argument.
|
|
37
|
+
* - `static-return-null-conditional` — `if (cond) return null` at the
|
|
38
|
+
* top of a component body runs ONCE; signal changes
|
|
39
|
+
* in `cond` never re-evaluate the early-return.
|
|
40
|
+
* Wrap in a returned reactive accessor.
|
|
41
|
+
* - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
|
|
42
|
+
* cast on JSX returns is unnecessary (`JSX.Element`
|
|
43
|
+
* is already assignable to `VNodeChild`).
|
|
32
44
|
*
|
|
33
45
|
* Two-mode surface mirrors `react-intercept.ts`:
|
|
34
46
|
* - `detectPyreonPatterns(code)` — diagnostics only
|
|
@@ -66,6 +78,9 @@ export type PyreonDiagnosticCode =
|
|
|
66
78
|
| 'raw-remove-event-listener'
|
|
67
79
|
| 'date-math-random-id'
|
|
68
80
|
| 'on-click-undefined'
|
|
81
|
+
| 'signal-write-as-call'
|
|
82
|
+
| 'static-return-null-conditional'
|
|
83
|
+
| 'as-unknown-as-vnodechild'
|
|
69
84
|
|
|
70
85
|
export interface PyreonDiagnostic {
|
|
71
86
|
/** Machine-readable code for filtering + programmatic handling */
|
|
@@ -92,6 +107,13 @@ interface DetectContext {
|
|
|
92
107
|
sf: ts.SourceFile
|
|
93
108
|
code: string
|
|
94
109
|
diagnostics: PyreonDiagnostic[]
|
|
110
|
+
/**
|
|
111
|
+
* Identifiers bound to `signal(...)` or `computed(...)` calls anywhere in
|
|
112
|
+
* the file. Populated by `collectSignalBindings()` before the main
|
|
113
|
+
* detection walk. Used by `detectSignalWriteAsCall` to flag `sig(value)`
|
|
114
|
+
* patterns that should be `sig.set(value)`.
|
|
115
|
+
*/
|
|
116
|
+
signalBindings: Set<string>
|
|
95
117
|
}
|
|
96
118
|
|
|
97
119
|
function getNodeText(ctx: DetectContext, node: ts.Node): string {
|
|
@@ -439,6 +461,192 @@ function detectOnClickUndefined(ctx: DetectContext, node: ts.JsxAttribute): void
|
|
|
439
461
|
)
|
|
440
462
|
}
|
|
441
463
|
|
|
464
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
465
|
+
// Pattern: signal-write-as-call (sig(value) instead of sig.set(value))
|
|
466
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Walks the file and collects every identifier bound to a `signal(...)` or
|
|
470
|
+
* `computed(...)` call. Only `const` declarations are tracked — `let`/`var`
|
|
471
|
+
* may be reassigned to non-signal values, so a use-site call wouldn't be a
|
|
472
|
+
* reliable signal-write.
|
|
473
|
+
*
|
|
474
|
+
* The collection is intentionally scope-blind: a name shadowed in a nested
|
|
475
|
+
* scope (`const x = signal(0); function f() { const x = 5; x(7) }`) would
|
|
476
|
+
* produce a false positive on `x(7)`. That tradeoff is acceptable because
|
|
477
|
+
* (1) shadowing a signal name with a non-signal is itself unusual and
|
|
478
|
+
* (2) the detector message points at exactly the wrong-shape call so a
|
|
479
|
+
* human reviewer can dismiss the rare false positive in seconds.
|
|
480
|
+
*/
|
|
481
|
+
function collectSignalBindings(sf: ts.SourceFile): Set<string> {
|
|
482
|
+
const names = new Set<string>()
|
|
483
|
+
function isSignalFactoryCall(init: ts.Expression | undefined): boolean {
|
|
484
|
+
if (!init || !ts.isCallExpression(init)) return false
|
|
485
|
+
const callee = init.expression
|
|
486
|
+
if (!ts.isIdentifier(callee)) return false
|
|
487
|
+
return callee.text === 'signal' || callee.text === 'computed'
|
|
488
|
+
}
|
|
489
|
+
function walk(node: ts.Node): void {
|
|
490
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
491
|
+
// Only `const` — find the parent VariableDeclarationList to check.
|
|
492
|
+
const list = node.parent
|
|
493
|
+
if (
|
|
494
|
+
ts.isVariableDeclarationList(list) &&
|
|
495
|
+
(list.flags & ts.NodeFlags.Const) !== 0 &&
|
|
496
|
+
isSignalFactoryCall(node.initializer)
|
|
497
|
+
) {
|
|
498
|
+
names.add(node.name.text)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
ts.forEachChild(node, walk)
|
|
502
|
+
}
|
|
503
|
+
walk(sf)
|
|
504
|
+
return names
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function detectSignalWriteAsCall(ctx: DetectContext, node: ts.CallExpression): void {
|
|
508
|
+
if (ctx.signalBindings.size === 0) return
|
|
509
|
+
const callee = node.expression
|
|
510
|
+
if (!ts.isIdentifier(callee)) return
|
|
511
|
+
if (!ctx.signalBindings.has(callee.text)) return
|
|
512
|
+
// `sig()` (zero args) is a READ — that's the intended Pyreon API.
|
|
513
|
+
if (node.arguments.length === 0) return
|
|
514
|
+
// `sig.set(x)` / `sig.update(fn)` / `sig.peek()` — the proper write/read
|
|
515
|
+
// surface — go through PropertyAccess, not direct CallExpression on the
|
|
516
|
+
// identifier. So if we got here, the call is `sig(value)` or
|
|
517
|
+
// `sig(value, ..)` which is the buggy shape.
|
|
518
|
+
pushDiag(
|
|
519
|
+
ctx,
|
|
520
|
+
node,
|
|
521
|
+
'signal-write-as-call',
|
|
522
|
+
`\`${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.`,
|
|
523
|
+
getNodeText(ctx, node),
|
|
524
|
+
`${callee.text}.set(${node.arguments.map((a) => getNodeText(ctx, a)).join(', ')})`,
|
|
525
|
+
false,
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
530
|
+
// Pattern: static-return-null-conditional in component bodies
|
|
531
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* `if (cond) return null` at the top of a component body runs ONCE — Pyreon
|
|
535
|
+
* components mount and never re-execute their function bodies. A signal
|
|
536
|
+
* change inside `cond` therefore never re-evaluates the condition; the
|
|
537
|
+
* component is permanently stuck on whichever branch the first run picked.
|
|
538
|
+
*
|
|
539
|
+
* The fix is to wrap the conditional in a returned reactive accessor:
|
|
540
|
+
* return (() => { if (!cond()) return null; return <div /> })
|
|
541
|
+
*
|
|
542
|
+
* Detection:
|
|
543
|
+
* - The function contains JSX (i.e. it's a component)
|
|
544
|
+
* - The function body has an `IfStatement` whose `thenStatement` is
|
|
545
|
+
* `return null` (either bare `return null` or `{ return null }`)
|
|
546
|
+
* - The `if` is at the function body's top level, NOT inside a returned
|
|
547
|
+
* arrow / IIFE (those are reactive scopes — flagging them would be a
|
|
548
|
+
* false positive)
|
|
549
|
+
*/
|
|
550
|
+
function returnsNullStatement(stmt: ts.Statement): boolean {
|
|
551
|
+
if (ts.isReturnStatement(stmt)) {
|
|
552
|
+
const expr = stmt.expression
|
|
553
|
+
return !!expr && expr.kind === ts.SyntaxKind.NullKeyword
|
|
554
|
+
}
|
|
555
|
+
if (ts.isBlock(stmt)) {
|
|
556
|
+
return stmt.statements.length === 1 && returnsNullStatement(stmt.statements[0]!)
|
|
557
|
+
}
|
|
558
|
+
return false
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Returns true if the function looks like a top-level component:
|
|
563
|
+
* - `function PascalName(...) { ... }` (FunctionDeclaration with PascalCase id), OR
|
|
564
|
+
* - `const PascalName = (...) => { ... }` (arrow inside a VariableDeclaration whose name is PascalCase).
|
|
565
|
+
*
|
|
566
|
+
* Anonymous nested arrows — most importantly the reactive accessor
|
|
567
|
+
* `return (() => { if (!cond()) return null; return <div /> })` — are
|
|
568
|
+
* NOT considered components here, even when they contain JSX. Without
|
|
569
|
+
* this filter the detector would fire on the very pattern the
|
|
570
|
+
* diagnostic recommends as the fix.
|
|
571
|
+
*/
|
|
572
|
+
function isComponentShapedFunction(
|
|
573
|
+
node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
|
|
574
|
+
): boolean {
|
|
575
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
576
|
+
return !!node.name && /^[A-Z]/.test(node.name.text)
|
|
577
|
+
}
|
|
578
|
+
// Arrow / FunctionExpression: check VariableDeclaration parent.
|
|
579
|
+
const parent = node.parent
|
|
580
|
+
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
|
|
581
|
+
return /^[A-Z]/.test(parent.name.text)
|
|
582
|
+
}
|
|
583
|
+
return false
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function detectStaticReturnNullConditional(
|
|
587
|
+
ctx: DetectContext,
|
|
588
|
+
node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
|
|
589
|
+
): void {
|
|
590
|
+
// Only component-shaped functions (must render JSX AND be named with
|
|
591
|
+
// PascalCase) — see isComponentShapedFunction for why the name check
|
|
592
|
+
// matters: it filters out the reactive-accessor-as-fix pattern.
|
|
593
|
+
if (!isComponentShapedFunction(node)) return
|
|
594
|
+
if (!containsJsx(node)) return
|
|
595
|
+
const body = node.body
|
|
596
|
+
if (!body || !ts.isBlock(body)) return
|
|
597
|
+
|
|
598
|
+
for (const stmt of body.statements) {
|
|
599
|
+
if (!ts.isIfStatement(stmt)) continue
|
|
600
|
+
if (!returnsNullStatement(stmt.thenStatement)) continue
|
|
601
|
+
// Found `if (cond) return null` at top-level component body scope.
|
|
602
|
+
pushDiag(
|
|
603
|
+
ctx,
|
|
604
|
+
stmt,
|
|
605
|
+
'static-return-null-conditional',
|
|
606
|
+
'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.',
|
|
607
|
+
getNodeText(ctx, stmt),
|
|
608
|
+
'return (() => { if (!cond()) return null; return <JSX /> })',
|
|
609
|
+
false,
|
|
610
|
+
)
|
|
611
|
+
// Only flag the FIRST occurrence per component to avoid noise on
|
|
612
|
+
// chained early-returns (often a single mistake, not three).
|
|
613
|
+
return
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
618
|
+
// Pattern: `expr as unknown as VNodeChild`
|
|
619
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* `JSX.Element` (which is what JSX evaluates to) is already assignable to
|
|
623
|
+
* `VNodeChild`. The `as unknown as VNodeChild` double-cast is unnecessary
|
|
624
|
+
* — it's been showing up in `@pyreon/ui-primitives` as a defensive habit
|
|
625
|
+
* carried over from earlier framework versions. The cast is never load-
|
|
626
|
+
* bearing today; removing it never changes runtime behavior. Pure cosmetic
|
|
627
|
+
* but a useful proxy for non-idiomatic Pyreon code in primitives.
|
|
628
|
+
*/
|
|
629
|
+
function detectAsUnknownAsVNodeChild(ctx: DetectContext, node: ts.AsExpression): void {
|
|
630
|
+
// Outer cast: `... as VNodeChild`
|
|
631
|
+
const outerType = node.type
|
|
632
|
+
if (!ts.isTypeReferenceNode(outerType)) return
|
|
633
|
+
if (!ts.isIdentifier(outerType.typeName) || outerType.typeName.text !== 'VNodeChild') return
|
|
634
|
+
// Inner: `<expr> as unknown`
|
|
635
|
+
const inner = node.expression
|
|
636
|
+
if (!ts.isAsExpression(inner)) return
|
|
637
|
+
if (inner.type.kind !== ts.SyntaxKind.UnknownKeyword) return
|
|
638
|
+
|
|
639
|
+
pushDiag(
|
|
640
|
+
ctx,
|
|
641
|
+
node,
|
|
642
|
+
'as-unknown-as-vnodechild',
|
|
643
|
+
'`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.',
|
|
644
|
+
getNodeText(ctx, node),
|
|
645
|
+
getNodeText(ctx, inner.expression),
|
|
646
|
+
false,
|
|
647
|
+
)
|
|
648
|
+
}
|
|
649
|
+
|
|
442
650
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
443
651
|
// Visitor
|
|
444
652
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -453,6 +661,7 @@ function visitNode(ctx: DetectContext, node: ts.Node): void {
|
|
|
453
661
|
ts.isFunctionExpression(node)
|
|
454
662
|
) {
|
|
455
663
|
detectPropsDestructured(ctx, node)
|
|
664
|
+
detectStaticReturnNullConditional(ctx, node)
|
|
456
665
|
}
|
|
457
666
|
if (ts.isBinaryExpression(node)) {
|
|
458
667
|
detectProcessDevGate(ctx, node)
|
|
@@ -464,10 +673,14 @@ function visitNode(ctx: DetectContext, node: ts.Node): void {
|
|
|
464
673
|
if (ts.isCallExpression(node)) {
|
|
465
674
|
detectEmptyTheme(ctx, node)
|
|
466
675
|
detectRawEventListener(ctx, node)
|
|
676
|
+
detectSignalWriteAsCall(ctx, node)
|
|
467
677
|
}
|
|
468
678
|
if (ts.isJsxAttribute(node)) {
|
|
469
679
|
detectOnClickUndefined(ctx, node)
|
|
470
680
|
}
|
|
681
|
+
if (ts.isAsExpression(node)) {
|
|
682
|
+
detectAsUnknownAsVNodeChild(ctx, node)
|
|
683
|
+
}
|
|
471
684
|
}
|
|
472
685
|
|
|
473
686
|
function visit(ctx: DetectContext, node: ts.Node): void {
|
|
@@ -483,7 +696,12 @@ function visit(ctx: DetectContext, node: ts.Node): void {
|
|
|
483
696
|
|
|
484
697
|
export function detectPyreonPatterns(code: string, filename = 'input.tsx'): PyreonDiagnostic[] {
|
|
485
698
|
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX)
|
|
486
|
-
const ctx: DetectContext = {
|
|
699
|
+
const ctx: DetectContext = {
|
|
700
|
+
sf,
|
|
701
|
+
code,
|
|
702
|
+
diagnostics: [],
|
|
703
|
+
signalBindings: collectSignalBindings(sf),
|
|
704
|
+
}
|
|
487
705
|
visit(ctx, sf)
|
|
488
706
|
// Sort by (line, column) for stable ordering when multiple patterns fire.
|
|
489
707
|
ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column)
|
|
@@ -499,6 +717,12 @@ export function hasPyreonPatterns(code: string): boolean {
|
|
|
499
717
|
/\b(?:add|remove)EventListener\s*\(/.test(code) ||
|
|
500
718
|
(/\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code)) ||
|
|
501
719
|
/on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) ||
|
|
502
|
-
/=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code)
|
|
720
|
+
/=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code) ||
|
|
721
|
+
// signal-write-as-call: `const X = signal(` declaration anywhere
|
|
722
|
+
/\b(?:signal|computed)\s*[<(]/.test(code) ||
|
|
723
|
+
// static-return-null-conditional: `if (...) return null` anywhere
|
|
724
|
+
/\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) ||
|
|
725
|
+
// as-unknown-as-vnodechild
|
|
726
|
+
/\bas\s+unknown\s+as\s+VNodeChild\b/.test(code)
|
|
503
727
|
)
|
|
504
728
|
}
|
|
@@ -32,6 +32,9 @@ const KNOWN_CODES = [
|
|
|
32
32
|
'raw-remove-event-listener',
|
|
33
33
|
'date-math-random-id',
|
|
34
34
|
'on-click-undefined',
|
|
35
|
+
'signal-write-as-call',
|
|
36
|
+
'static-return-null-conditional',
|
|
37
|
+
'as-unknown-as-vnodechild',
|
|
35
38
|
] as const
|
|
36
39
|
type KnownCode = (typeof KNOWN_CODES)[number]
|
|
37
40
|
|