@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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +2081 -1262
- package/lib/types/index.d.ts +310 -125
- package/package.json +14 -12
- package/src/defer-inline.ts +397 -157
- package/src/index.ts +14 -2
- package/src/jsx.ts +784 -19
- package/src/load-native.ts +1 -0
- package/src/manifest.ts +280 -0
- package/src/pyreon-intercept.ts +164 -0
- package/src/react-intercept.ts +59 -0
- package/src/reactivity-lens.ts +190 -0
- package/src/tests/backend-parity-r7-r9.test.ts +91 -0
- package/src/tests/backend-prop-derived-callback-divergence.test.ts +74 -0
- package/src/tests/collapse-bail-census.test.ts +245 -0
- package/src/tests/collapse-key-source-hygiene.test.ts +88 -0
- package/src/tests/defer-inline.test.ts +209 -21
- package/src/tests/detector-tag-consistency.test.ts +2 -0
- package/src/tests/element-valued-const-child.test.ts +61 -0
- package/src/tests/falsy-child-characterization.test.ts +48 -0
- package/src/tests/malformed-input-resilience.test.ts +50 -0
- package/src/tests/manifest-snapshot.test.ts +55 -0
- package/src/tests/native-equivalence.test.ts +104 -3
- package/src/tests/partial-collapse-detector.test.ts +121 -0
- package/src/tests/partial-collapse-emit.test.ts +104 -0
- package/src/tests/partial-collapse-robustness.test.ts +53 -0
- package/src/tests/prop-derived-shadow.test.ts +96 -0
- package/src/tests/pure-call-reactive-args.test.ts +50 -0
- package/src/tests/pyreon-intercept.test.ts +189 -0
- package/src/tests/r13-callback-stmt-equivalence.test.ts +58 -0
- package/src/tests/r14-ssr-mode-parity.test.ts +51 -0
- package/src/tests/r15-elemconst-propderived.test.ts +47 -0
- package/src/tests/r19-defer-inline-robust.test.ts +54 -0
- package/src/tests/r20-backend-equivalence-sweep.test.ts +50 -0
- package/src/tests/react-intercept.test.ts +50 -2
- package/src/tests/reactivity-lens.test.ts +170 -0
- package/src/tests/rocketstyle-collapse.test.ts +208 -0
- package/src/tests/signal-autocall-shadow.test.ts +86 -0
- package/src/tests/sourcemap-fidelity.test.ts +77 -0
- package/src/tests/static-text-baking.test.ts +64 -0
- 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
|
+
})
|