@pyreon/compiler 0.16.0 → 0.19.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.
@@ -0,0 +1,387 @@
1
+ import { transformDeferInline } from '../defer-inline'
2
+
3
+ describe('transformDeferInline — basic rewrites', () => {
4
+ test('rewrites <Defer when={x}><Modal /></Defer> with named import', () => {
5
+ const input = `
6
+ import { Defer } from '@pyreon/core'
7
+ import { Modal } from './Modal'
8
+
9
+ export function App() {
10
+ const open = () => true
11
+ return <Defer when={open}><Modal /></Defer>
12
+ }
13
+ `
14
+ const result = transformDeferInline(input, 'app.tsx')
15
+ expect(result.changed).toBe(true)
16
+ expect(result.code).not.toContain("import { Modal } from './Modal'")
17
+ expect(result.code).toContain(`chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}`)
18
+ expect(result.code).toContain('{(__C) => <__C />}')
19
+ })
20
+
21
+ test('rewrites with default import', () => {
22
+ const input = `
23
+ import { Defer } from '@pyreon/core'
24
+ import Modal from './Modal'
25
+
26
+ export function App() {
27
+ return <Defer when={() => true}><Modal /></Defer>
28
+ }
29
+ `
30
+ const result = transformDeferInline(input, 'app.tsx')
31
+ expect(result.changed).toBe(true)
32
+ expect(result.code).not.toContain('import Modal from')
33
+ expect(result.code).toContain(`chunk={() => import('./Modal')}`)
34
+ expect(result.code).not.toContain(`.then((__m) =>`)
35
+ })
36
+
37
+ test('preserves other props on Defer (fallback, when, on)', () => {
38
+ const input = `
39
+ import { Defer } from '@pyreon/core'
40
+ import { Modal } from './Modal'
41
+ export function App() {
42
+ return <Defer when={() => true} fallback={<span>loading</span>}><Modal /></Defer>
43
+ }
44
+ `
45
+ const result = transformDeferInline(input, 'app.tsx')
46
+ expect(result.changed).toBe(true)
47
+ expect(result.code).toContain('when={() => true}')
48
+ expect(result.code).toContain('fallback={<span>loading</span>}')
49
+ })
50
+
51
+ test('works for on="visible" trigger', () => {
52
+ const input = `
53
+ import { Defer } from '@pyreon/core'
54
+ import { Comments } from './Comments'
55
+ export function Post() {
56
+ return <Defer on="visible"><Comments /></Defer>
57
+ }
58
+ `
59
+ const result = transformDeferInline(input, 'post.tsx')
60
+ expect(result.changed).toBe(true)
61
+ expect(result.code).toContain('on="visible"')
62
+ expect(result.code).toContain(`chunk={() => import('./Comments').then((__m) => ({ default: __m.Comments }))}`)
63
+ })
64
+ })
65
+
66
+ describe('transformDeferInline — bail-out cases', () => {
67
+ test('leaves unchanged when chunk prop is already provided', () => {
68
+ const input = `
69
+ import { Defer } from '@pyreon/core'
70
+ import { Modal } from './Modal'
71
+ export function App() {
72
+ return (
73
+ <Defer chunk={() => import('./Modal')} when={() => true}>
74
+ {Modal => <Modal />}
75
+ </Defer>
76
+ )
77
+ }
78
+ `
79
+ const result = transformDeferInline(input, 'app.tsx')
80
+ expect(result.changed).toBe(false)
81
+ expect(result.code).toBe(input)
82
+ expect(result.warnings).toEqual([])
83
+ })
84
+
85
+ test('warns when inline child is also used outside the Defer', () => {
86
+ const input = `
87
+ import { Defer } from '@pyreon/core'
88
+ import { Modal } from './Modal'
89
+ const eagerCopy = <Modal />
90
+ export function App() {
91
+ return <Defer when={() => true}><Modal /></Defer>
92
+ }
93
+ `
94
+ const result = transformDeferInline(input, 'app.tsx')
95
+ expect(result.changed).toBe(false)
96
+ expect(result.warnings).toHaveLength(1)
97
+ expect(result.warnings[0]!.code).toBe('defer-inline/import-used-elsewhere')
98
+ })
99
+
100
+ test('warns when inline child is not imported', () => {
101
+ const input = `
102
+ import { Defer } from '@pyreon/core'
103
+ export function App() {
104
+ return <Defer when={() => true}><LocalThing /></Defer>
105
+ }
106
+ function LocalThing() { return null }
107
+ `
108
+ const result = transformDeferInline(input, 'app.tsx')
109
+ expect(result.changed).toBe(false)
110
+ expect(result.warnings).toHaveLength(1)
111
+ expect(result.warnings[0]!.code).toBe('defer-inline/import-not-found')
112
+ })
113
+
114
+ test('skips Defer with multiple children (still requires render-prop form)', () => {
115
+ const input = `
116
+ import { Defer } from '@pyreon/core'
117
+ import { Modal } from './Modal'
118
+ import { Spinner } from './Spinner'
119
+ export function App() {
120
+ return <Defer when={() => true}><Modal /><Spinner /></Defer>
121
+ }
122
+ `
123
+ const result = transformDeferInline(input, 'app.tsx')
124
+ // v2 now emits a `multiple-children` warning so the author knows to use
125
+ // the explicit `chunk` form. v1 was silent — that was a footgun.
126
+ expect(result.changed).toBe(false)
127
+ expect(result.warnings[0]?.code).toBe('defer-inline/multiple-children')
128
+ })
129
+
130
+ test('fast-path: no Defer in source returns unchanged', () => {
131
+ const input = `
132
+ import { signal } from '@pyreon/reactivity'
133
+ export const count = signal(0)
134
+ `
135
+ const result = transformDeferInline(input, 'count.ts')
136
+ expect(result.changed).toBe(false)
137
+ expect(result.code).toBe(input)
138
+ })
139
+
140
+ test('does not blow up on syntactically-invalid source — returns unchanged', () => {
141
+ const input = `import {{{ Defer broken syntax`
142
+ const result = transformDeferInline(input, 'broken.tsx')
143
+ expect(result.changed).toBe(false)
144
+ // Returns the input unchanged; downstream parser will surface the real error.
145
+ expect(result.code).toBe(input)
146
+ })
147
+
148
+ // v1 bailed on `{ Modal as M }`. v2 handles renamed imports — the
149
+ // local name in JSX is `M`, but the chunk extracts `__m.Modal` (the
150
+ // original exported name). See the positive test in the v2 section.
151
+ })
152
+
153
+ describe('transformDeferInline — v2 capabilities', () => {
154
+ test('preserves props on inline child', () => {
155
+ const input = `
156
+ import { Defer } from '@pyreon/core'
157
+ import { Modal } from './Modal'
158
+ export function App() {
159
+ return <Defer when={() => true}><Modal title="Confirm" size="md" /></Defer>
160
+ }
161
+ `
162
+ const result = transformDeferInline(input, 'app.tsx')
163
+ expect(result.changed).toBe(true)
164
+ expect(result.code).not.toContain("import { Modal }")
165
+ // Props pass through verbatim. Only the component name is replaced.
166
+ expect(result.code).toContain('{(__C) => <__C title="Confirm" size="md" />}')
167
+ })
168
+
169
+ test('preserves nested children on inline child (non-self-closing)', () => {
170
+ const input = `
171
+ import { Defer } from '@pyreon/core'
172
+ import { Modal } from './Modal'
173
+ export function App() {
174
+ return (
175
+ <Defer when={() => true}>
176
+ <Modal title="Hello">
177
+ <p>nested content</p>
178
+ </Modal>
179
+ </Defer>
180
+ )
181
+ }
182
+ `
183
+ const result = transformDeferInline(input, 'app.tsx')
184
+ expect(result.changed).toBe(true)
185
+ // Both opening AND closing tag names replaced with __C; nested JSX intact.
186
+ expect(result.code).toContain('<__C title="Hello">')
187
+ expect(result.code).toContain('</__C>')
188
+ expect(result.code).toContain('<p>nested content</p>')
189
+ })
190
+
191
+ test('captures closure variables via render-prop scope (signal in handler)', () => {
192
+ const input = `
193
+ import { Defer } from '@pyreon/core'
194
+ import { signal } from '@pyreon/reactivity'
195
+ import { Modal } from './Modal'
196
+
197
+ export function App() {
198
+ const open = signal(false)
199
+ const count = signal(0)
200
+ return (
201
+ <Defer when={open}>
202
+ <Modal count={count} onClose={() => open.set(false)} />
203
+ </Defer>
204
+ )
205
+ }
206
+ `
207
+ const result = transformDeferInline(input, 'app.tsx')
208
+ expect(result.changed).toBe(true)
209
+ // The render-prop arrow naturally captures `count` + `open.set` from
210
+ // the App function's scope — no closure-tracking pass needed, JS
211
+ // lexical scope just works.
212
+ expect(result.code).toContain('<__C count={count} onClose={() => open.set(false)} />')
213
+ })
214
+
215
+ test('handles renamed imports — { Modal as M }', () => {
216
+ const input = `
217
+ import { Defer } from '@pyreon/core'
218
+ import { Modal as M } from './Modal'
219
+ export function App() {
220
+ return <Defer when={() => true}><M /></Defer>
221
+ }
222
+ `
223
+ const result = transformDeferInline(input, 'app.tsx')
224
+ expect(result.changed).toBe(true)
225
+ // Local name `M` is used at the JSX site, but the chunk extracts
226
+ // `__m.Modal` (the original exported name from './Modal').
227
+ expect(result.code).toContain(`chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}`)
228
+ expect(result.code).not.toContain("import { Modal as M }")
229
+ expect(result.code).toContain('{(__C) => <__C />}')
230
+ })
231
+
232
+ test('multi-specifier import: drops only the Defer-targeted binding', () => {
233
+ const input = `
234
+ import { Defer } from '@pyreon/core'
235
+ import { Modal, OtherThing } from './shared'
236
+ export function App() {
237
+ const use = OtherThing
238
+ return <Defer when={() => true}><Modal /></Defer>
239
+ }
240
+ `
241
+ const result = transformDeferInline(input, 'app.tsx')
242
+ expect(result.changed).toBe(true)
243
+ // OtherThing is referenced elsewhere — its import must survive.
244
+ expect(result.code).toContain("OtherThing")
245
+ expect(result.code).toMatch(/import \{\s*OtherThing\s*\} from '\.\/shared'/)
246
+ // Modal binding is gone from the import declaration.
247
+ expect(result.code).not.toMatch(/import \{[^}]*\bModal\b[^}]*\}/)
248
+ // ...but the dynamic chunk pulls Modal from './shared' (same source).
249
+ expect(result.code).toContain(`chunk={() => import('./shared').then((__m) => ({ default: __m.Modal }))}`)
250
+ })
251
+ })
252
+
253
+ describe('transformDeferInline — multiple Defers in one file', () => {
254
+ test('rewrites two independent Defers with distinct imports', () => {
255
+ const input = `
256
+ import { Defer } from '@pyreon/core'
257
+ import { Modal } from './Modal'
258
+ import { Comments } from './Comments'
259
+ export function App() {
260
+ return (
261
+ <div>
262
+ <Defer when={() => true}><Modal /></Defer>
263
+ <Defer on="visible"><Comments /></Defer>
264
+ </div>
265
+ )
266
+ }
267
+ `
268
+ const result = transformDeferInline(input, 'app.tsx')
269
+ expect(result.changed).toBe(true)
270
+ expect(result.code).not.toContain("import { Modal } from './Modal'")
271
+ expect(result.code).not.toContain("import { Comments } from './Comments'")
272
+ expect(result.code).toContain(`chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}`)
273
+ expect(result.code).toContain(`chunk={() => import('./Comments').then((__m) => ({ default: __m.Comments }))}`)
274
+ })
275
+ })
276
+
277
+ // Gap 4 from the v2 follow-up roadmap. Namespace imports were the last
278
+ // inline-Defer shape that fell back to the explicit form — closing this
279
+ // gap means EVERY common import shape is supported by the inline form.
280
+ describe('transformDeferInline — namespace imports (v3)', () => {
281
+ test('rewrites <M.Modal /> with namespace import — chunk extracts __m.Modal', () => {
282
+ const input = `
283
+ import { Defer } from '@pyreon/core'
284
+ import * as M from './Modal'
285
+ export function App() {
286
+ return <Defer when={() => true}><M.Modal /></Defer>
287
+ }
288
+ `
289
+ const result = transformDeferInline(input, 'app.tsx')
290
+ expect(result.changed).toBe(true)
291
+ expect(result.code).toContain(`chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}`)
292
+ // M.Modal in the JSX replaced with __C (the whole member expression
293
+ // is the "name" range that gets substituted).
294
+ expect(result.code).toContain('{(__C) => <__C />}')
295
+ expect(result.code).not.toContain('import * as M from')
296
+ })
297
+
298
+ test('rewrites with props on member-expression child', () => {
299
+ const input = `
300
+ import { Defer } from '@pyreon/core'
301
+ import * as M from './Modal'
302
+ export function App() {
303
+ return <Defer when={() => true}><M.Modal title="hi" /></Defer>
304
+ }
305
+ `
306
+ const result = transformDeferInline(input, 'app.tsx')
307
+ expect(result.changed).toBe(true)
308
+ expect(result.code).toContain('{(__C) => <__C title="hi" />}')
309
+ })
310
+
311
+ test('non-self-closing member-expression child preserves opening + closing replacement', () => {
312
+ const input = `
313
+ import { Defer } from '@pyreon/core'
314
+ import * as M from './Modal'
315
+ export function App() {
316
+ return <Defer when={() => true}><M.Modal title="hi"><span>body</span></M.Modal></Defer>
317
+ }
318
+ `
319
+ const result = transformDeferInline(input, 'app.tsx')
320
+ expect(result.changed).toBe(true)
321
+ expect(result.code).toContain('{(__C) => <__C title="hi"><span>body</span></__C>}')
322
+ })
323
+
324
+ test('bails when namespace is referenced elsewhere in the file', () => {
325
+ // `M` is used for multiple components. Removing the static import
326
+ // would break the other usage AND the dynamic import becomes a
327
+ // no-op (Rolldown bundles the module statically when ANY part is
328
+ // referenced).
329
+ const input = `
330
+ import { Defer } from '@pyreon/core'
331
+ import * as M from './Modal'
332
+ export function App() {
333
+ void M.Settings
334
+ return <Defer when={() => true}><M.Modal /></Defer>
335
+ }
336
+ `
337
+ const result = transformDeferInline(input, 'app.tsx')
338
+ expect(result.changed).toBe(false)
339
+ expect(result.warnings).toHaveLength(1)
340
+ expect(result.warnings[0]!.code).toBe('defer-inline/import-used-elsewhere')
341
+ })
342
+
343
+ test('bails on deeper member expression — <M.Sub.X /> not supported', () => {
344
+ const input = `
345
+ import { Defer } from '@pyreon/core'
346
+ import * as M from './Modal'
347
+ export function App() {
348
+ return <Defer when={() => true}><M.Sub.Modal /></Defer>
349
+ }
350
+ `
351
+ const result = transformDeferInline(input, 'app.tsx')
352
+ // analyzeChildElement returns null for non-depth-1 member
353
+ // expressions → no match in findDeferMatches → no warning. The
354
+ // Defer is left alone; runtime errors with "missing chunk".
355
+ expect(result.changed).toBe(false)
356
+ })
357
+
358
+ test('bails when member property is lowercase — <M.helper /> is not a component', () => {
359
+ const input = `
360
+ import { Defer } from '@pyreon/core'
361
+ import * as M from './lib'
362
+ export function App() {
363
+ return <Defer when={() => true}><M.helper /></Defer>
364
+ }
365
+ `
366
+ const result = transformDeferInline(input, 'app.tsx')
367
+ expect(result.changed).toBe(false)
368
+ })
369
+
370
+ test('bails when member expression but import is default (not namespace)', () => {
371
+ // `import M from './X'` (default) followed by `<M.Modal />` is a
372
+ // member access on the default-exported component itself, not a
373
+ // namespace lookup. Different semantics; out of scope. Compiler
374
+ // emits `unsupported-import-shape` so the author knows why.
375
+ const input = `
376
+ import { Defer } from '@pyreon/core'
377
+ import M from './Modal'
378
+ export function App() {
379
+ return <Defer when={() => true}><M.Modal /></Defer>
380
+ }
381
+ `
382
+ const result = transformDeferInline(input, 'app.tsx')
383
+ expect(result.changed).toBe(false)
384
+ expect(result.warnings).toHaveLength(1)
385
+ expect(result.warnings[0]!.code).toBe('defer-inline/unsupported-import-shape')
386
+ })
387
+ })
@@ -30,6 +30,7 @@ const COMPILER_CODES = [
30
30
  'for-missing-by',
31
31
  'for-with-key',
32
32
  'props-destructured',
33
+ 'props-destructured-body',
33
34
  'process-dev-gate',
34
35
  'empty-theme',
35
36
  'raw-add-event-listener',
@@ -40,6 +41,7 @@ const COMPILER_CODES = [
40
41
  'static-return-null-conditional',
41
42
  'as-unknown-as-vnodechild',
42
43
  'island-never-with-registry-entry',
44
+ 'query-options-as-function',
43
45
  ] as const
44
46
  type CompilerCode = (typeof COMPILER_CODES)[number]
45
47
 
@@ -283,13 +283,33 @@ describe('JSX transform — component elements', () => {
283
283
  expect(result).toContain('_rp(')
284
284
  })
285
285
 
286
- test('spread props on component pass through without _rp wrapping', () => {
286
+ test('spread props on component are wrapped with _wrapSpread to preserve reactivity', () => {
287
287
  const result = t('<Comp {...getProps()} label="hi" />')
288
- // Spread should remain as-is
289
- expect(result).toContain('{...getProps()}')
288
+ // Spread argument is wrapped so getter-shaped reactive props survive
289
+ // esbuild's JS-level object spread in the automatic JSX runtime.
290
+ expect(result).toContain('{..._wrapSpread(getProps())}')
290
291
  // Static label should not be wrapped
291
292
  expect(result).not.toContain('_rp(() => "hi")')
292
293
  })
294
+
295
+ test('spread props on DOM elements are NOT wrapped (handled by template path)', () => {
296
+ const result = t('<div {...rest} class="x" />')
297
+ // DOM-element spreads go through the template path's _applyProps.
298
+ expect(result).toContain('{...rest}')
299
+ expect(result).not.toContain('_wrapSpread')
300
+ })
301
+
302
+ test('multiple spread sources on a component each get wrapped independently', () => {
303
+ const result = t('<Comp {...a} {...b} foo="x" />')
304
+ expect(result).toContain('{..._wrapSpread(a)}')
305
+ expect(result).toContain('{..._wrapSpread(b)}')
306
+ })
307
+
308
+ test('_wrapSpread emission is idempotent on re-compilation', () => {
309
+ const result = t('<Comp {..._wrapSpread(rest)} />')
310
+ // Should not double-wrap.
311
+ expect(result).not.toContain('_wrapSpread(_wrapSpread(')
312
+ })
293
313
  })
294
314
 
295
315
  // ─── Spread attributes ──────────────────────────────────────────────────────
@@ -0,0 +1,55 @@
1
+ import {
2
+ renderApiReferenceEntries,
3
+ renderLlmsFullSection,
4
+ renderLlmsTxtLine,
5
+ } from '@pyreon/manifest'
6
+ import manifest from '../manifest'
7
+
8
+ describe('gen-docs — compiler snapshot', () => {
9
+ it('renders a llms.txt bullet starting with the package prefix', () => {
10
+ const line = renderLlmsTxtLine(manifest)
11
+ expect(line.startsWith('- @pyreon/compiler —')).toBe(true)
12
+ })
13
+
14
+ it('renders a llms-full.txt section with the right header', () => {
15
+ const section = renderLlmsFullSection(manifest)
16
+ expect(section.startsWith('## @pyreon/compiler —')).toBe(true)
17
+ expect(section).toContain('```typescript')
18
+ })
19
+
20
+ it('renders MCP api-reference entries for every api[] item', () => {
21
+ const record = renderApiReferenceEntries(manifest)
22
+ expect(Object.keys(record).sort()).toEqual([
23
+ 'compiler/analyzeReactivity',
24
+ 'compiler/auditIslands',
25
+ 'compiler/auditSsg',
26
+ 'compiler/auditTestEnvironment',
27
+ 'compiler/detectPyreonPatterns',
28
+ 'compiler/detectReactPatterns',
29
+ 'compiler/diagnoseError',
30
+ 'compiler/formatIslandAudit',
31
+ 'compiler/formatReactivityLens',
32
+ 'compiler/formatSsgAudit',
33
+ 'compiler/formatTestAudit',
34
+ 'compiler/generateContext',
35
+ 'compiler/hasPyreonPatterns',
36
+ 'compiler/hasReactPatterns',
37
+ 'compiler/migrateReactCode',
38
+ 'compiler/transformDeferInline',
39
+ 'compiler/transformJSX',
40
+ 'compiler/transformJSX_JS',
41
+ ])
42
+ })
43
+
44
+ it('flags the experimental Reactivity-Lens entries', () => {
45
+ const r = renderApiReferenceEntries(manifest)
46
+ expect(r['compiler/analyzeReactivity']?.notes).toContain('[EXPERIMENTAL]')
47
+ expect(r['compiler/formatReactivityLens']?.notes).toContain('[EXPERIMENTAL]')
48
+ })
49
+
50
+ it('carries the foot-gun catalog into MCP mistakes for flagship APIs', () => {
51
+ const r = renderApiReferenceEntries(manifest)
52
+ expect(r['compiler/transformJSX']?.mistakes).toBeTruthy()
53
+ expect(r['compiler/detectPyreonPatterns']?.mistakes).toContain('fixable')
54
+ })
55
+ })
@@ -5,11 +5,23 @@
5
5
  * between the two backends.
6
6
  */
7
7
  import { transformJSX_JS } from '../jsx'
8
+ import type { ReactivitySpan } from '../jsx'
8
9
 
9
10
  // Load native if available
10
- let nativeTransform: ((code: string, filename: string, ssr: boolean, knownSignals: string[] | null) => {
11
- code: string; usesTemplates?: boolean | null; warnings: Array<{ message: string; line: number; column: number; code: string }>
12
- }) | null = null
11
+ let nativeTransform:
12
+ | ((
13
+ code: string,
14
+ filename: string,
15
+ ssr: boolean,
16
+ knownSignals: string[] | null,
17
+ reactivityLens?: boolean,
18
+ ) => {
19
+ code: string
20
+ usesTemplates?: boolean | null
21
+ warnings: Array<{ message: string; line: number; column: number; code: string }>
22
+ reactivityLens?: ReactivitySpan[] | null
23
+ })
24
+ | null = null
13
25
 
14
26
  try {
15
27
  const path = require('node:path')
@@ -39,6 +51,50 @@ function compareSsr(input: string) {
39
51
  expect(rs.code).toBe(js.code)
40
52
  }
41
53
 
54
+ // Reactivity-lens cross-backend gate (Phase 3). Asserts the Rust binary
55
+ // emits the SAME sidecar the JS oracle does. Two contracts:
56
+ // 1. ADDITIVE — `code` is byte-identical with the lens collected vs
57
+ // not, on BOTH backends (the option never affects codegen).
58
+ // 2. PARITY — the SET of recorded spans is identical JS↔Rust.
59
+ // Spans are compared order-independently: the LSP consumer sorts before
60
+ // rendering, so traversal order is NOT part of the contract — the SET of
61
+ // codegen decisions is. Sorting by (start,end,kind,detail) makes a
62
+ // missing/extra/wrong span fail loudly while ignoring walk order.
63
+ function canon(spans: ReactivitySpan[] | null | undefined): string[] {
64
+ return (spans ?? [])
65
+ .map(
66
+ (s) =>
67
+ `${s.start}|${s.end}|${s.line}|${s.column}|${s.endLine}|${s.endColumn}|${s.kind}|${s.detail}`,
68
+ )
69
+ .sort()
70
+ }
71
+
72
+ function compareLens(input: string, filename = 'test.tsx') {
73
+ const jsOff = transformJSX_JS(input, filename)
74
+ const jsOn = transformJSX_JS(input, filename, { reactivityLens: true })
75
+ const rsOff = nativeTransform!(input, filename, false, null, false)
76
+ const rsOn = nativeTransform!(input, filename, false, null, true)
77
+
78
+ // (1) additive — collecting the lens never changes emitted code, on
79
+ // either backend, and both backends still agree on that code.
80
+ expect(jsOn.code).toBe(jsOff.code)
81
+ expect(rsOn.code).toBe(rsOff.code)
82
+ expect(rsOn.code).toBe(jsOn.code)
83
+
84
+ // The opt-out path must NOT carry the sidecar (parity with JS, which
85
+ // omits the field entirely when not collecting).
86
+ expect(rsOff.reactivityLens == null).toBe(true)
87
+ expect(jsOff.reactivityLens == null).toBe(true)
88
+
89
+ // (2) parity — identical SET of spans.
90
+ const j = canon(jsOn.reactivityLens)
91
+ const r = canon(rsOn.reactivityLens)
92
+ expect(r).toEqual(j)
93
+ // Guard against the degenerate "both empty → trivially equal" pass:
94
+ // every fixture below is chosen to produce ≥1 span.
95
+ expect(j.length).toBeGreaterThan(0)
96
+ }
97
+
42
98
  // ─── Cross-backend equivalence ──────────────────────────────────────────────
43
99
 
44
100
  describeNative('Native vs JS equivalence — basic', () => {
@@ -729,3 +785,48 @@ describeNative('Native vs JS equivalence — DOM properties', () => {
729
785
  compare('<div><input title={x()} /></div>')
730
786
  })
731
787
  })
788
+
789
+ // ─── Reactivity-lens parity (Phase 3) ───────────────────────────────────────
790
+ // The Rust binary must emit the SAME sidecar as the JS oracle so the
791
+ // ~80% of users on the native path get the Lens too. Each fixture is
792
+ // chosen to exercise one of the five structural kinds; `compareLens`
793
+ // also asserts the additive guarantee (codegen byte-identical with the
794
+ // option on vs off, both backends) so this block doubles as the native
795
+ // regression guard for "the lens option must never affect output".
796
+ //
797
+ // Bisect-verified: removing ANY of the 6 `ctx.lens(...)` calls in
798
+ // native/src/lib.rs fails the matching fixture below with an
799
+ // array-length / element mismatch in `compareLens`'s parity assertion
800
+ // (e.g. dropping the `reactive-prop` call → `<Comp value={x()} />` fails
801
+ // `expect(r).toEqual(j)` because the Rust set is missing that span);
802
+ // restored → 9/9 pass.
803
+ describeNative('Reactivity-lens — JS↔Rust span parity', () => {
804
+ test('reactive text child (_bindText)', () =>
805
+ compareLens('<div>{count()}</div>'))
806
+
807
+ test('reactive accessor text child (() => …)', () =>
808
+ compareLens('<div>{() => count()}</div>'))
809
+
810
+ test('static-text child (baked once — the high-precision negative)', () =>
811
+ compareLens('<div>{someConst}</div>'))
812
+
813
+ test('reactive-prop on a component (_rp(() => …))', () =>
814
+ compareLens('<Comp value={count()} />'))
815
+
816
+ test('reactive-attr on a DOM element (live binding)', () =>
817
+ compareLens('<div><span title={count()}>hi</span></div>'))
818
+
819
+ test('hoisted-static (module-scope hoist)', () =>
820
+ compareLens('<Comp>{<b class="x">hi</b>}</Comp>'))
821
+
822
+ test('mixed: reactive + static + prop in one tree', () =>
823
+ compareLens(
824
+ '<section><Comp value={count()} /><p>{count()}</p><p>{label}</p></section>',
825
+ ))
826
+
827
+ test('multi-line source — line/column parity across newlines', () =>
828
+ compareLens('<div>\n {count()}\n <span title={other()}>\n z</span>\n</div>'))
829
+
830
+ test('signal auto-call shape — declared signal, bare {count} → reactive', () =>
831
+ compareLens('const count = signal(0); const App = () => <div>{count}</div>', 'auto.tsx'))
832
+ })