@pyreon/compiler 0.19.0 → 0.20.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.
Files changed (28) hide show
  1. package/lib/analysis/index.js.html +1 -1
  2. package/lib/index.js +418 -18
  3. package/lib/types/index.d.ts +92 -1
  4. package/package.json +13 -12
  5. package/src/index.ts +2 -1
  6. package/src/jsx.ts +669 -17
  7. package/src/tests/backend-parity-r7-r9.test.ts +91 -0
  8. package/src/tests/backend-prop-derived-callback-divergence.test.ts +74 -0
  9. package/src/tests/collapse-bail-census.test.ts +245 -0
  10. package/src/tests/collapse-key-source-hygiene.test.ts +88 -0
  11. package/src/tests/element-valued-const-child.test.ts +61 -0
  12. package/src/tests/falsy-child-characterization.test.ts +48 -0
  13. package/src/tests/malformed-input-resilience.test.ts +50 -0
  14. package/src/tests/partial-collapse-detector.test.ts +121 -0
  15. package/src/tests/partial-collapse-emit.test.ts +104 -0
  16. package/src/tests/partial-collapse-robustness.test.ts +53 -0
  17. package/src/tests/prop-derived-shadow.test.ts +96 -0
  18. package/src/tests/pure-call-reactive-args.test.ts +50 -0
  19. package/src/tests/r13-callback-stmt-equivalence.test.ts +58 -0
  20. package/src/tests/r14-ssr-mode-parity.test.ts +51 -0
  21. package/src/tests/r15-elemconst-propderived.test.ts +47 -0
  22. package/src/tests/r19-defer-inline-robust.test.ts +54 -0
  23. package/src/tests/r20-backend-equivalence-sweep.test.ts +50 -0
  24. package/src/tests/rocketstyle-collapse.test.ts +208 -0
  25. package/src/tests/signal-autocall-shadow.test.ts +86 -0
  26. package/src/tests/sourcemap-fidelity.test.ts +77 -0
  27. package/src/tests/static-text-baking.test.ts +64 -0
  28. package/src/tests/transform-state-isolation.test.ts +49 -0
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Compiler hardening — Round 20 (JS↔Rust equivalence sweep, R11–R19 corpus).
3
+ *
4
+ * The dual backends MUST emit byte-identical output. This sweep locks the
5
+ * adversarial corpus surfaced in rounds 11–19 where BOTH backends agree, so a
6
+ * future one-backend change that introduces a divergence (the recurring
7
+ * R7/R13/R15 one-backend-change failure mode) is caught immediately. R11's
8
+ * and R13's fixes CONVERGED the backends (R11 brought JS onto the
9
+ * already-correct Rust; R13's gate/collector fix realigned native with JS,
10
+ * which also resolved the R15 element-const×prop-derived divergence) — those
11
+ * shapes must now stay identical, asserted here. The earlier self-
12
+ * discriminating `it.fails` divergence locks auto-flipped on resolution and
13
+ * were removed; R15's residual is a deterministic JS-backend
14
+ * characterization (r15-elemconst-propderived.test.ts).
15
+ */
16
+ import { describe, expect, it } from 'vitest'
17
+ import { transformJSX, transformJSX_JS } from '../jsx'
18
+
19
+ const EQUIVALENT: Array<[string, string]> = [
20
+ // R11 — signal-auto-call shadowing (JS converged to Rust)
21
+ ['r11-destructured-param', `function C(){ const x = signal(0); return <ul>{[{x:1}].map(({x}) => <li>{x}</li>)}</ul> }`],
22
+ ['r11-filter-obj-param', `function C(){ const id = signal(0); return <ul>{[{id:1}].filter(({id}) => id > 0).map(r => <li>{r}</li>)}</ul> }`],
23
+ ['r11-plain-param-shadow', `function C(){ const s = signal(0); return <ul>{[1].map(s => <li>{s}</li>)}</ul> }`],
24
+ ['r11-direct-signal', `function C(){ const s = signal(0); return <div>{s}</div> }`],
25
+ ['r11-signal-shadow-sibling', `function C(){ const s = signal(0); return <div>{[1].map(s => <i>{s}</i>)}<b>{s}</b></div> }`],
26
+ // R16 — knownSignals (cross-module signal) path
27
+ ['r16-knownsignal-shadow', `function C(){ return <ul>{[{count:1}].map(({count}) => <li>{count}</li>)}</ul> }`],
28
+ // R15 — element-const interactions that DO converge
29
+ ['r15-simple-elem-const', `function C(){ const h=<h1>T</h1>; return <div>{h}<p>x</p></div> }`],
30
+ ['r15-elem-const-reused', `function C(){ const ic=<svg/>; return <div>{ic}<span>{ic}</span></div> }`],
31
+ // R17 — spread depth
32
+ ['r17-root-spread-reactive', `function C(p){ const s=signal(0); return <div {...p} onClick={()=>s.set(1)}>{s}</div> }`],
33
+ ['r17-component-spread-pd', `function C(p){ const c=p.x+'-b'; return <Comp {...p} class={c}/> }`],
34
+ ['r17-double-spread', `function C(p){ return <div {...p.a} {...p.b}>{p.c}</div> }`],
35
+ // R18 — hoisting × elementVars
36
+ ['r18-hoist-vs-elemconst', `function C(){ const e=<b>x</b>; return <div>{<i/>}{e}</div> }`],
37
+ ['r18-elemconst-reused-3x', `function C(){ const e=<hr/>; return <div>{e}{e}{e}</div> }`],
38
+ // R13 — the convergent control (return-body callback)
39
+ ['r13-return-cb', `function C(p){ const c=p.x+'-b'; return <ul>{p.i.map(i => { return <li class={c}>{i}</li> })}</ul> }`],
40
+ // general
41
+ ['mixed', `function C(p){ const a=p.x; return <section><h1>{a}</h1>{p.i.map(i=><p key={i.id}>{i.t}</p>)}</section> }`],
42
+ ]
43
+
44
+ describe('Round 20 — JS↔Rust byte-equivalence (R11–R19 corpus)', () => {
45
+ for (const [name, code] of EQUIVALENT) {
46
+ it(`${name}: JS ≡ Rust`, () => {
47
+ expect(transformJSX(code, 'c.tsx').code).toBe(transformJSX_JS(code, 'c.tsx').code)
48
+ })
49
+ }
50
+ })
@@ -0,0 +1,208 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { rocketstyleCollapseKey, transformJSX } from '../jsx'
3
+
4
+ // Layer 4: the compiler DETECTS a literal-prop rocketstyle call site
5
+ // (bail catalogue — RFC decision 3) and EMITS the collapsed
6
+ // `_rsCollapse(...)` + once-per-module idempotent `injectRules`, when
7
+ // the Vite plugin supplies an SSR-resolved `sites` entry. It never runs
8
+ // the rocketstyle chain itself. These tests stub the resolved `sites`
9
+ // map directly (no Vite) — the real SSR resolution is proven in
10
+ // @pyreon/vite-plugin's resolver test; the end-to-end byte-parity is
11
+ // proven by the ui-showcase e2e gate (Phase 4).
12
+
13
+ const SITE = {
14
+ templateHtml: '<button data-x="1"><span class="inner">Save</span></button>',
15
+ lightClass: 'pyr-L1 pyr-L2',
16
+ darkClass: 'pyr-D1 pyr-D2',
17
+ rules: ['.pyr-L1{color:red}', '.pyr-D1{color:blue}'],
18
+ ruleKey: 'bundleA',
19
+ }
20
+
21
+ function collapseOpt(candidates: string[], sites: Record<string, typeof SITE>) {
22
+ return {
23
+ collapseRocketstyle: {
24
+ candidates: new Set(candidates),
25
+ sites: new Map(Object.entries(sites)),
26
+ mode: { name: 'useMode', source: '@pyreon/ui-core' },
27
+ },
28
+ }
29
+ }
30
+
31
+ describe('rocketstyleCollapseKey — stable + order-independent', () => {
32
+ it('same component+props+text ⇒ same key regardless of attr order', () => {
33
+ const a = rocketstyleCollapseKey('Button', { state: 'primary', size: 'md' }, 'Save')
34
+ const b = rocketstyleCollapseKey('Button', { size: 'md', state: 'primary' }, 'Save')
35
+ expect(a).toBe(b)
36
+ expect(a).not.toBe(rocketstyleCollapseKey('Button', { state: 'secondary' }, 'Save'))
37
+ expect(a).not.toBe(rocketstyleCollapseKey('Button', { state: 'primary', size: 'md' }, 'Go'))
38
+ })
39
+ })
40
+
41
+ describe('compiler — collapsible call site emission', () => {
42
+ it('emits _rsCollapse + dual-emit mode thunk + once-per-module injectRules', () => {
43
+ const key = rocketstyleCollapseKey('Button', { state: 'primary', size: 'medium' }, 'Save')
44
+ const src = `
45
+ import { Button } from '@pyreon/ui-components'
46
+ export function App() {
47
+ return <Button state="primary" size="medium">Save</Button>
48
+ }`
49
+ const { code } = transformJSX(src, 'App.tsx', collapseOpt(['Button'], { [key]: SITE }))
50
+ // collapsed call replaces the JSX
51
+ expect(code).toContain(
52
+ '__rsCollapse("<button data-x=\\"1\\"><span class=\\"inner\\">Save</span></button>", "pyr-L1 pyr-L2", "pyr-D1 pyr-D2", () => __pyrMode() === "dark")',
53
+ )
54
+ // dual-emit mode accessor imported from the configured source
55
+ expect(code).toContain('import { useMode as __pyrMode } from "@pyreon/ui-core";')
56
+ // runtime helper + styler sheet imports
57
+ expect(code).toContain('import { _rsCollapse as __rsCollapse } from "@pyreon/runtime-dom";')
58
+ expect(code).toContain('import { sheet as __rsSheet } from "@pyreon/styler";')
59
+ // once-per-module idempotent rule injection, keyed by ruleKey
60
+ expect(code).toContain('__rsSheet.injectRules(')
61
+ expect(code).toContain(JSON.stringify(SITE.rules))
62
+ expect(code).toContain('"bundleA")')
63
+ // the original <Button …> JSX is gone
64
+ expect(code).not.toContain('<Button')
65
+ })
66
+
67
+ it('two identical sites in one module emit ONE injectRules (deduped by ruleKey)', () => {
68
+ const key = rocketstyleCollapseKey('Button', { state: 'primary' }, 'X')
69
+ const src = `
70
+ import { Button } from '@pyreon/ui-components'
71
+ export const A = () => <Button state="primary">X</Button>
72
+ export const B = () => <Button state="primary">X</Button>`
73
+ const { code } = transformJSX(src, 'M.tsx', collapseOpt(['Button'], { [key]: SITE }))
74
+ const injCount = code.split('__rsSheet.injectRules(').length - 1
75
+ expect(injCount).toBe(1)
76
+ const callCount = code.split('__rsCollapse(').length - 1
77
+ // 2 call sites (the `_rsCollapse as __rsCollapse` import alias has
78
+ // no trailing `(`, so it doesn't count)
79
+ expect(callCount).toBe(2)
80
+ })
81
+ })
82
+
83
+ describe('compiler — bail catalogue (RFC decision 3): NO collapse', () => {
84
+ const key = rocketstyleCollapseKey('Button', { state: 'primary' }, 'Save')
85
+ const sites = { [key]: SITE }
86
+
87
+ function noCollapse(src: string, opt = collapseOpt(['Button'], sites)) {
88
+ const { code } = transformJSX(src, 'B.tsx', opt)
89
+ expect(code).not.toContain('__rsCollapse')
90
+ return code
91
+ }
92
+
93
+ it('bails on a non-literal (signal/expr) dimension prop', () => {
94
+ noCollapse(`
95
+ import { Button } from '@pyreon/ui-components'
96
+ export const A = (p) => <Button state={p.s}>Save</Button>`)
97
+ })
98
+
99
+ it('bails on a JSX spread attribute', () => {
100
+ noCollapse(`
101
+ import { Button } from '@pyreon/ui-components'
102
+ export const A = (p) => <Button state="primary" {...p}>Save</Button>`)
103
+ })
104
+
105
+ it('bails on an element child (non-static-text children)', () => {
106
+ noCollapse(`
107
+ import { Button } from '@pyreon/ui-components'
108
+ export const A = () => <Button state="primary"><i>Save</i></Button>`)
109
+ })
110
+
111
+ it('bails on an expression child', () => {
112
+ noCollapse(`
113
+ import { Button } from '@pyreon/ui-components'
114
+ export const A = (p) => <Button state="primary">{p.label}</Button>`)
115
+ })
116
+
117
+ it('bails when the component is not a registered candidate', () => {
118
+ noCollapse(
119
+ `
120
+ import { Card } from '@pyreon/ui-components'
121
+ export const A = () => <Card state="primary">Save</Card>`,
122
+ collapseOpt(['Button'], sites),
123
+ )
124
+ })
125
+
126
+ it('bails when there is no resolved site for the key (resolver bailed / not data)', () => {
127
+ noCollapse(
128
+ `
129
+ import { Button } from '@pyreon/ui-components'
130
+ export const A = () => <Button state="zzz">Save</Button>`,
131
+ collapseOpt(['Button'], sites),
132
+ )
133
+ })
134
+
135
+ it('does nothing when collapseRocketstyle option is absent (default OFF)', () => {
136
+ const { code } = transformJSX(
137
+ `
138
+ import { Button } from '@pyreon/ui-components'
139
+ export const A = () => <Button state="primary">Save</Button>`,
140
+ 'Off.tsx',
141
+ {},
142
+ )
143
+ expect(code).not.toContain('__rsCollapse')
144
+ })
145
+ })
146
+
147
+ describe('bisect: collapse forces the JS path', () => {
148
+ it('emits the collapse even though a native binary may be present', () => {
149
+ // transformJSX must short-circuit to transformJSX_JS when
150
+ // collapseRocketstyle is set (the Rust binary doesn't implement it).
151
+ // If the force-JS guard were removed and a native binary were
152
+ // loaded, this would emit no __rsCollapse — proving the guard is
153
+ // load-bearing. With the guard, JS path always runs.
154
+ const key = rocketstyleCollapseKey('Button', {}, 'Hi')
155
+ const { code } = transformJSX(
156
+ `
157
+ import { Button } from '@pyreon/ui-components'
158
+ export const A = () => <Button>Hi</Button>`,
159
+ 'J.tsx',
160
+ collapseOpt(['Button'], { [key]: SITE }),
161
+ )
162
+ expect(code).toContain('__rsCollapse(')
163
+ })
164
+ })
165
+
166
+ describe('scanCollapsibleSites — plugin scanner == compiler detection', () => {
167
+ it('finds the collapsible site with the SAME key the compiler looks up', async () => {
168
+ const { scanCollapsibleSites } = await import('../jsx')
169
+ const src = `
170
+ import { Button as Btn } from '@pyreon/ui-components'
171
+ import { useState } from 'somewhere'
172
+ export const A = () => <Btn state="primary" size="medium">Save</Btn>
173
+ export const B = (p) => <Btn state={p.s}>x</Btn>
174
+ export const C = () => <div state="primary">not a candidate</div>`
175
+ const sites = scanCollapsibleSites(src, 'A.tsx', new Set(['@pyreon/ui-components']))
176
+ // only the literal-prop, static-text <Btn> collapses; the {expr}
177
+ // one and the <div> are bailed (catalogue) / non-candidate.
178
+ expect(sites).toHaveLength(1)
179
+ const s = sites[0]!
180
+ expect(s.componentName).toBe('Btn') // LOCAL alias — key uses this
181
+ expect(s.importedName).toBe('Button') // resolver imports this
182
+ expect(s.source).toBe('@pyreon/ui-components')
183
+ expect(s.props).toEqual({ state: 'primary', size: 'medium' })
184
+ expect(s.childrenText).toBe('Save')
185
+ // The key the plugin computes here MUST equal the key the compiler
186
+ // recomputes from the JSX node — proven by feeding a sites map
187
+ // keyed by s.key and asserting the compiler collapses.
188
+ const { code } = transformJSX(src, 'A.tsx', {
189
+ collapseRocketstyle: {
190
+ candidates: new Set(['Btn']),
191
+ sites: new Map([[s.key, SITE]]),
192
+ mode: { name: 'useMode', source: '@pyreon/ui-core' },
193
+ },
194
+ })
195
+ // exactly the literal-prop site collapsed; the {expr} <Btn> bailed
196
+ // and remains as JSX (1 collapse call, 1 surviving <Btn).
197
+ expect(code.split('__rsCollapse(').length - 1).toBe(1)
198
+ expect(code).toContain('<Btn state={')
199
+ })
200
+
201
+ it('skips a component imported from a non-collapsible source', async () => {
202
+ const { scanCollapsibleSites } = await import('../jsx')
203
+ const src = `
204
+ import { Button } from './local-button'
205
+ export const A = () => <Button state="primary">Save</Button>`
206
+ expect(scanCollapsibleSites(src, 'A.tsx', new Set(['@pyreon/ui-components']))).toHaveLength(0)
207
+ })
208
+ })
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Compiler hardening — Round 11 (REAL bug, FIXED + bisect-verified).
3
+ *
4
+ * The signal-auto-call rewrite (`autoCallSignals` → `findSignalIdents`,
5
+ * jsx.ts) inserts `()` after every active-signal-named identifier. Its
6
+ * skip-list handled MemberExpr / VarDeclarator / Property-key|shorthand but
7
+ * NOT callback parameter binding positions, and `findSignalIdents` did its
8
+ * OWN scope-blind recursive walk over the wrapped expression. So a
9
+ * destructured/plain callback param reusing a signal's name was wrongly
10
+ * auto-called:
11
+ *
12
+ * const x = signal(0)
13
+ * <ul>{[{x:1}].map(({x}) => <li>{x}</li>)}</ul>
14
+ * → …map(({x}) => <li>{x()}</li>) // x is the map item (1) → 1()
15
+ * // → runtime TypeError
16
+ *
17
+ * Trigger: an inline object/array literal in the expr whose property name
18
+ * collides with a signal makes `referencesSignalVar` fire, invoking the
19
+ * scope-blind `autoCallSignals` over the whole expression. This is the exact
20
+ * signal twin of R2's prop-derived scope-blind inlining.
21
+ *
22
+ * Fix: `findSignalIdents` is now block-accurate scope-aware (mirrors R2's
23
+ * `findIdents`): a `scopeBoundSignals(node)` collects signal-named bindings a
24
+ * scope introduces (params incl. nested/destructured patterns, nested const,
25
+ * catch/loop vars), threaded through a `shadowed` set with enter/leave so a
26
+ * shadowed name is never auto-called. Legitimate (non-shadowed) signal reads
27
+ * still auto-call — proven by the CONTROL specs.
28
+ *
29
+ * Bisect: drop `&& !shadowed.has(node.name)` from the `findSignalIdents`
30
+ * active-signal guard → the SHADOW specs fail (emit `{x()}` / `id()`); the
31
+ * CONTROL specs stay green (no over-suppression). Restore → all pass.
32
+ */
33
+ import { parseSync } from 'oxc-parser'
34
+ import { describe, expect, it } from 'vitest'
35
+ import { transformJSX_JS } from '../jsx'
36
+
37
+ const emit = (c: string): string => transformJSX_JS(c, 'c.tsx').code ?? ''
38
+ const parses = (o: string): boolean => {
39
+ try {
40
+ return (parseSync('o.tsx', o).errors?.length ?? 0) === 0
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+
46
+ describe('Round 11 — signal auto-call respects lexical shadowing', () => {
47
+ it('destructured-shorthand callback param shadowing a signal is NOT auto-called', () => {
48
+ const out = emit(`function C(){ const x = signal(0); return <ul>{[{x:1}].map(({x}) => <li>{x}</li>)}</ul> }`)
49
+ expect(parses(out)).toBe(true)
50
+ expect(out).not.toContain('{x()}')
51
+ expect(out).toContain('({x}) => <li>{x}</li>')
52
+ })
53
+
54
+ it('destructured param shadowing a signal in a filter predicate is NOT auto-called', () => {
55
+ const out = emit(`function C(){ const id = signal(0); return <ul>{[{id:1}].filter(({id}) => id > 0).map(r => <li>{r}</li>)}</ul> }`)
56
+ expect(parses(out)).toBe(true)
57
+ expect(out).not.toMatch(/\(\{id\}\) => id\(\)/)
58
+ })
59
+
60
+ it('renamed destructured value param shadowing a signal is NOT auto-called', () => {
61
+ const out = emit(`function C(){ const v = signal(0); return <ul>{[{k:1}].map(({k: v}) => <li>{v}</li>)}</ul> }`)
62
+ expect(out).not.toContain('{v()}')
63
+ })
64
+
65
+ it('plain callback param shadowing a signal is NOT auto-called', () => {
66
+ const out = emit(`function C(){ const s = signal(0); return <ul>{[1].map(s => <li>{s}</li>)}</ul> }`)
67
+ expect(out).not.toContain('{s()}')
68
+ })
69
+
70
+ // ── CONTROL: legitimate signal reads MUST still auto-call ──
71
+ it('CONTROL: a direct non-shadowed signal child still auto-calls', () => {
72
+ expect(emit(`function C(){ const s = signal(0); return <div>{s}</div> }`)).toContain('__t0.data = s()')
73
+ })
74
+
75
+ it('CONTROL: a non-shadowed signal SIBLING of a shadowing callback still auto-calls', () => {
76
+ const out = emit(`function C(){ const s = signal(0); return <div>{[1].map(s => <i>{s}</i>)}<b>{s}</b></div> }`)
77
+ expect(out).not.toContain('<i>{s()}</i>') // the shadowing param — not called
78
+ expect(out).toContain('__t0.data = s()') // the real signal sibling — called
79
+ })
80
+
81
+ it('CONTROL: signal.set in a handler is not auto-called but its arg is', () => {
82
+ const out = emit(`function C(){ const s = signal(0); return <button onClick={() => s.set(s() + 1)}>{s}</button> }`)
83
+ expect(out).toContain('s.set(s() + 1)')
84
+ expect(out).toContain('__t0.data = s()')
85
+ })
86
+ })
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Compiler hardening — Round 12 (REAL high-impact gap, FIXED + bisect).
3
+ *
4
+ * Pre-fix: `transformJSX` emitted NO source map and its string-slice
5
+ * substitutions shifted line counts (template emission expands one-line JSX
6
+ * into a multi-line `_tpl(...)` factory). `@pyreon/vite-plugin` returned
7
+ * `{ code, map: null }`, so every runtime stack frame / debugger breakpoint
8
+ * in every Pyreon component mislocated — app-wide, in every project.
9
+ *
10
+ * Fix: `transformJSX_JS` applies its existing disjoint `{start,end,text}`
11
+ * replacement set through MagicString (`update`/`appendLeft`) and the
12
+ * generated preamble via `prepend`. `toString()` is byte-identical to the
13
+ * old concatenation — proven by the full ~1240-test suite + the 180
14
+ * native-equivalence tests, which assert exact emitted strings and all stay
15
+ * green — while `generateMap()` now yields a correct V3 map. `prepend`
16
+ * shifts every mapping by the preamble's line count, so original positions
17
+ * resolve to the correct OUTPUT line despite the line-shift.
18
+ *
19
+ * Bisect: revert the MagicString block to the slice/join + chained-prepend
20
+ * assembly → `map` is `undefined` and these specs fail; restore → pass. The
21
+ * byte-identical guarantee is itself bisect-covered by the rest of the suite
22
+ * (any drift fails an exact-string assertion somewhere).
23
+ */
24
+ import { describe, expect, it } from 'vitest'
25
+ import { transformJSX_JS } from '../jsx'
26
+
27
+ // Edit-correctness oracle is the rest of the compiler suite: the full
28
+ // ~1240-test + 180 native-equivalence corpus asserts EXACT emitted strings,
29
+ // so any byte drift from the MagicString assembly fails there. Map *math*
30
+ // (segment offsets through `update`/`appendLeft`/`prepend`) is magic-string's
31
+ // — the battle-tested generator vite/rollup/svelte rely on; not re-derived
32
+ // here. These specs assert the gap is closed (a valid, content-embedded V3
33
+ // map is produced and serializes for Vite) and the no-op contract.
34
+
35
+ const MULTILINE = `function C(props) {
36
+ return (
37
+ <section>
38
+ <h1>{props.title}</h1>
39
+ <p>{props.body}</p>
40
+ </section>
41
+ )
42
+ }`
43
+
44
+ describe('Round 12 — sourcemap fidelity (fixed)', () => {
45
+ it('a transforming compile now produces a V3 source map', () => {
46
+ const r = transformJSX_JS(MULTILINE, 'C.tsx')
47
+ expect(r.map).toBeDefined()
48
+ expect(r.map!.version).toBe(3)
49
+ expect(r.map!.sources).toContain('C.tsx')
50
+ expect(r.map!.mappings.length).toBeGreaterThan(0)
51
+ })
52
+
53
+ it('the map embeds original content and is JSON/`toString`-serializable for Vite', () => {
54
+ const r = transformJSX_JS(MULTILINE, 'C.tsx')
55
+ expect(r.map!.sourcesContent?.[0]).toBe(MULTILINE)
56
+ const json = JSON.parse(r.map!.toString())
57
+ expect(json.version).toBe(3)
58
+ expect(json.mappings).toBe(r.map!.mappings)
59
+ })
60
+
61
+ it('output still line-shifts — but the map now accounts for it (the whole point)', () => {
62
+ const r = transformJSX_JS(MULTILINE, 'C.tsx')
63
+ // Template emission still expands lines (unchanged codegen)…
64
+ expect(r.code.split('\n').length).toBeGreaterThan(MULTILINE.split('\n').length)
65
+ // …and the segment mappings are non-trivial (multi-segment, i.e. the
66
+ // preamble + per-replacement remapping is recorded, not an empty/identity
67
+ // map that would silently mislocate like before).
68
+ expect(r.map!.mappings).toMatch(/[;,]/)
69
+ expect(r.map!.names).toBeInstanceOf(Array)
70
+ })
71
+
72
+ it('a no-op compile (nothing to transform) returns no map (code is unchanged)', () => {
73
+ const r = transformJSX_JS(`const x = 1`, 'plain.ts')
74
+ expect(r.map).toBeUndefined()
75
+ expect(r.code).toBe(`const x = 1`)
76
+ })
77
+ })
@@ -0,0 +1,64 @@
1
+ import { transformJSX } from '../jsx'
2
+
3
+ const t = (code: string) => transformJSX(code, 'input.tsx').code
4
+ const hasBind = (out: string) => /\b_bindText\(|\b_bind\(/.test(out)
5
+
6
+ // ── Static-text baking contract (perf-correctness regression gate) ──────────
7
+ //
8
+ // The compiler bakes a provably-static `{expr}` child straight into the
9
+ // `_tpl()` HTML and emits NO `_bind`/`_bindText` for it. The Reactivity
10
+ // Lens's `static-text` kind is a faithful RECORD of exactly this codegen
11
+ // branch — it is NOT an independent oracle the emitter could disagree
12
+ // with. So "the analysis proves static but codegen still binds" is
13
+ // structurally impossible; the only thing that can erode this is an
14
+ // `isDynamic` PRECISION regression that starts treating a static shape as
15
+ // dynamic (a silent per-mount allocation + subscription leak with no
16
+ // other guard) OR — the inverse correctness bug — under-wrapping a truly
17
+ // reactive shape.
18
+ //
19
+ // This suite is self-discriminating, which IS its bisect proof: it
20
+ // asserts BOTH regimes against the SAME `isDynamic` decision. An
21
+ // over-broad regression fails the "baked" half; an under-broad
22
+ // (correctness) regression fails the "reactive" half. Both halves are
23
+ // demonstrated reachable by the empirical probe that motivated this gate
24
+ // (every static shape baked; the unknown-call / signal / prop shapes
25
+ // bound) — neither half passes vacuously.
26
+
27
+ describe('static-text baking — provably-static children are baked, never _bind', () => {
28
+ const STATIC: [string, string][] = [
29
+ ['module-const string ref', `const N='hi'; export const C=()=> <div>{N}</div>`],
30
+ ['string literal', `export const C=()=> <div>{'hi'}</div>`],
31
+ ['number literal', `export const C=()=> <div>{42}</div>`],
32
+ ['static ternary on a module const', `const F=false; export const C=()=> <div>{F ? 'a' : 'b'}</div>`],
33
+ ['template literal interpolating only a const', `const N='x'; export const C=()=> <div>{\`v-\${N}\`}</div>`],
34
+ ['module-const array .length', `const A=[1,2,3]; export const C=()=> <div>{A.length}</div>`],
35
+ ['pure built-in call (Math.max)', `export const C=()=> <div>{Math.max(1,2)}</div>`],
36
+ ['const string concat', `const A='a',B='b'; export const C=()=> <div>{A+B}</div>`],
37
+ ]
38
+ for (const [name, src] of STATIC) {
39
+ test(`bakes (no _bind): ${name}`, () => {
40
+ const out = t(src)
41
+ expect(out).toContain('_tpl(')
42
+ expect(hasBind(out)).toBe(false)
43
+ })
44
+ }
45
+ })
46
+
47
+ describe('static-text baking — genuinely-reactive / unprovable children DO bind (discriminator)', () => {
48
+ const REACTIVE: [string, string][] = [
49
+ [
50
+ 'signal read',
51
+ `import {signal} from '@pyreon/reactivity'; const s=signal(0); export const C=()=> <div>{s()}</div>`,
52
+ ],
53
+ ['prop access', `export const C=(props:any)=> <div>{props.x}</div>`],
54
+ [
55
+ 'unknown local call (conservatively reactive — correct: cannot prove signal-free)',
56
+ `function f(){return 'z'} export const C=()=> <div>{f()}</div>`,
57
+ ],
58
+ ]
59
+ for (const [name, src] of REACTIVE) {
60
+ test(`binds: ${name}`, () => {
61
+ expect(hasBind(t(src))).toBe(true)
62
+ })
63
+ }
64
+ })
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Compiler hardening — Round 6 (state-isolation lock; no leak found).
3
+ *
4
+ * Probed: does the JS transform leak across calls? All per-transform caches
5
+ * (`_isDynamicCache`, `resolvedCache`, `resolving`, `warnedCycles`) are
6
+ * declared INSIDE `transformJSX_JS` (function scope → GC'd per call); the only
7
+ * module-level state is immutable constant lookup Sets (`PURE_CALLS`,
8
+ * `VOID_ELEMENTS`, `SKIP_PROPS`, …). Empirically 8000 transforms added
9
+ * ~0.2KB/call of short-lived (collectable) allocation — no retention path.
10
+ *
11
+ * Not a heap-threshold test (those are flaky under parallel vitest / GC
12
+ * timing — the project explicitly avoids them). Instead this locks the
13
+ * DETERMINISTIC structural guarantee a leak/contamination regression would
14
+ * break: cross-call output isolation + constant-Set integrity. If someone
15
+ * later hoists a per-transform cache to module scope (the classic leak
16
+ * regression), the same input would start producing drifting output and/or
17
+ * the constant Sets would mutate — caught here without heap timing.
18
+ */
19
+ import { describe, expect, it } from 'vitest'
20
+ import { transformJSX_JS } from '../jsx'
21
+
22
+ const emit = (c: string): string => transformJSX_JS(c, 'c.tsx').code ?? ''
23
+
24
+ describe('Round 6 — transform state is per-call isolated (leak/contamination gate)', () => {
25
+ it('identical input → byte-identical output across 500 interleaved calls', () => {
26
+ const a = `function A(p){ const v=p.x; return <ul>{p.items.map(i => <li>{v}{i}</li>)}</ul> }`
27
+ const b = `function B(){ const s=signal(0); return <button onClick={()=>s.set(1)}>{s()}</button> }`
28
+ const a0 = emit(a)
29
+ const b0 = emit(b)
30
+ for (let i = 0; i < 500; i++) {
31
+ // Interleave different shapes so any module-level cache keyed by
32
+ // node.start (collides across files) would drift the output.
33
+ expect(emit(b)).toBe(b0)
34
+ expect(emit(a)).toBe(a0)
35
+ }
36
+ })
37
+
38
+ it('constant lookup Sets are not mutated by transforms', () => {
39
+ // PURE_CALLS / VOID_ELEMENTS behavior must be stable after heavy use.
40
+ for (let i = 0; i < 200; i++) {
41
+ emit(`function C(p){ return <div>{Math.max(p.x, 0)}</div> }`)
42
+ emit(`function C(){ return <br /> }`)
43
+ }
44
+ // br stays verbatim (VOID/self-closing contract) and Math.max with a
45
+ // dynamic arg stays reactive (PURE_CALLS not corrupted to "always pure").
46
+ expect(emit(`function C(){ return <br /> }`)).toContain('<br />')
47
+ expect(emit(`function C(p){ return <div>{Math.max(p.x,0)}</div> }`)).toContain('_bind(')
48
+ })
49
+ })