@pyreon/compiler 0.18.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 (41) hide show
  1. package/lib/analysis/index.js.html +1 -1
  2. package/lib/index.js +2081 -1262
  3. package/lib/types/index.d.ts +310 -125
  4. package/package.json +14 -12
  5. package/src/defer-inline.ts +397 -157
  6. package/src/index.ts +14 -2
  7. package/src/jsx.ts +784 -19
  8. package/src/load-native.ts +1 -0
  9. package/src/manifest.ts +280 -0
  10. package/src/pyreon-intercept.ts +164 -0
  11. package/src/react-intercept.ts +59 -0
  12. package/src/reactivity-lens.ts +190 -0
  13. package/src/tests/backend-parity-r7-r9.test.ts +91 -0
  14. package/src/tests/backend-prop-derived-callback-divergence.test.ts +74 -0
  15. package/src/tests/collapse-bail-census.test.ts +245 -0
  16. package/src/tests/collapse-key-source-hygiene.test.ts +88 -0
  17. package/src/tests/defer-inline.test.ts +209 -21
  18. package/src/tests/detector-tag-consistency.test.ts +2 -0
  19. package/src/tests/element-valued-const-child.test.ts +61 -0
  20. package/src/tests/falsy-child-characterization.test.ts +48 -0
  21. package/src/tests/malformed-input-resilience.test.ts +50 -0
  22. package/src/tests/manifest-snapshot.test.ts +55 -0
  23. package/src/tests/native-equivalence.test.ts +104 -3
  24. package/src/tests/partial-collapse-detector.test.ts +121 -0
  25. package/src/tests/partial-collapse-emit.test.ts +104 -0
  26. package/src/tests/partial-collapse-robustness.test.ts +53 -0
  27. package/src/tests/prop-derived-shadow.test.ts +96 -0
  28. package/src/tests/pure-call-reactive-args.test.ts +50 -0
  29. package/src/tests/pyreon-intercept.test.ts +189 -0
  30. package/src/tests/r13-callback-stmt-equivalence.test.ts +58 -0
  31. package/src/tests/r14-ssr-mode-parity.test.ts +51 -0
  32. package/src/tests/r15-elemconst-propderived.test.ts +47 -0
  33. package/src/tests/r19-defer-inline-robust.test.ts +54 -0
  34. package/src/tests/r20-backend-equivalence-sweep.test.ts +50 -0
  35. package/src/tests/react-intercept.test.ts +50 -2
  36. package/src/tests/reactivity-lens.test.ts +170 -0
  37. package/src/tests/rocketstyle-collapse.test.ts +208 -0
  38. package/src/tests/signal-autocall-shadow.test.ts +86 -0
  39. package/src/tests/sourcemap-fidelity.test.ts +77 -0
  40. package/src/tests/static-text-baking.test.ts +64 -0
  41. package/src/tests/transform-state-isolation.test.ts +49 -0
@@ -0,0 +1,170 @@
1
+ import { transformJSX_JS } from '../jsx'
2
+ import { analyzeReactivity, formatReactivityLens } from '../reactivity-lens'
3
+ import type { ReactivityFinding } from '../reactivity-lens'
4
+
5
+ /**
6
+ * Reactivity Lens — unit + drift gate.
7
+ *
8
+ * The load-bearing correctness contract: a lens span is a FAITHFUL RECORD of
9
+ * a codegen decision, never an approximation. Two invariants are gated here:
10
+ *
11
+ * 1. **Additive** — collecting the lens does NOT change emitted `code`
12
+ * (kill-criterion a). Bisect: if a future edit makes lens collection
13
+ * mutate codegen, `additive` fails.
14
+ * 2. **Drift** — every positive `reactive*` span's OUTPUT carries the
15
+ * matching codegen token (`_bind`/`_bindText`/`_rp`), and every
16
+ * `static-text` span's text is NOT reactively bound (kill-criterion b).
17
+ * Bisect: reverting the `lens(...)` call at any instrumented site drops
18
+ * the corresponding span and the matching `expect(kinds).toContain(...)`
19
+ * fails — documented per-fixture below.
20
+ */
21
+
22
+ function kindsAt(code: string): string[] {
23
+ return analyzeReactivity(code).findings.map((f) => f.kind)
24
+ }
25
+ function find(code: string, kind: string): ReactivityFinding[] {
26
+ return analyzeReactivity(code).findings.filter((f) => f.kind === kind)
27
+ }
28
+ function sliceFinding(code: string, f: ReactivityFinding): string {
29
+ // Re-derive the byte slice from line/col for a human-readable assertion.
30
+ const lines = code.split('\n')
31
+ const line = lines[f.line - 1] ?? ''
32
+ return f.endLine === f.line
33
+ ? line.slice(f.column, f.endColumn)
34
+ : line.slice(f.column)
35
+ }
36
+
37
+ describe('reactivity-lens — additive contract (kill-criterion a)', () => {
38
+ const FIXTURES = [
39
+ `function C(){ return <div>{count()}</div> }`,
40
+ `function C(p){ return <span>{p.label}</span> }`,
41
+ `function C(){ return <Box title={n()} /> }`,
42
+ `function C(){ return <div class="static">hi</div> }`,
43
+ `function C(){ return <a class={() => cls()}>x</a> }`,
44
+ `const x = 1; function C(){ const {a}=props; return <i>{a}</i> }`,
45
+ ]
46
+
47
+ it('lens collection NEVER changes emitted code (byte-identical)', () => {
48
+ for (const src of FIXTURES) {
49
+ const off = transformJSX_JS(src, 'f.tsx').code
50
+ const on = transformJSX_JS(src, 'f.tsx', { reactivityLens: true }).code
51
+ expect(on).toBe(off)
52
+ }
53
+ })
54
+
55
+ it('lens field is absent unless opted in', () => {
56
+ const r = transformJSX_JS(FIXTURES[0]!, 'f.tsx')
57
+ expect(r.reactivityLens).toBeUndefined()
58
+ const r2 = transformJSX_JS(FIXTURES[0]!, 'f.tsx', { reactivityLens: true })
59
+ expect(Array.isArray(r2.reactivityLens)).toBe(true)
60
+ })
61
+ })
62
+
63
+ describe('reactivity-lens — drift gate (positive claim = codegen record)', () => {
64
+ it('reactive text: {count()} → reactive span + _bind in output', () => {
65
+ const src = `function C(){ return <div>{count()}</div> }`
66
+ const reactive = find(src, 'reactive')
67
+ expect(reactive.length).toBe(1)
68
+ expect(sliceFinding(src, reactive[0]!)).toBe('count()')
69
+ // Drift proof: the codegen actually emitted a reactive binding.
70
+ const out = transformJSX_JS(src, 'f.tsx').code
71
+ expect(out).toMatch(/_bind(Text|Direct)?\(/)
72
+ expect(kindsAt(src)).not.toContain('static-text')
73
+ })
74
+
75
+ it('static text: {p.x}-free plain identifier → static-text, NOT reactive', () => {
76
+ const src = `function C(){ const label = "hi"; return <div>{label}</div> }`
77
+ const k = kindsAt(src)
78
+ expect(k).toContain('static-text')
79
+ expect(k).not.toContain('reactive')
80
+ const st = find(src, 'static-text')[0]!
81
+ expect(sliceFinding(src, st)).toBe('label')
82
+ // Drift proof: codegen baked it (no reactive binding helper for this).
83
+ const out = transformJSX_JS(src, 'f.tsx').code
84
+ expect(out).not.toMatch(/_bind\(\(\) => \{ \w+\.data = label \}/)
85
+ })
86
+
87
+ it('reactive prop: <Box title={n()} /> → reactive-prop + _rp in output', () => {
88
+ const src = `function C(){ return <Box title={n()} /> }`
89
+ const rp = find(src, 'reactive-prop')
90
+ expect(rp.length).toBe(1)
91
+ expect(sliceFinding(src, rp[0]!)).toBe('n()')
92
+ expect(transformJSX_JS(src, 'f.tsx').code).toContain('_rp(() =>')
93
+ })
94
+
95
+ it('hoisted static: static JSX in a non-template position → hoisted-static + module preamble', () => {
96
+ // A top-level returned static element becomes a `_tpl()` clone (template
97
+ // path) — that's not a hoist, and the lens correctly stays silent (no
98
+ // span = "not asserted"). maybeHoist only fires for static JSX in an
99
+ // expression slot of a non-DOM (component) parent; that's the faithful
100
+ // trigger and what the lens records.
101
+ const src = `function C(){ return <Comp>{<b class="x">hi</b>}</Comp> }`
102
+ const hs = find(src, 'hoisted-static')
103
+ expect(hs.length).toBeGreaterThanOrEqual(1)
104
+ expect(transformJSX_JS(src, 'f.tsx').code).toMatch(/const _\$h\d+ =/)
105
+ })
106
+
107
+ it('reactive attr: class={() => cls()} → reactive-attr', () => {
108
+ const src = `function C(){ return <a class={() => cls()}>x</a> }`
109
+ const ra = find(src, 'reactive-attr')
110
+ expect(ra.length).toBe(1)
111
+ expect(ra[0]!.detail).toContain('class')
112
+ })
113
+ })
114
+
115
+ describe('reactivity-lens — footgun merge (existing detectPyreonPatterns)', () => {
116
+ it('param-destructured props surface a footgun finding with the detector code', () => {
117
+ // detectPyreonPatterns catches the PARAMETER-destructure shape
118
+ // `({ name })`. The body-scope `const {x}=props` shape is the static
119
+ // layer's known cliff (doc-only anti-pattern, no reliable AST detector)
120
+ // — the lens's structural `static-text`/`reactive` signals are what
121
+ // compensate for that downstream. This asserts the merge surfaces
122
+ // whatever the existing detector finds, faithfully.
123
+ const src = `function C({ name }){ return <div>{name}</div> }`
124
+ const fg = find(src, 'footgun')
125
+ expect(fg.length).toBeGreaterThanOrEqual(1)
126
+ expect(fg.some((f) => f.code === 'props-destructured')).toBe(true)
127
+ })
128
+
129
+ it('findings are sorted by (line, column)', () => {
130
+ const src = [
131
+ `function C(props){`,
132
+ ` const { a } = props`,
133
+ ` return <div>{count()}</div>`,
134
+ `}`,
135
+ ].join('\n')
136
+ const { findings } = analyzeReactivity(src)
137
+ for (let i = 1; i < findings.length; i++) {
138
+ const prev = findings[i - 1]!
139
+ const cur = findings[i]!
140
+ expect(
141
+ prev.line < cur.line ||
142
+ (prev.line === cur.line && prev.column <= cur.column),
143
+ ).toBe(true)
144
+ }
145
+ })
146
+ })
147
+
148
+ describe('reactivity-lens — zero false "live" on idiomatic code (kill-criterion b)', () => {
149
+ it('purely static component yields no reactive* findings', () => {
150
+ const src = `function Card(){ return <div class="card"><h2>Title</h2><p>Body</p></div> }`
151
+ const k = kindsAt(src)
152
+ expect(k).not.toContain('reactive')
153
+ expect(k).not.toContain('reactive-prop')
154
+ expect(k).not.toContain('reactive-attr')
155
+ })
156
+
157
+ it('parse failure → empty, never throws', () => {
158
+ const r = analyzeReactivity(`function C( { return <div`)
159
+ expect(Array.isArray(r.findings)).toBe(true)
160
+ })
161
+ })
162
+
163
+ describe('reactivity-lens — formatter', () => {
164
+ it('renders annotated source with kind badges', () => {
165
+ const src = `function C(){ return <div>{count()}</div> }`
166
+ const out = formatReactivityLens(src, analyzeReactivity(src))
167
+ expect(out).toContain('live')
168
+ expect(out).toContain('1 |')
169
+ })
170
+ })
@@ -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
+ })