@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/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
- const eventName = (attrName[2] ?? '').toLowerCase() + attrName.slice(3)
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
- : `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`
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 trimmed = raw.replace(/\n\s*/g, '').trim()
1084
- if (trimmed) out.push({ kind: 'text', text: trimmed })
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 hasNonElem = flatChildren.some((c) => c.kind !== 'element')
1134
+ const hasText = flatChildren.some((c) => c.kind === 'text')
1112
1135
  const exprCount = flatChildren.filter((c) => c.kind === 'expression').length
1113
- return { useMixed: hasElem && hasNonElem, useMultiExpr: exprCount > 1 }
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
- let body = bindLines.map((l) => ` ${l}`).join('\n')
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, '&amp;').replace(/</g, '&lt;')
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 ?? [])
@@ -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 = { sf, code, diagnostics: [] }
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