@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.
Files changed (28) hide show
  1. package/lib/analysis/index.js.html +1 -1
  2. package/lib/index.js +418 -18
  3. package/lib/types/index.d.ts +92 -1
  4. package/package.json +13 -12
  5. package/src/index.ts +2 -1
  6. package/src/jsx.ts +669 -17
  7. package/src/tests/backend-parity-r7-r9.test.ts +91 -0
  8. package/src/tests/backend-prop-derived-callback-divergence.test.ts +74 -0
  9. package/src/tests/collapse-bail-census.test.ts +245 -0
  10. package/src/tests/collapse-key-source-hygiene.test.ts +88 -0
  11. package/src/tests/element-valued-const-child.test.ts +61 -0
  12. package/src/tests/falsy-child-characterization.test.ts +48 -0
  13. package/src/tests/malformed-input-resilience.test.ts +50 -0
  14. package/src/tests/partial-collapse-detector.test.ts +121 -0
  15. package/src/tests/partial-collapse-emit.test.ts +104 -0
  16. package/src/tests/partial-collapse-robustness.test.ts +53 -0
  17. package/src/tests/prop-derived-shadow.test.ts +96 -0
  18. package/src/tests/pure-call-reactive-args.test.ts +50 -0
  19. package/src/tests/r13-callback-stmt-equivalence.test.ts +58 -0
  20. package/src/tests/r14-ssr-mode-parity.test.ts +51 -0
  21. package/src/tests/r15-elemconst-propderived.test.ts +47 -0
  22. package/src/tests/r19-defer-inline-robust.test.ts +54 -0
  23. package/src/tests/r20-backend-equivalence-sweep.test.ts +50 -0
  24. package/src/tests/rocketstyle-collapse.test.ts +208 -0
  25. package/src/tests/signal-autocall-shadow.test.ts +86 -0
  26. package/src/tests/sourcemap-fidelity.test.ts +77 -0
  27. package/src/tests/static-text-baking.test.ts +64 -0
  28. package/src/tests/transform-state-isolation.test.ts +49 -0
@@ -0,0 +1,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
+ })