@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.
@@ -580,6 +580,39 @@ describe('JSX transform — template emission', () => {
580
580
  expect(result).toContain('__ev_click = handler')
581
581
  })
582
582
 
583
+ // Regression: multi-word event-name casing was broken — `onKeyDown`
584
+ // produced `addEventListener("keyDown", ...)` (camelCase) instead of
585
+ // `addEventListener("keydown", ...)` (DOM convention). The handler
586
+ // never fired because `keyDown` is not a real DOM event name.
587
+ // Same bug class affected `onMouseEnter`, `onMouseLeave`, etc.
588
+ test('lowercases multi-word event names (onKeyDown → keydown — delegated)', () => {
589
+ const result = t('<div><input onKeyDown={handler} /></div>')
590
+ // keydown IS in DELEGATED_EVENTS — must use the expando, not addEventListener.
591
+ // Prior behavior: addEventListener("keyDown", ...) — wrong casing AND
592
+ // wrong path (delegated check missed because case mismatched).
593
+ expect(result).toContain('__ev_keydown = handler')
594
+ expect(result).not.toContain('__ev_keyDown')
595
+ expect(result).not.toContain('"keyDown"')
596
+ })
597
+
598
+ test('lowercases multi-word event names (onMouseEnter → mouseenter — non-delegated)', () => {
599
+ const result = t('<div><span onMouseEnter={handler}>hi</span></div>')
600
+ // mouseenter is NOT delegated — must reach addEventListener with lowercase name
601
+ expect(result).toContain('addEventListener("mouseenter", handler)')
602
+ expect(result).not.toContain('"mouseEnter"')
603
+ })
604
+
605
+ test('lowercases multi-word event names for input change (onChange → change — delegated)', () => {
606
+ const result = t('<div><input onChange={handler} /></div>')
607
+ expect(result).toContain('__ev_change = handler')
608
+ })
609
+
610
+ test('lowercases multi-word event names with multiple capitals (onPointerLeave → pointerleave)', () => {
611
+ const result = t('<div><span onPointerLeave={handler}>hi</span></div>')
612
+ expect(result).toContain('addEventListener("pointerleave", handler)')
613
+ expect(result).not.toContain('"pointerLeave"')
614
+ })
615
+
583
616
  test('uses element children indexing for nested access', () => {
584
617
  const result = t('<div><span>{a()}</span><em>{b()}</em></div>')
585
618
  // Can't have two expression children in same parent, but each is in its own element
@@ -779,6 +812,55 @@ describe('JSX transform — template emission', () => {
779
812
  expect(result).toContain('((el) => { myEl = el })(__root)')
780
813
  })
781
814
 
815
+ test('block-arrow ref on a child element with adjacent reactive props compiles cleanly', () => {
816
+ // Regression: a child element (NOT __root) with `hasDynamic=true`
817
+ // used to emit `const __e0 = __root.children[N]` followed by an
818
+ // unterminated ref-call `((el) => { x = el })(__e0)`. Without a
819
+ // trailing `;` on the const line, ASI did NOT insert one (because
820
+ // `__root.children[N]((el) => ...)` is a valid function call), and
821
+ // the two lines parsed as ONE expression:
822
+ // `const __e0 = __root.children[N]((el) => ...)(__e0)`
823
+ // — calling `children[N]` as a function with the arrow as arg, and
824
+ // self-referencing `__e0` before assignment. Surfaced when the
825
+ // app-showcase /dnd demo used `ref={(el) => { letVar = el }}` next
826
+ // to `data-X={signal()}` reactive props on the same element. Fix:
827
+ // append `;` to every bind line (`bindLines.map(l => ` ${l};`)`).
828
+ //
829
+ // This test asserts the OUTPUT is well-formed: the const line ends
830
+ // in `;` and the ref call IIFE follows on its own line.
831
+ const result = t(
832
+ '<div><span ref={(el) => { x = el }} data-state={cls()} /></div>',
833
+ )
834
+ // Const declaration must terminate before the ref IIFE.
835
+ expect(result).toMatch(/const __e0 = __root\.children\[0\];\s*\n/)
836
+ // The ref IIFE is its own statement, calling __e0 (not chained).
837
+ expect(result).toContain('((el) => { x = el })(__e0)')
838
+ // And the chained-call shape MUST NOT appear (the bug pattern).
839
+ expect(result).not.toMatch(/__root\.children\[0\]\(\(/)
840
+ })
841
+
842
+ test('compiled output parses cleanly for block-arrow ref + reactive prop', () => {
843
+ // Functional regression: prove the compiled module is well-formed JS.
844
+ // Pre-fix the AST-level shape was malformed and execution threw
845
+ // "TypeError: __root.children[N] is not a function" at mount time
846
+ // because the const declaration chained into the ref IIFE.
847
+ //
848
+ // Strip imports, stub framework calls, and parse the body via the
849
+ // Function constructor. If the const line lacks its terminator, the
850
+ // ref-call IIFE would silently turn the RHS into a function call —
851
+ // valid JS, wrong runtime behavior. So we BOTH parse-check AND
852
+ // string-shape-check: parse to catch syntax errors, regex to catch
853
+ // the silent-merge case (which parses fine but means the wrong thing).
854
+ const result = t(
855
+ '<div><span ref={(el) => { x = el }} data-state={cls()} /></div>',
856
+ )
857
+ const codeOnly = result.replace(/^\s*import\b[^;]+;?\s*/gm, '')
858
+ const wrapped = `let x;\nconst _tpl = () => {}; const _bind = () => {}; const _bindDirect = () => {}; const cls = () => "v";\nreturn ${codeOnly};`
859
+ expect(() => new Function(wrapped)).not.toThrow()
860
+ // And the buggy chained-call shape MUST NOT appear.
861
+ expect(result).not.toMatch(/children\[0\]\(\(/)
862
+ })
863
+
782
864
  test('handles non-void self-closing element as closing tag', () => {
783
865
  const result = t('<div><span></span></div>')
784
866
  expect(result).toContain('_tpl(')
@@ -1116,13 +1198,28 @@ describe('JSX transform — template emission edge cases', () => {
1116
1198
  test('non-delegated event (onMouseEnter) uses addEventListener not delegation', () => {
1117
1199
  const result = t('<div onMouseEnter={handler}><span /></div>')
1118
1200
  expect(result).toContain('_tpl(')
1119
- // mouseenter is NOT in DELEGATED_EVENTS → must use addEventListener
1120
- // onMouseEnter eventName = "m" + "ouseEnter" = "mouseEnter"
1121
- expect(result).toContain('addEventListener(')
1122
- expect(result).toContain('mouseEnter')
1201
+ // mouseenter is NOT in DELEGATED_EVENTS → must use addEventListener.
1202
+ // The event name is the JSX attribute with the "on" prefix dropped
1203
+ // and the rest fully lowercased (`onMouseEnter` → `mouseenter`)
1204
+ // — DOM events are all-lowercase. Prior to the fix this emitted
1205
+ // `mouseEnter` (camelCase) which the browser never dispatches.
1206
+ expect(result).toContain('addEventListener("mouseenter"')
1207
+ expect(result).not.toContain('mouseEnter')
1123
1208
  expect(result).not.toContain('__ev_')
1124
1209
  })
1125
1210
 
1211
+ // Regression: `onDoubleClick` is the one React→DOM event-name where
1212
+ // the simple-lowercase rule is wrong. The DOM event is `dblclick`,
1213
+ // NOT `doubleclick`. Pre-fix the compiler emitted `doubleclick`,
1214
+ // attaching a listener the browser never fires. Proven broken in
1215
+ // real Chromium via `e2e/app.spec.ts:dbl-click button increments by 10`.
1216
+ test('onDoubleClick maps to dblclick (not doubleclick)', () => {
1217
+ const result = t('<div onDoubleClick={handler}><span /></div>')
1218
+ expect(result).toContain('_tpl(')
1219
+ expect(result).toContain('__ev_dblclick = handler')
1220
+ expect(result).not.toContain('doubleclick')
1221
+ })
1222
+
1126
1223
  test('template with both dynamic attribute AND dynamic child text', () => {
1127
1224
  const result = t('<div title={getTitle()}>{count()}</div>')
1128
1225
  expect(result).toContain('_tpl(')
@@ -1849,6 +1946,92 @@ describe('JSX transform — signal auto-call', () => {
1849
1946
  expect(result).toContain('props.label')
1850
1947
  })
1851
1948
 
1949
+ // Regression: signal-method calls were getting double-wrapped — the
1950
+ // auto-call inserted `()` after the bare signal reference inside
1951
+ // `signal.set(value)`, producing `signal().set(value)`. That calls
1952
+ // the signal (returns its current value, e.g. a string) then tries
1953
+ // `.set` on the string (undefined → TypeError). Every `signal.set`,
1954
+ // `signal.peek`, `signal.update` call inside event handlers / hot
1955
+ // paths was silently broken.
1956
+ test('signal.set() in event handler does NOT auto-call the bare signal reference', () => {
1957
+ const result = t(
1958
+ 'function C() { const value = signal(""); return <input onInput={(e) => value.set(e.target.value)} /> }',
1959
+ )
1960
+ // Must keep `value.set(...)` — NOT `value().set(...)`.
1961
+ expect(result).toContain('value.set(e.target.value)')
1962
+ expect(result).not.toContain('value().set')
1963
+ })
1964
+
1965
+ test('signal.peek() does NOT auto-call', () => {
1966
+ const result = t(
1967
+ 'function C() { const count = signal(0); return <button onClick={() => console.log(count.peek())}>x</button> }',
1968
+ )
1969
+ expect(result).toContain('count.peek()')
1970
+ expect(result).not.toContain('count().peek')
1971
+ })
1972
+
1973
+ test('signal.update() does NOT auto-call', () => {
1974
+ const result = t(
1975
+ 'function C() { const count = signal(0); return <button onClick={() => count.update(n => n + 1)}>+</button> }',
1976
+ )
1977
+ expect(result).toContain('count.update(')
1978
+ expect(result).not.toContain('count().update')
1979
+ })
1980
+
1981
+ // Bare member-access on a signal (no call) STILL auto-calls — preserves
1982
+ // the existing convention where a signal containing an object can be
1983
+ // dereferenced via `signalContainingObj.someProp` (compiles to
1984
+ // `signalContainingObj().someProp`). Only the CALLED form
1985
+ // (`signal.method(...)`) skips the auto-call. See findSignalIdents
1986
+ // in jsx.ts for the rationale.
1987
+ test('signal.someProp (bare member access) DOES auto-call', () => {
1988
+ const result = t(
1989
+ 'function C() { const data = signal({ count: 0 }); return <div>{data.count}</div> }',
1990
+ )
1991
+ expect(result).toContain('data().count')
1992
+ })
1993
+
1994
+ // The bare signal reference STILL auto-calls in JSX text — make sure
1995
+ // the fix doesn't over-correct.
1996
+ test('bare signal in JSX text still auto-calls (fix does not over-correct)', () => {
1997
+ const result = t('function C() { const count = signal(0); return <div>{count}</div> }')
1998
+ expect(result).toContain('count()')
1999
+ })
2000
+
2001
+ // ── JSX text/expression whitespace (regression) ─────────────────────
2002
+ // The compiler used `.replace(/\n\s*/g, '').trim()` on JSX text which
2003
+ // stripped ALL leading/trailing whitespace — even spaces adjacent to
2004
+ // expressions on the same line. So `<p>doubled: {x}</p>` produced
2005
+ // `<p>doubled:</p>` + appended text node, rendering "doubled:0"
2006
+ // instead of "doubled: 0". Same class for `<p>{x} remaining</p>` →
2007
+ // text "remaining" loses its leading space, rendering as "Xremaining".
2008
+ // Fix: only strip whitespace adjacent to newlines (multi-line JSX
2009
+ // formatting), preserve same-line whitespace adjacent to expressions.
2010
+ test('preserves trailing space in JSX text before expression on same line', () => {
2011
+ const result = t('<p>doubled: {x()}</p>')
2012
+ // The static text portion of the template must keep "doubled: "
2013
+ // (with trailing space) so the appended expression value renders
2014
+ // as "doubled: 0", not "doubled:0".
2015
+ expect(result).toContain('doubled: ')
2016
+ })
2017
+
2018
+ test('preserves leading space in JSX text after expression on same line', () => {
2019
+ const result = t('<p>{x()} remaining</p>')
2020
+ // Static portion must include " remaining" (with leading space).
2021
+ expect(result).toContain(' remaining')
2022
+ })
2023
+
2024
+ test('strips multi-line JSX text whitespace adjacent to newlines', () => {
2025
+ // Multi-line JSX with indentation should still collapse — only
2026
+ // SAME-LINE whitespace adjacent to expressions is preserved.
2027
+ const result = t(`<div>
2028
+ <span>hello</span>
2029
+ </div>`)
2030
+ // The newlines + indentation should not produce stray text nodes.
2031
+ expect(result).toContain('hello')
2032
+ expect(result).not.toContain('"\\n "')
2033
+ })
2034
+
1852
2035
  test('shadowed signal variable by const is NOT auto-called', () => {
1853
2036
  const result = t(`
1854
2037
  function App() {
@@ -2066,6 +2249,55 @@ describe('JSX transform — template reactive style _bindDirect path', () => {
2066
2249
  })
2067
2250
  })
2068
2251
 
2252
+ // ── DOM-property assignment for value/checked/etc. (regression) ─────────
2253
+ // The compiler used `setAttribute("value", v)` for ALL non-class/style
2254
+ // attributes. For inputs that's wrong: `value` is a live DOM property,
2255
+ // `setAttribute` only sets the initial attribute. After the user types,
2256
+ // the property and attribute drift. Then `input.set('')` runs the
2257
+ // _bindDirect updater — which only resets the attribute, leaving the
2258
+ // stale typed text in the visible field. Same for `checked` on
2259
+ // checkboxes (presence of the attribute means checked, regardless of
2260
+ // value). Fix: emit property assignment for known DOM properties.
2261
+ describe('JSX transform — DOM properties use property assignment', () => {
2262
+ test('reactive value on input emits property assignment, not setAttribute', () => {
2263
+ const result = t('<div><input value={() => input()} /></div>')
2264
+ // Should be `el.value = v`, not `setAttribute("value", ...)`
2265
+ expect(result).toContain('.value = v')
2266
+ expect(result).not.toContain('setAttribute("value"')
2267
+ })
2268
+
2269
+ test('reactive checked on input emits property assignment', () => {
2270
+ const result = t('<div><input checked={done()} /></div>')
2271
+ expect(result).toContain('.checked = v')
2272
+ expect(result).not.toContain('setAttribute("checked"')
2273
+ })
2274
+
2275
+ test('static-call value on input emits property assignment', () => {
2276
+ // Non-signal-direct dynamic expression goes through reactiveBindExprs
2277
+ const result = t('<div><input value={x.y} /></div>')
2278
+ expect(result).toContain('.value = x.y')
2279
+ expect(result).not.toContain('setAttribute("value"')
2280
+ })
2281
+
2282
+ test('selected on option emits property assignment', () => {
2283
+ const result = t('<div><option selected={isSelected()}>x</option></div>')
2284
+ expect(result).toContain('.selected = v')
2285
+ expect(result).not.toContain('setAttribute("selected"')
2286
+ })
2287
+
2288
+ test('disabled on button emits property assignment', () => {
2289
+ const result = t('<div><button disabled={isDisabled()}>x</button></div>')
2290
+ expect(result).toContain('.disabled = v')
2291
+ expect(result).not.toContain('setAttribute("disabled"')
2292
+ })
2293
+
2294
+ test('non-DOM-prop attribute still uses setAttribute', () => {
2295
+ // placeholder is a real attribute, not a property-divergent IDL prop
2296
+ const result = t('<div><input placeholder={msg()} /></div>')
2297
+ expect(result).toContain('setAttribute("placeholder"')
2298
+ })
2299
+ })
2300
+
2069
2301
  describe('JSX transform — template combined _bind for complex expressions', () => {
2070
2302
  test('complex attribute expression uses combined _bind', () => {
2071
2303
  const result = t('<div class={`${a()} ${b()}`}><span /></div>')
@@ -122,6 +122,25 @@ describeNative('Native vs JS equivalence — template emission', () => {
122
122
  test('void element', () => compare('<div><br /><span>text</span></div>'))
123
123
  test('ref in template (object)', () => compare('<div ref={myRef}><span /></div>'))
124
124
  test('ref in template (arrow)', () => compare('<div ref={(el) => { myEl = el }}><span /></div>'))
125
+
126
+ // Regression: a child element with a block-arrow ref AND adjacent
127
+ // reactive props used to emit `const __e0 = __root.children[N]`
128
+ // followed by `((el) => { ... })(__e0)` with NO `;` between, so JS's
129
+ // ASI merged them into one expression `const __e0 = X((el) => ...)(__e0)`
130
+ // (calling X as fn, self-referencing __e0). Both backends now append
131
+ // `;` to every bind line. This test asserts both emit the SAME `;`-
132
+ // terminated output and the chained-call shape never appears.
133
+ test('block-arrow ref on child element with adjacent reactive prop', () => {
134
+ const input = '<div><span ref={(el) => { x = el }} data-state={cls()} /></div>'
135
+ compare(input)
136
+ // Tighter assertion: neither backend may emit the silent-merge shape.
137
+ const js = transformJSX_JS(input, 'test.tsx')
138
+ expect(js.code).not.toMatch(/children\[0\]\(\(/)
139
+ expect(js.code).toMatch(/const __e0 = __root\.children\[0\];/)
140
+ const rs = nativeTransform!(input, 'test.tsx', false, null)
141
+ expect(rs.code).not.toMatch(/children\[0\]\(\(/)
142
+ expect(rs.code).toMatch(/const __e0 = __root\.children\[0\];/)
143
+ })
125
144
  test('non-delegated event', () => compare('<div onMouseEnter={handler}><span /></div>'))
126
145
  test('style object in template', () => compare('<div style={{ overflow: "hidden" }}>text</div>'))
127
146
  test('style string in template', () => compare('<div style="color: red">text</div>'))
@@ -652,3 +671,61 @@ describeNative('Native vs JS equivalence — knownSignals cross-module', () => {
652
671
  ['theme'],
653
672
  ))
654
673
  })
674
+
675
+ // PR #352 added a `DOM_PROPS` set so `<input value={x()} />` inside a
676
+ // template-emitting context compiles to `el.value = x()` (property
677
+ // assignment) instead of `el.setAttribute("value", x())` (content
678
+ // attribute). The two diverge for IDL properties whose live state
679
+ // differs from the content attribute (`value`, `checked`, etc.). The
680
+ // Rust native backend reimplements this list separately. A typo in
681
+ // either side's list would silently produce wrong output for one
682
+ // DOM_PROP without breaking any other test. This block enumerates
683
+ // every DOM_PROP under template context (the only context where
684
+ // DOM_PROPS actually fires — root-level standalone JSX uses the
685
+ // `h()` path, not `_tpl() + _bind()`) and asserts JS↔Rust agreement,
686
+ // so a drift between the two lists fails one specific test.
687
+ //
688
+ // Reference: packages/core/compiler/src/jsx.ts:1389 — DOM_PROPS Set.
689
+ describeNative('Native vs JS equivalence — DOM properties', () => {
690
+ const DOM_PROPS = [
691
+ 'value',
692
+ 'checked',
693
+ 'selected',
694
+ 'disabled',
695
+ 'multiple',
696
+ 'readOnly',
697
+ 'indeterminate',
698
+ ] as const
699
+
700
+ for (const prop of DOM_PROPS) {
701
+ test(`DOM_PROP in template: <div><input ${prop}={x()} /></div> (reactive)`, () => {
702
+ compare(`<div><input ${prop}={x()} /></div>`)
703
+ })
704
+
705
+ test(`DOM_PROP in template: <div><input ${prop}={() => x()} /></div> (accessor)`, () => {
706
+ compare(`<div><input ${prop}={() => x()} /></div>`)
707
+ })
708
+
709
+ test(`DOM_PROP in template: <div><input ${prop}={true} /></div> (literal)`, () => {
710
+ compare(`<div><input ${prop}={true} /></div>`)
711
+ })
712
+ }
713
+
714
+ test('regression: all DOM_PROPS together in one template', () => {
715
+ // Sentinel — if a future PR adds a new DOM property to either
716
+ // backend without adding it to the other, the loop above won't
717
+ // notice unless that prop is in the test list. This single test
718
+ // compiles JSX with ALL known DOM_PROPS together and verifies
719
+ // both backends agree on the combined output.
720
+ const allProps = DOM_PROPS.map((p) => `${p}={x()}`).join(' ')
721
+ compare(`<div><input ${allProps} /></div>`)
722
+ })
723
+
724
+ test('non-DOM-prop control: title in template uses setAttribute, not assignment', () => {
725
+ // Negative control — `title` is NOT a DOM_PROP, so it should
726
+ // compile through setAttribute. If this test starts failing,
727
+ // someone added `title` to DOM_PROPS — verify intent before
728
+ // updating.
729
+ compare('<div><input title={x()} /></div>')
730
+ })
731
+ })
@@ -328,4 +328,159 @@ describe('detectPyreonPatterns', () => {
328
328
  expect(lines).toEqual([...lines].sort((a, b) => a - b))
329
329
  })
330
330
  })
331
+
332
+ describe('signal-write-as-call', () => {
333
+ it('flags `sig(value)` when sig was declared as a signal', () => {
334
+ const code = `
335
+ import { signal } from '@pyreon/reactivity'
336
+ const count = signal(0)
337
+ function inc() { count(count() + 1) }
338
+ `
339
+ const diags = detectPyreonPatterns(code)
340
+ const hits = diags.filter((d) => d.code === 'signal-write-as-call')
341
+ expect(hits).toHaveLength(1)
342
+ expect(hits[0]!.message).toContain('signal()')
343
+ expect(hits[0]!.suggested).toContain('count.set(')
344
+ })
345
+
346
+ it('does NOT flag `sig()` (zero args — that is the read API)', () => {
347
+ const code = `
348
+ const count = signal(0)
349
+ function read() { return count() }
350
+ `
351
+ const diags = detectPyreonPatterns(code)
352
+ expect(diags.filter((d) => d.code === 'signal-write-as-call')).toEqual([])
353
+ })
354
+
355
+ it('does NOT flag `sig.set(value)` (the proper write API)', () => {
356
+ const code = `
357
+ const count = signal(0)
358
+ function set(v) { count.set(v) }
359
+ `
360
+ const diags = detectPyreonPatterns(code)
361
+ expect(diags.filter((d) => d.code === 'signal-write-as-call')).toEqual([])
362
+ })
363
+
364
+ it('does NOT flag calls on identifiers that are not signal-bound', () => {
365
+ const code = `
366
+ const handler = (v) => console.log(v)
367
+ handler(42)
368
+ `
369
+ const diags = detectPyreonPatterns(code)
370
+ expect(diags.filter((d) => d.code === 'signal-write-as-call')).toEqual([])
371
+ })
372
+
373
+ it('flags `computed(value)` shape too — same misread of the API', () => {
374
+ const code = `
375
+ const doubled = computed(() => count() * 2)
376
+ function bug() { doubled(99) }
377
+ `
378
+ const diags = detectPyreonPatterns(code)
379
+ expect(diags.filter((d) => d.code === 'signal-write-as-call')).toHaveLength(1)
380
+ })
381
+ })
382
+
383
+ describe('static-return-null-conditional', () => {
384
+ it('flags `if (cond) return null` at the top of a component body', () => {
385
+ const code = `
386
+ function TabPanel({ id }) {
387
+ if (!isActive(id)) return null
388
+ return <div class="panel">content</div>
389
+ }
390
+ `
391
+ const diags = detectPyreonPatterns(code)
392
+ const hits = diags.filter((d) => d.code === 'static-return-null-conditional')
393
+ expect(hits).toHaveLength(1)
394
+ expect(hits[0]!.message).toContain('run ONCE')
395
+ expect(hits[0]!.suggested).toContain('=> {')
396
+ })
397
+
398
+ it('flags the block-form `if (cond) { return null }` too', () => {
399
+ const code = `
400
+ function Modal() {
401
+ if (!isOpen()) {
402
+ return null
403
+ }
404
+ return <div class="modal">…</div>
405
+ }
406
+ `
407
+ const diags = detectPyreonPatterns(code)
408
+ expect(
409
+ diags.filter((d) => d.code === 'static-return-null-conditional'),
410
+ ).toHaveLength(1)
411
+ })
412
+
413
+ it('does NOT flag non-component functions returning null', () => {
414
+ const code = `
415
+ function findUser(id) {
416
+ if (!id) return null
417
+ return { id }
418
+ }
419
+ `
420
+ const diags = detectPyreonPatterns(code)
421
+ expect(diags.filter((d) => d.code === 'static-return-null-conditional')).toEqual([])
422
+ })
423
+
424
+ it('does NOT flag the recommended reactive-accessor pattern', () => {
425
+ const code = `
426
+ function TabPanel() {
427
+ return (() => {
428
+ if (!isActive()) return null
429
+ return <div>content</div>
430
+ })
431
+ }
432
+ `
433
+ const diags = detectPyreonPatterns(code)
434
+ // The inner arrow contains the if-return-null but is itself a
435
+ // returned reactive accessor — not the "static-return-null" shape
436
+ // because the OUTER component's body has no top-level if-return-null.
437
+ expect(diags.filter((d) => d.code === 'static-return-null-conditional')).toEqual([])
438
+ })
439
+
440
+ it('only flags ONCE per component body even when chained', () => {
441
+ const code = `
442
+ function MultiGuard() {
443
+ if (!a()) return null
444
+ if (!b()) return null
445
+ return <div>ok</div>
446
+ }
447
+ `
448
+ const diags = detectPyreonPatterns(code)
449
+ expect(
450
+ diags.filter((d) => d.code === 'static-return-null-conditional'),
451
+ ).toHaveLength(1)
452
+ })
453
+ })
454
+
455
+ describe('as-unknown-as-vnodechild', () => {
456
+ it('flags `expr as unknown as VNodeChild`', () => {
457
+ const code = `
458
+ function Wrapper() {
459
+ return (<div>hi</div> as unknown as VNodeChild)
460
+ }
461
+ `
462
+ const diags = detectPyreonPatterns(code)
463
+ const hits = diags.filter((d) => d.code === 'as-unknown-as-vnodechild')
464
+ expect(hits).toHaveLength(1)
465
+ expect(hits[0]!.message).toContain('JSX.Element')
466
+ })
467
+
468
+ it('does NOT flag a single `as VNodeChild` (no double-cast)', () => {
469
+ const code = `
470
+ function Wrapper() {
471
+ return (something as VNodeChild)
472
+ }
473
+ `
474
+ const diags = detectPyreonPatterns(code)
475
+ expect(diags.filter((d) => d.code === 'as-unknown-as-vnodechild')).toEqual([])
476
+ })
477
+
478
+ it('does NOT flag `as unknown as OtherType`', () => {
479
+ const code = `
480
+ const x = (foo as unknown as Whatever)
481
+ `
482
+ const diags = detectPyreonPatterns(code)
483
+ expect(diags.filter((d) => d.code === 'as-unknown-as-vnodechild')).toEqual([])
484
+ })
485
+ })
331
486
  })