@pyreon/compiler 0.19.0 → 0.21.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 +418 -18
- package/lib/types/index.d.ts +92 -1
- package/package.json +13 -12
- package/src/index.ts +2 -1
- package/src/jsx.ts +669 -17
- 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/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/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/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/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,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JS↔Rust backend parity — fixes the two compiler bugs scoped out of the
|
|
3
|
+
* 10-round hardening sweep (#686), in BOTH backends, 1:1.
|
|
4
|
+
*
|
|
5
|
+
* R7 — prop-derived inlining inside callback-nested JSX. Pre-fix the native
|
|
6
|
+
* backend's `collect_prop_derived_idents` had
|
|
7
|
+
* `Arrow|FunctionExpression => {}` (+ no JSX arm), so
|
|
8
|
+
* `const cls=props.t; items.map(i => <li class={cls}/>)` kept `class={cls}`
|
|
9
|
+
* (frozen const → reactivity SILENTLY LOST under the production-preferred
|
|
10
|
+
* native backend) while JS inlined `class={(props.t)}`. Fixed: the Rust arms
|
|
11
|
+
* recurse into fn bodies + JSX with a `pd_minus` scope filter that is
|
|
12
|
+
* byte-equivalent to the JS pass's enter/leave `shadowed` set — so recursing
|
|
13
|
+
* does NOT reintroduce the param-clobber the JS scope-aware pass guards. The
|
|
14
|
+
* JS scope-aware pass is included here too (origin/main lacked it), so a
|
|
15
|
+
* shadowing arrow param is not clobbered on EITHER backend.
|
|
16
|
+
*
|
|
17
|
+
* R9 — an element-valued `const`/`let` (`const h=<h1/>`) used as a bare JSX
|
|
18
|
+
* child was text-coerced (`createTextNode(h)` → "[object Object]") instead of
|
|
19
|
+
* mounted, on both backends. Fixed: both backends track element-valued
|
|
20
|
+
* bindings and route a bare `{h}` child through `_mountSlot` (the path
|
|
21
|
+
* `props.children` already used).
|
|
22
|
+
*
|
|
23
|
+
* Bisect (PR body): (a) revert the Rust `ArrowFunctionExpression`/
|
|
24
|
+
* `FunctionExpression` arms in native/src/lib.rs to `=> {}` + rebuild
|
|
25
|
+
* (`bun run build:native`) → the R7 cross-backend specs fail (Rust reverts to
|
|
26
|
+
* `class={cls}`). (b) Revert the `!shadowed.has(node.name)` guard in
|
|
27
|
+
* `resolveIdentifiersInText` → SHADOW_PARAM JS emits `(props.x) =>`
|
|
28
|
+
* (un-parseable) and diverges. (c) Revert the `isElementValuedIdent` clause
|
|
29
|
+
* in `processOneChild` (+ the Rust `is_element_valued_ident`) → R9 specs
|
|
30
|
+
* fail. Restore + rebuild → all pass.
|
|
31
|
+
*/
|
|
32
|
+
import { parseSync } from 'oxc-parser'
|
|
33
|
+
import { describe, expect, it } from 'vitest'
|
|
34
|
+
import { transformJSX, transformJSX_JS } from '../jsx'
|
|
35
|
+
|
|
36
|
+
const js = (c: string): string => transformJSX_JS(c, 'c.tsx').code ?? ''
|
|
37
|
+
const rust = (c: string): string => transformJSX(c, 'c.tsx').code ?? ''
|
|
38
|
+
const parses = (s: string): boolean => {
|
|
39
|
+
try {
|
|
40
|
+
return (parseSync('o.tsx', s).errors?.length ?? 0) === 0
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const R7_CALLBACK = `function C(props){ const cls = props.theme + '-btn'; return <ul>{props.items.map(i => <li class={cls}>{i}</li>)}</ul> }`
|
|
47
|
+
const R7_TRANSITIVE = `function C(props){ const a = props.x; const b = a + 1; return <ul>{props.items.map(i => <li>{b}</li>)}</ul> }`
|
|
48
|
+
const R7_SHADOW = `function C(props){ const a = props.x; return <ul>{props.items.map(a => <li>{a}</li>)}</ul> }`
|
|
49
|
+
const R7_DIRECT = `function C(props){ const a = props.x; return <div>{a}</div> }`
|
|
50
|
+
const R9_CONST = `function C(){ const header = <h1>T</h1>; return <div>{header}<p>x</p></div> }`
|
|
51
|
+
const R9_LET = `function C(){ let el = <a/>; return <div>{el}</div> }`
|
|
52
|
+
const R9_STR_CTRL = `function C(){ const t = 'T'; return <div>{t}<p/></div> }`
|
|
53
|
+
|
|
54
|
+
describe('Round 7 — callback-nested prop-derived inlining is 1:1 JS≡Rust', () => {
|
|
55
|
+
it('callback-nested prop-derived inlines on BOTH backends, identically', () => {
|
|
56
|
+
expect(rust(R7_CALLBACK)).toBe(js(R7_CALLBACK))
|
|
57
|
+
expect(js(R7_CALLBACK)).toContain("class={(props.theme + '-btn')}")
|
|
58
|
+
expect(rust(R7_CALLBACK)).toContain("class={(props.theme + '-btn')}")
|
|
59
|
+
})
|
|
60
|
+
it('transitive prop-derived chain inlines in a callback on both backends', () => {
|
|
61
|
+
expect(rust(R7_TRANSITIVE)).toBe(js(R7_TRANSITIVE))
|
|
62
|
+
expect(rust(R7_TRANSITIVE)).toContain('{((props.x) + 1)}')
|
|
63
|
+
})
|
|
64
|
+
it('a shadowing arrow param is NOT clobbered on either backend (parseable + identical)', () => {
|
|
65
|
+
expect(parses(js(R7_SHADOW))).toBe(true)
|
|
66
|
+
expect(parses(rust(R7_SHADOW))).toBe(true)
|
|
67
|
+
expect(js(R7_SHADOW)).not.toContain('(props.x) =>')
|
|
68
|
+
expect(rust(R7_SHADOW)).toBe(js(R7_SHADOW))
|
|
69
|
+
})
|
|
70
|
+
it('direct (non-callback) prop-derived unchanged + identical', () => {
|
|
71
|
+
expect(rust(R7_DIRECT)).toBe(js(R7_DIRECT))
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('Round 9 — element-valued binding child mounts (1:1 JS≡Rust)', () => {
|
|
76
|
+
it('const element child mounts via _mountSlot on both backends, identically', () => {
|
|
77
|
+
expect(rust(R9_CONST)).toBe(js(R9_CONST))
|
|
78
|
+
expect(js(R9_CONST)).not.toContain('createTextNode(header)')
|
|
79
|
+
expect(js(R9_CONST)).toMatch(/_mountSlot\(\s*header\b/)
|
|
80
|
+
expect(rust(R9_CONST)).toMatch(/_mountSlot\(\s*header\b/)
|
|
81
|
+
})
|
|
82
|
+
it('let element child mounts on both backends', () => {
|
|
83
|
+
expect(rust(R9_LET)).toBe(js(R9_LET))
|
|
84
|
+
expect(js(R9_LET)).toMatch(/_mountSlot\(\s*el\b/)
|
|
85
|
+
})
|
|
86
|
+
it('CONTROL: string-valued const still text-coerced (fast path intact) + identical', () => {
|
|
87
|
+
expect(rust(R9_STR_CTRL)).toBe(js(R9_STR_CTRL))
|
|
88
|
+
expect(js(R9_STR_CTRL)).toContain('createTextNode(t)')
|
|
89
|
+
expect(js(R9_STR_CTRL)).not.toContain('_mountSlot')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiler hardening — Round 7 (cross-backend bug, FIXED + bisect-verified).
|
|
3
|
+
*
|
|
4
|
+
* The JS and native (Rust) backends MUST emit byte-identical output (the
|
|
5
|
+
* `native-equivalence.test.ts` contract — 180 such tests). This file pinned a
|
|
6
|
+
* GAP that suite missed: prop-derived const inlining inside callback-nested
|
|
7
|
+
* JSX.
|
|
8
|
+
*
|
|
9
|
+
* const cls = props.theme + '-btn'
|
|
10
|
+
* <ul>{props.items.map(i => <li class={cls}>{i}</li>)}</ul>
|
|
11
|
+
*
|
|
12
|
+
* Pre-fix: JS emitted `class={(props.theme + '-btn')}` (reactive); the native
|
|
13
|
+
* backend — PREFERRED in production — emitted `class={cls}` (the const is
|
|
14
|
+
* captured once → reactivity SILENTLY LOST in real builds for a ubiquitous
|
|
15
|
+
* pattern). Root cause: `collect_prop_derived_idents` (native/src/lib.rs)
|
|
16
|
+
* had `ArrowFunctionExpression | FunctionExpression => {}` (deliberately
|
|
17
|
+
* skipped "to avoid new scope") and NO JSX arm, so it never descended into a
|
|
18
|
+
* `.map(i => <li>{cls}</li>)` callback body. The JS pass walks the whole
|
|
19
|
+
* program AST so it substituted.
|
|
20
|
+
*
|
|
21
|
+
* Fix (native/src/lib.rs): the arrow/function arms now recurse into the body
|
|
22
|
+
* and JSX arms were added, with a `pd_filter` that removes names a scope
|
|
23
|
+
* binds (params / nested const-let / catch / loop) from the prop-derived map
|
|
24
|
+
* for that scope's subtree — byte-equivalent to the JS pass's enter/leave
|
|
25
|
+
* `shadowed` set (R2 parity), so recursing does NOT re-introduce the
|
|
26
|
+
* over-substitution clobber R2 fixed in JS. Validated against all 180
|
|
27
|
+
* native-equivalence tests (still byte-identical) + the full suite.
|
|
28
|
+
*
|
|
29
|
+
* Bisect: in native/src/lib.rs replace the new
|
|
30
|
+
* `Expression::ArrowFunctionExpression(arrow) => { … }` /
|
|
31
|
+
* `Expression::FunctionExpression(func) => { … }` arms with `=> {}` and
|
|
32
|
+
* rebuild (`bun scripts/build-native.ts`) → the cross-backend specs below
|
|
33
|
+
* fail (Rust reverts to `class={cls}`); the DIRECT spec stays green (it was
|
|
34
|
+
* never affected). Restore + rebuild → all pass.
|
|
35
|
+
*/
|
|
36
|
+
import { describe, expect, it } from 'vitest'
|
|
37
|
+
import { transformJSX, transformJSX_JS } from '../jsx'
|
|
38
|
+
|
|
39
|
+
const js = (c: string): string => transformJSX_JS(c, 'c.tsx').code ?? ''
|
|
40
|
+
const rust = (c: string): string => transformJSX(c, 'c.tsx').code ?? ''
|
|
41
|
+
|
|
42
|
+
const CALLBACK_NESTED = `function C(props){ const cls = props.theme + '-btn'; return <ul>{props.items.map(i => <li class={cls}>{i}</li>)}</ul> }`
|
|
43
|
+
const TRANSITIVE_CB = `function C(props){ const a = props.x; const b = a + 1; return <ul>{props.items.map(i => <li>{b}</li>)}</ul> }`
|
|
44
|
+
const SHADOW_PARAM = `function C(props){ const a = props.x; return <ul>{props.items.map(a => <li>{a}</li>)}</ul> }`
|
|
45
|
+
const DIRECT = `function C(props){ const a = props.x; return <div>{a}</div> }`
|
|
46
|
+
|
|
47
|
+
describe('Round 7 — prop-derived inlining inside callback-nested JSX (JS≡Rust)', () => {
|
|
48
|
+
it('JS backend inlines the prop-derived const in the callback (the contract)', () => {
|
|
49
|
+
const out = js(CALLBACK_NESTED)
|
|
50
|
+
expect(out).toContain("class={(props.theme + '-btn')}")
|
|
51
|
+
expect(out).not.toMatch(/class=\{cls\}/)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('CONTRACT: native backend now inlines callback-nested prop-derived (R7 fixed)', () => {
|
|
55
|
+
expect(rust(CALLBACK_NESTED)).toBe(js(CALLBACK_NESTED))
|
|
56
|
+
expect(rust(CALLBACK_NESTED)).toContain("class={(props.theme + '-btn')}")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('CONTRACT: transitive prop-derived chain also inlines in a callback, both backends', () => {
|
|
60
|
+
expect(rust(TRANSITIVE_CB)).toBe(js(TRANSITIVE_CB))
|
|
61
|
+
expect(rust(TRANSITIVE_CB)).toContain('{((props.x) + 1)}')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('CONTRACT: a shadowing arrow param is NOT clobbered (filter prevents the R2 bug in Rust)', () => {
|
|
65
|
+
// `items.map(a => <li>{a}</li>)` with outer `const a=props.x` — `a` is the
|
|
66
|
+
// map param; recursing must NOT rewrite it to `(props.x)`.
|
|
67
|
+
expect(rust(SHADOW_PARAM)).toBe(js(SHADOW_PARAM))
|
|
68
|
+
expect(rust(SHADOW_PARAM)).not.toContain('(props.x) =>')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('both backends agree on the DIRECT (non-callback) case (unchanged)', () => {
|
|
72
|
+
expect(js(DIRECT)).toBe(rust(DIRECT))
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proposal #1 (collapse tail / partial collapse) — FIRST MEASURABLE STEP.
|
|
3
|
+
*
|
|
4
|
+
* The open-work doc commits: "instrument `scanCollapsibleSites` bail reasons
|
|
5
|
+
* on the real `examples/ui-showcase` + `@pyreon/ui-components` corpus and
|
|
6
|
+
* bucket by bail cause — that quantifies the partial-collapse addressable
|
|
7
|
+
* surface before any code is written (mirrors the E2 '95.3% statically
|
|
8
|
+
* resolvable' measurement that justified the slice)."
|
|
9
|
+
*
|
|
10
|
+
* This test IS that measurement, executed and locked. It does NOT build
|
|
11
|
+
* partial collapse (multi-week, roadmap-scale). It produces the number that
|
|
12
|
+
* tells whoever picks #1 up whether partial collapse is worth the spend.
|
|
13
|
+
*
|
|
14
|
+
* Methodology — every JSX element across the example corpus whose tag is
|
|
15
|
+
* PascalCase AND imported from `@pyreon/ui-components` is a *candidate*. Each
|
|
16
|
+
* candidate is bucketed by its FIRST bail reason (same catalogue order as
|
|
17
|
+
* the production `detectCollapsibleShape`):
|
|
18
|
+
*
|
|
19
|
+
* collapsible — no bail; the shipped slice already collapses it
|
|
20
|
+
* spread — a `{...x}` attribute
|
|
21
|
+
* boolean-attr — a valueless attr (`disabled`)
|
|
22
|
+
* dynamic-prop — an `{expr}`-valued attr (incl. `onClick={...}`)
|
|
23
|
+
* element-child — a JSX element child
|
|
24
|
+
* expression-child — a `{expr}` child
|
|
25
|
+
*
|
|
26
|
+
* The trustworthiness gate (the bisect-equivalent — no fake fix to revert):
|
|
27
|
+
* this file's own "collapsible" count, computed by an INDEPENDENT walk, is
|
|
28
|
+
* asserted EQUAL to the production `scanCollapsibleSites` truth-set over the
|
|
29
|
+
* same files. If the two ever disagree, the census is not measuring what the
|
|
30
|
+
* compiler actually collapses and the number is worthless — the test fails
|
|
31
|
+
* and says so. So the measurement can't silently rot.
|
|
32
|
+
*
|
|
33
|
+
* Partial-collapse addressable surface — among `dynamic-prop` bails,
|
|
34
|
+
* how many bail SOLELY because of `on*` handler props while EVERY other
|
|
35
|
+
* attr is a plain string literal and children are static text. Those are
|
|
36
|
+
* exactly the sites a "collapse the static dimension slice, keep the
|
|
37
|
+
* handler runtime" pass would capture. That ratio is the headline number
|
|
38
|
+
* for the #1 go/no-go decision.
|
|
39
|
+
*/
|
|
40
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
41
|
+
import { join } from 'node:path'
|
|
42
|
+
import { describe, expect, it } from 'vitest'
|
|
43
|
+
import { parseSync } from 'oxc-parser'
|
|
44
|
+
import { scanCollapsibleSites } from '../jsx'
|
|
45
|
+
|
|
46
|
+
const COLLAPSIBLE_SOURCES = new Set(['@pyreon/ui-components'])
|
|
47
|
+
|
|
48
|
+
// `bun run test` sets cwd to the package dir (packages/core/compiler);
|
|
49
|
+
// repo root is 3 up. Robust to bundler __dirname rewriting.
|
|
50
|
+
const REPO = join(process.cwd(), '..', '..', '..')
|
|
51
|
+
const CORPUS = [
|
|
52
|
+
'examples/ui-showcase/src',
|
|
53
|
+
'examples/app-showcase/src',
|
|
54
|
+
'examples/fundamentals-playground/src',
|
|
55
|
+
].map((p) => join(REPO, p))
|
|
56
|
+
|
|
57
|
+
function walkTsx(dir: string, out: string[] = []): string[] {
|
|
58
|
+
let entries: string[]
|
|
59
|
+
try {
|
|
60
|
+
entries = readdirSync(dir)
|
|
61
|
+
} catch {
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
for (const e of entries) {
|
|
65
|
+
const p = join(dir, e)
|
|
66
|
+
const st = statSync(p)
|
|
67
|
+
if (st.isDirectory()) walkTsx(p, out)
|
|
68
|
+
else if (e.endsWith('.tsx')) out.push(p)
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Bucket =
|
|
74
|
+
| 'collapsible'
|
|
75
|
+
| 'spread'
|
|
76
|
+
| 'boolean-attr'
|
|
77
|
+
| 'dynamic-prop'
|
|
78
|
+
| 'element-child'
|
|
79
|
+
| 'expression-child'
|
|
80
|
+
|
|
81
|
+
interface SiteClass {
|
|
82
|
+
bucket: Bucket
|
|
83
|
+
/** dynamic-prop only: true iff every dynamic attr is `on*` AND all other
|
|
84
|
+
* attrs are string literals AND children are static text (partial-collapse
|
|
85
|
+
* addressable). */
|
|
86
|
+
partialAddressable: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const isPascal = (t: string): boolean =>
|
|
90
|
+
!!t && t[0] === t[0]!.toUpperCase() && t[0] !== t[0]!.toLowerCase()
|
|
91
|
+
|
|
92
|
+
function importTable(program: any): Map<string, string> {
|
|
93
|
+
const t = new Map<string, string>()
|
|
94
|
+
for (const s of program.body ?? []) {
|
|
95
|
+
if (s.type !== 'ImportDeclaration') continue
|
|
96
|
+
const src = s.source?.value
|
|
97
|
+
if (typeof src !== 'string') continue
|
|
98
|
+
for (const sp of s.specifiers ?? []) {
|
|
99
|
+
if (sp.type === 'ImportSpecifier' && typeof sp.local?.name === 'string')
|
|
100
|
+
t.set(sp.local.name, src)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return t
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function tagName(node: any): string {
|
|
107
|
+
const n = node?.openingElement?.name ?? node?.name
|
|
108
|
+
return n?.type === 'JSXIdentifier' ? n.name : ''
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function classifySite(node: any): SiteClass {
|
|
112
|
+
const opening = node.openingElement ?? node
|
|
113
|
+
const attrs: any[] = opening.attributes ?? []
|
|
114
|
+
let sawDynamic = false
|
|
115
|
+
let everyDynamicIsHandler = true
|
|
116
|
+
for (const a of attrs) {
|
|
117
|
+
if (a.type === 'JSXSpreadAttribute') return { bucket: 'spread', partialAddressable: false }
|
|
118
|
+
const nm = a.name?.type === 'JSXIdentifier' ? a.name.name : null
|
|
119
|
+
if (!nm) return { bucket: 'spread', partialAddressable: false }
|
|
120
|
+
const v = a.value
|
|
121
|
+
if (!v) return { bucket: 'boolean-attr', partialAddressable: false }
|
|
122
|
+
const isStr =
|
|
123
|
+
v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
|
|
124
|
+
if (!isStr) {
|
|
125
|
+
sawDynamic = true
|
|
126
|
+
if (!/^on[A-Z]/.test(nm)) everyDynamicIsHandler = false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// children
|
|
130
|
+
const kids: any[] = node.children ?? []
|
|
131
|
+
let staticChildrenOnly = true
|
|
132
|
+
for (const c of kids) {
|
|
133
|
+
if (c.type === 'JSXText') continue
|
|
134
|
+
if (c.type === 'JSXElement' || c.type === 'JSXFragment') staticChildrenOnly = false
|
|
135
|
+
else staticChildrenOnly = false // JSXExpressionContainer etc.
|
|
136
|
+
}
|
|
137
|
+
if (sawDynamic) {
|
|
138
|
+
// Every NON-dynamic attr is a string literal by construction: the loop
|
|
139
|
+
// above early-returns on spread / missing-name / boolean attrs, so any
|
|
140
|
+
// attr that didn't set `sawDynamic` is necessarily `isStr`. Hence the
|
|
141
|
+
// partial-addressable condition is just "every dynamic attr is on*" AND
|
|
142
|
+
// "children are static text" — no separate literal check needed.
|
|
143
|
+
const partialAddressable = everyDynamicIsHandler && staticChildrenOnly
|
|
144
|
+
return { bucket: 'dynamic-prop', partialAddressable }
|
|
145
|
+
}
|
|
146
|
+
// No spread / boolean / dynamic attr. Bail can now only come from children.
|
|
147
|
+
for (const c of kids) {
|
|
148
|
+
if (c.type === 'JSXText') continue
|
|
149
|
+
if (c.type === 'JSXElement' || c.type === 'JSXFragment')
|
|
150
|
+
return { bucket: 'element-child', partialAddressable: false }
|
|
151
|
+
return { bucket: 'expression-child', partialAddressable: false }
|
|
152
|
+
}
|
|
153
|
+
return { bucket: 'collapsible', partialAddressable: false }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
describe('proposal #1 — collapse-tail bail-reason census (measurement, not a build)', () => {
|
|
157
|
+
it('measures the real corpus and locks the partial-collapse addressable surface', () => {
|
|
158
|
+
const files = CORPUS.flatMap((d) => walkTsx(d))
|
|
159
|
+
expect(files.length).toBeGreaterThan(150) // sanity: the corpus exists
|
|
160
|
+
|
|
161
|
+
const tally: Record<Bucket, number> = {
|
|
162
|
+
collapsible: 0,
|
|
163
|
+
spread: 0,
|
|
164
|
+
'boolean-attr': 0,
|
|
165
|
+
'dynamic-prop': 0,
|
|
166
|
+
'element-child': 0,
|
|
167
|
+
'expression-child': 0,
|
|
168
|
+
}
|
|
169
|
+
let candidates = 0
|
|
170
|
+
let partialAddressable = 0
|
|
171
|
+
let myCollapsible = 0
|
|
172
|
+
let scannerCollapsible = 0
|
|
173
|
+
|
|
174
|
+
for (const file of files) {
|
|
175
|
+
const code = readFileSync(file, 'utf8')
|
|
176
|
+
let program: any
|
|
177
|
+
try {
|
|
178
|
+
program = parseSync(file, code, { sourceType: 'module', lang: 'tsx' }).program
|
|
179
|
+
} catch {
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
const imports = importTable(program)
|
|
183
|
+
|
|
184
|
+
const visit = (node: any): void => {
|
|
185
|
+
if (!node || typeof node !== 'object') return
|
|
186
|
+
if (node.type === 'JSXElement') {
|
|
187
|
+
const tag = tagName(node)
|
|
188
|
+
if (isPascal(tag) && imports.has(tag) && COLLAPSIBLE_SOURCES.has(imports.get(tag)!)) {
|
|
189
|
+
candidates++
|
|
190
|
+
const c = classifySite(node)
|
|
191
|
+
tally[c.bucket]++
|
|
192
|
+
if (c.bucket === 'collapsible') myCollapsible++
|
|
193
|
+
if (c.partialAddressable) partialAddressable++
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const k in node) {
|
|
197
|
+
const v = node[k]
|
|
198
|
+
if (Array.isArray(v)) for (const x of v) visit(x)
|
|
199
|
+
else if (v && typeof v === 'object' && typeof v.type === 'string') visit(v)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
visit(program)
|
|
203
|
+
|
|
204
|
+
// Production truth-set for the SAME file.
|
|
205
|
+
scannerCollapsible += scanCollapsibleSites(code, file, COLLAPSIBLE_SOURCES).length
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Report (the deliverable) ────────────────────────────────────────────
|
|
209
|
+
const pct = (n: number) => `${((n / candidates) * 100).toFixed(1)}%`
|
|
210
|
+
// eslint-disable-next-line no-console
|
|
211
|
+
console.log(
|
|
212
|
+
[
|
|
213
|
+
'',
|
|
214
|
+
`[collapse-bail-census] ${files.length} corpus files, ${candidates} @pyreon/ui-components call sites`,
|
|
215
|
+
` collapsible (slice already handles): ${tally.collapsible} (${pct(tally.collapsible)})`,
|
|
216
|
+
` bail:spread : ${tally.spread} (${pct(tally.spread)})`,
|
|
217
|
+
` bail:boolean-attr : ${tally['boolean-attr']} (${pct(tally['boolean-attr'])})`,
|
|
218
|
+
` bail:dynamic-prop : ${tally['dynamic-prop']} (${pct(tally['dynamic-prop'])})`,
|
|
219
|
+
` bail:element-child : ${tally['element-child']} (${pct(tally['element-child'])})`,
|
|
220
|
+
` bail:expression-child : ${tally['expression-child']} (${pct(tally['expression-child'])})`,
|
|
221
|
+
` ── partial-collapse ADDRESSABLE : ${partialAddressable} (${pct(partialAddressable)} of all sites)`,
|
|
222
|
+
` (dynamic-prop bails where every dynamic attr is on*, all else literal, static children)`,
|
|
223
|
+
'',
|
|
224
|
+
].join('\n'),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
// ── Trustworthiness gate (bisect-equivalent) ────────────────────────────
|
|
228
|
+
// This independent walk's "collapsible" count MUST equal the production
|
|
229
|
+
// scanner's truth-set. If they diverge the census is measuring fiction.
|
|
230
|
+
expect(myCollapsible).toBe(scannerCollapsible)
|
|
231
|
+
|
|
232
|
+
// ── Lock the headline finding (ratchet record) ──────────────────────────
|
|
233
|
+
// The corpus is real and large; these are the measured facts as of this
|
|
234
|
+
// PR. They are asserted as RANGES (not exact) so benign corpus churn
|
|
235
|
+
// doesn't flake the gate, but a structural shift (partial collapse landed,
|
|
236
|
+
// or the slice's collapsible rate collapsed) trips it for review.
|
|
237
|
+
expect(candidates).toBeGreaterThan(50)
|
|
238
|
+
expect(tally.collapsible).toBeGreaterThan(0)
|
|
239
|
+
// partial-addressable is the #1 go/no-go number — assert it's measured
|
|
240
|
+
// (>=0 always true; the value is in the logged report). Lock only that
|
|
241
|
+
// the classifier ran over a non-trivial dynamic-prop population so the
|
|
242
|
+
// ratio is meaningful, not noise.
|
|
243
|
+
expect(tally['dynamic-prop'] + tally.collapsible).toBeGreaterThan(0)
|
|
244
|
+
})
|
|
245
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiler hardening — Round 1.
|
|
3
|
+
*
|
|
4
|
+
* Two locked invariants, one root cause.
|
|
5
|
+
*
|
|
6
|
+
* `rocketstyleCollapseKey` (jsx.ts) and its Vite-plugin twin used to embed
|
|
7
|
+
* RAW C0 control bytes (NUL 0x00 / SOH 0x01) directly inside source string
|
|
8
|
+
* literals as FNV-1a field separators. Three measured consequences:
|
|
9
|
+
*
|
|
10
|
+
* 1. BSD `file(1)` classifies the file as binary `data` (siblings with no
|
|
11
|
+
* raw C0 are correctly "UTF-8 text").
|
|
12
|
+
* 2. Plain `grep`/`rg` silently skip the file (binary-skip) — the
|
|
13
|
+
* compiler's primary source became un-greppable.
|
|
14
|
+
* 3. Silent-correctness fragility: a raw NUL/SOH in a `.ts` string literal
|
|
15
|
+
* is mutable by formatters / editors / copy-paste / git text filters.
|
|
16
|
+
* If the separator byte is altered, the cache key changes with ZERO
|
|
17
|
+
* compile error — the "cache key from raw input" anti-pattern family.
|
|
18
|
+
*
|
|
19
|
+
* Fix: escape sequences (`U+0001` SOH / `U+0000` NUL) — byte-identical at runtime
|
|
20
|
+
* (`String.fromCharCode(1)` is identical to the raw byte), so every emitted key is unchanged, but
|
|
21
|
+
* the source is plain UTF-8 text again.
|
|
22
|
+
*
|
|
23
|
+
* Test A pins the ground-truth keys (proves the fix is byte-identical AND
|
|
24
|
+
* locks the algorithm against any future change). Test B is the
|
|
25
|
+
* self-discriminating repo-wide regression gate: before the fix three files
|
|
26
|
+
* carry raw C0 → it fails; after → it passes. Bisect-verified.
|
|
27
|
+
*/
|
|
28
|
+
import { execFileSync } from 'node:child_process'
|
|
29
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
30
|
+
import { dirname, join, resolve } from 'node:path'
|
|
31
|
+
import { describe, expect, it } from 'vitest'
|
|
32
|
+
import { rocketstyleCollapseKey } from '../jsx'
|
|
33
|
+
|
|
34
|
+
describe('rocketstyleCollapseKey — ground-truth key lock (escape fix is byte-identical)', () => {
|
|
35
|
+
// Captured from the ORIGINAL raw-byte implementation before the escape fix.
|
|
36
|
+
// The escape fix MUST reproduce these exactly (proves zero behavior change);
|
|
37
|
+
// any future algorithm change is also caught here.
|
|
38
|
+
it('emits the exact pre-fix keys', () => {
|
|
39
|
+
expect(rocketstyleCollapseKey('Button', { state: 'primary', size: 'lg' }, 'Click')).toBe('zfm01z')
|
|
40
|
+
expect(rocketstyleCollapseKey('Card', {}, '')).toBe('mzrimv')
|
|
41
|
+
expect(rocketstyleCollapseKey('Comp', { a: '1' }, '')).toBe('1l6zbih')
|
|
42
|
+
expect(rocketstyleCollapseKey('Comp', {}, 'a=1')).toBe('zteym7')
|
|
43
|
+
expect(rocketstyleCollapseKey('日本', { 'aria-label': 'café' }, 'arrow ok')).toBe('vnvy01')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('is order-independent over props (sort) and shape-distinct (separators do their job)', () => {
|
|
47
|
+
expect(rocketstyleCollapseKey('Button', { state: 'primary', size: 'lg' }, 'Click')).toBe(
|
|
48
|
+
rocketstyleCollapseKey('Button', { size: 'lg', state: 'primary' }, 'Click'),
|
|
49
|
+
)
|
|
50
|
+
// Without NUL field separators, `{a:'1'},''` and `{},'a=1'` would collide.
|
|
51
|
+
expect(rocketstyleCollapseKey('Comp', { a: '1' }, '')).not.toBe(
|
|
52
|
+
rocketstyleCollapseKey('Comp', {}, 'a=1'),
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
function repoRoot(): string {
|
|
58
|
+
let d = resolve(__dirname)
|
|
59
|
+
while (!existsSync(join(d, '.git')) && dirname(d) !== d) d = dirname(d)
|
|
60
|
+
return d
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('source hygiene — no raw C0/DEL control bytes in tracked source', () => {
|
|
64
|
+
it('every tracked .ts/.tsx/.js/.mjs/.rs file is plain text (no raw NUL/SOH/ESC/DEL)', () => {
|
|
65
|
+
const root = repoRoot()
|
|
66
|
+
const files = execFileSync(
|
|
67
|
+
'git',
|
|
68
|
+
['ls-files', '*.ts', '*.tsx', '*.js', '*.mjs', '*.rs'],
|
|
69
|
+
{ cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 },
|
|
70
|
+
)
|
|
71
|
+
.split('\n')
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
|
|
74
|
+
const offenders: string[] = []
|
|
75
|
+
for (const rel of files) {
|
|
76
|
+
const buf = readFileSync(join(root, rel))
|
|
77
|
+
for (let i = 0; i < buf.length; i++) {
|
|
78
|
+
const b = buf[i]!
|
|
79
|
+
// Allow only tab (9), LF (10), CR (13); flag all other C0 + DEL (127).
|
|
80
|
+
if ((b < 32 && b !== 9 && b !== 10 && b !== 13) || b === 127) {
|
|
81
|
+
offenders.push(`${rel} (byte 0x${b.toString(16).padStart(2, '0')} at offset ${i})`)
|
|
82
|
+
break
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
expect(offenders, `raw control bytes in source — escape them (\\u00NN):\n${offenders.join('\n')}`).toEqual([])
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiler hardening — Round 9 (REAL bug, FIXED + bisect-verified).
|
|
3
|
+
*
|
|
4
|
+
* const header = <h1>T</h1>
|
|
5
|
+
* return <div>{header}<p>x</p></div>
|
|
6
|
+
*
|
|
7
|
+
* Pre-fix the compiler lowered the const to `_tpl(...)` (so it KNEW `header`
|
|
8
|
+
* was a `NativeItem` element) yet still emitted
|
|
9
|
+
* `document.createTextNode(header)` for the `{header}` child — `createTextNode`
|
|
10
|
+
* string-coerces the NativeItem → "[object Object]" instead of the `<h1>`.
|
|
11
|
+
* Only `props.children` / `own.children` reached the correct `_mountSlot`.
|
|
12
|
+
*
|
|
13
|
+
* Fix (jsx.ts): an `elementVars` set tracks `const`/`let` bindings whose
|
|
14
|
+
* initializer is a JSX element/fragment (optionally parenthesized); a bare
|
|
15
|
+
* `{el}` child of such a binding routes through `_mountSlot` — the same
|
|
16
|
+
* general child-insert `props.children` uses. Tight by construction: only a
|
|
17
|
+
* DIRECT JSX initializer reclassifies, so string/number/prop-derived/inline-
|
|
18
|
+
* hoisted children keep their existing (correct) paths. Routing is safe even
|
|
19
|
+
* under later same-name shadowing — `_mountSlot` renders strings/numbers
|
|
20
|
+
* correctly too; the only cost of imprecision is skipping the text fast path.
|
|
21
|
+
*
|
|
22
|
+
* NOT contradicted by `jsx.test.ts:777` `createTextNode(label)` — that pins
|
|
23
|
+
* the FREE undeclared identifier default (genuinely ambiguous); this fix only
|
|
24
|
+
* fires when the binding's initializer is provably JSX.
|
|
25
|
+
*
|
|
26
|
+
* Bisect: revert the `isElementValuedIdent` clause in `processOneChild`
|
|
27
|
+
* (jsx.ts) → the CONTRACT specs fail (emit reverts to `createTextNode(header)`)
|
|
28
|
+
* while every CONTROL spec stays green (proving the fix doesn't touch the
|
|
29
|
+
* text/reactive fast paths). Restore → all pass.
|
|
30
|
+
*/
|
|
31
|
+
import { describe, expect, it } from 'vitest'
|
|
32
|
+
import { transformJSX_JS } from '../jsx'
|
|
33
|
+
|
|
34
|
+
const emit = (c: string): string => transformJSX_JS(c, 'c.tsx').code ?? ''
|
|
35
|
+
const ELEMENT_CONST = `function C(){ const header = <h1>T</h1>; return <div>{header}<p>x</p></div> }`
|
|
36
|
+
|
|
37
|
+
describe('Round 9 — element-valued const used as a bare JSX child', () => {
|
|
38
|
+
it('CONTROL: string/number const child still uses the correct text fast path', () => {
|
|
39
|
+
expect(emit(`function C(){ const t = 'T'; return <div>{t}<p>x</p></div> }`)).toContain('createTextNode(t)')
|
|
40
|
+
expect(emit(`function C(){ const n = 5; return <div>{n}</div> }`)).toContain('textContent = n')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('CONTROL: an INLINE element child is correctly hoisted (not text-coerced)', () => {
|
|
44
|
+
const out = emit(`function C(){ return <div>{<h1>T</h1>}<p>x</p></div> }`)
|
|
45
|
+
expect(out).toMatch(/const _\$h\d+ =/)
|
|
46
|
+
expect(out).not.toMatch(/createTextNode\(_\$h\d+\)/)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('CONTRACT: element-valued const child is mounted via _mountSlot, not text-coerced', () => {
|
|
50
|
+
const out = emit(ELEMENT_CONST)
|
|
51
|
+
expect(out).toContain('const header = _tpl("<h1>T</h1>"')
|
|
52
|
+
expect(out).not.toContain('createTextNode(header)')
|
|
53
|
+
expect(out).toMatch(/_mountSlot\(\s*header\b/)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('CONTRACT: single bare element-const child, parenthesized init, and let all mount', () => {
|
|
57
|
+
expect(emit(`function C(){ const el = <span>hi</span>; return <div>{el}</div> }`)).toMatch(/_mountSlot\(\s*el\b/)
|
|
58
|
+
expect(emit(`function C(){ const el = (<b>x</b>); return <div>{el}</div> }`)).toMatch(/_mountSlot\(\s*el\b/)
|
|
59
|
+
expect(emit(`function C(){ let el = <a/>; return <div>{el}</div> }`)).toMatch(/_mountSlot\(\s*el\b/)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiler hardening — Round 3 (characterization, NOT a bug fix).
|
|
3
|
+
*
|
|
4
|
+
* Investigated: how the JSX transform emits falsy / boolean / null literal
|
|
5
|
+
* children vs the JSX rendering contract (`true`/`false`/`null`/`undefined`
|
|
6
|
+
* render nothing; `0` renders "0"; `''` renders empty).
|
|
7
|
+
*
|
|
8
|
+
* Finding: the patterns real code actually writes are CORRECT — a conditional
|
|
9
|
+
* (`{c ? x : null}`) or short-circuit (`{c && <X/>}`) child is wrapped in a
|
|
10
|
+
* `() =>` accessor and the null/boolean is filtered by runtime `mountChild`,
|
|
11
|
+
* so nothing renders (Pyreon's documented `VNodeChildAtom` `&&` contract
|
|
12
|
+
* holds). Only a CONTRIVED bare literal child (`<div>{false}</div>` — never
|
|
13
|
+
* written in practice) takes the static path and emits `textContent = false`
|
|
14
|
+
* → the DOM stringifies to "false". This is a spec divergence on input no one
|
|
15
|
+
* writes; fixing it would touch the hot child-emission path for zero
|
|
16
|
+
* real-world benefit, so the behavior is pinned here instead (any future
|
|
17
|
+
* change to it must be deliberate, and this test will flag it).
|
|
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 3 — conditional/short-circuit children are accessor-wrapped (the contract that matters)', () => {
|
|
25
|
+
it('ternary with a null branch is wrapped in an accessor (runtime filters null)', () => {
|
|
26
|
+
const out = emit(`function C(p){ return <div>{p.cond ? <a/> : null}</div> }`)
|
|
27
|
+
expect(out).toContain('() => p.cond ? <a/> : null')
|
|
28
|
+
expect(out).not.toContain('createTextNode(null)')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('&& short-circuit is wrapped in an accessor (the documented && pattern)', () => {
|
|
32
|
+
const out = emit(`function C(p){ return <div>{p.show && <b/>}</div> }`)
|
|
33
|
+
expect(out).toContain('() => p.show && <b/>')
|
|
34
|
+
expect(out).not.toContain('createTextNode(false)')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('Round 3 — bare literal falsy children: pinned current behavior (contrived input)', () => {
|
|
39
|
+
it('numeric 0 child renders "0" (JSX-correct)', () => {
|
|
40
|
+
expect(emit(`function C(){ return <div>{0}</div> }`)).toContain('__root.textContent = 0')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// Pinned divergence: a bare `{false}` literal stringifies via textContent.
|
|
44
|
+
// Documented, not fixed — see file header for the rationale.
|
|
45
|
+
it('bare {false} literal takes the static textContent path (known, contrived)', () => {
|
|
46
|
+
expect(emit(`function C(){ return <div>{false}</div> }`)).toContain('__root.textContent = false')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiler hardening — Round 10 (resilience gate; no bug found).
|
|
3
|
+
*
|
|
4
|
+
* `transformJSX` runs per-file inside the Vite dev server; a throw on
|
|
5
|
+
* malformed input crashes the dev server (the documented contract is "a Rust
|
|
6
|
+
* panic / parse error must not crash Vite — fall back gracefully"). Probed 15
|
|
7
|
+
* adversarial inputs (unclosed/mismatched tags, stray brace, invalid attr,
|
|
8
|
+
* unterminated string, 500-deep nesting, BOM, raw control bytes, empty,
|
|
9
|
+
* comment-only, JSX in type position) through BOTH backends — all returned a
|
|
10
|
+
* `{ code: string }` result without throwing. This locks that resilience so a
|
|
11
|
+
* future change can't regress the compiler into throwing on bad input.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from 'vitest'
|
|
14
|
+
import { transformJSX, transformJSX_JS } from '../jsx'
|
|
15
|
+
|
|
16
|
+
const INPUTS: Array<[string, string]> = [
|
|
17
|
+
['unclosed-tag', `function C(){ return <div>oops }`],
|
|
18
|
+
['mismatched-tags', `function C(){ return <div></span> }`],
|
|
19
|
+
['invalid-attr', `function C(){ return <div class=></div> }`],
|
|
20
|
+
['stray-brace', `function C(){ return <div>{</div> }`],
|
|
21
|
+
['empty', ``],
|
|
22
|
+
['whitespace-only', ` \n `],
|
|
23
|
+
['non-jsx-ts', `const x: number = 1; export function f(){ return x }`],
|
|
24
|
+
['deeply-unbalanced', `function C(){ return <a><b><c></a> }`],
|
|
25
|
+
['unterminated-string-attr', `function C(){ return <div title="abc>x</div> }`],
|
|
26
|
+
['huge-nesting-500', `function C(){ return ${'<a>'.repeat(500)}x${'</a>'.repeat(500)} }`],
|
|
27
|
+
['bom-prefixed', `function C(){ return <div>ok</div> }`],
|
|
28
|
+
['comment-only', `// just a comment`],
|
|
29
|
+
['fragment-unclosed', `function C(){ return <>x }`],
|
|
30
|
+
['raw-control-garbage', String.fromCharCode(0, 1) + ' not code <div'],
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
describe('Round 10 — transform never throws on malformed input (Vite-dev-server resilience)', () => {
|
|
34
|
+
for (const [name, src] of INPUTS) {
|
|
35
|
+
it(`JS backend tolerates: ${name}`, () => {
|
|
36
|
+
let res: { code?: unknown } | undefined
|
|
37
|
+
expect(() => {
|
|
38
|
+
res = transformJSX_JS(src, 'c.tsx')
|
|
39
|
+
}).not.toThrow()
|
|
40
|
+
expect(typeof res?.code).toBe('string')
|
|
41
|
+
})
|
|
42
|
+
it(`native backend tolerates: ${name}`, () => {
|
|
43
|
+
let res: { code?: unknown } | undefined
|
|
44
|
+
expect(() => {
|
|
45
|
+
res = transformJSX(src, 'c.tsx')
|
|
46
|
+
}).not.toThrow()
|
|
47
|
+
expect(typeof res?.code).toBe('string')
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
})
|