@barefootjs/jsx 0.26.2 → 0.26.4
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/dist/adapters/interface.d.ts +29 -0
- package/dist/adapters/interface.d.ts.map +1 -1
- package/dist/adapters/jsx-adapter.d.ts +9 -0
- package/dist/adapters/jsx-adapter.d.ts.map +1 -1
- package/dist/adapters/loop-bound-names.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +0 -10
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/adapters/test-adapter.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts +11 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts +40 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.js +1086 -269
- package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +14 -1
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +16 -1
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +39 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/shared.d.ts +12 -1
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +19 -0
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +67 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/build-static-array-child-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +29 -4
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/loop-destructure.d.ts.map +1 -1
- package/dist/strip-types.d.ts +18 -0
- package/dist/strip-types.d.ts.map +1 -1
- package/dist/types.d.ts +143 -32
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +277 -33
- package/src/__tests__/client-js-generation.test.ts +18 -5
- package/src/__tests__/compiler-runtime-contract.test.ts +4 -4
- package/src/__tests__/compiler-stress-1244.test.ts +12 -1
- package/src/__tests__/delegated-handler-preamble.test.ts +153 -0
- package/src/__tests__/event-delegation-scope-2367.test.ts +108 -0
- package/src/__tests__/flatmap-segments.test.ts +262 -0
- package/src/__tests__/map-arbitrary-body.test.ts +226 -0
- package/src/__tests__/map-body-no-silent-divergence.test.ts +405 -0
- package/src/__tests__/map-multi-return-body.test.ts +172 -0
- package/src/__tests__/preamble-region-patch.test.ts +150 -0
- package/src/__tests__/static-loop-csr-materialize.test.ts +3 -2
- package/src/__tests__/unsupported-expression.test.ts +20 -2
- package/src/adapters/interface.ts +40 -0
- package/src/adapters/jsx-adapter.ts +10 -0
- package/src/adapters/loop-bound-names.ts +6 -2
- package/src/adapters/parsed-expr-emitter.ts +5 -10
- package/src/adapters/test-adapter.ts +10 -0
- package/src/analyzer-context.ts +35 -1
- package/src/analyzer.ts +162 -24
- package/src/compiler.ts +2 -2
- package/src/expression-parser.ts +27 -19
- package/src/ir-to-client-js/build-references.ts +19 -2
- package/src/ir-to-client-js/collect-elements.ts +69 -13
- package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +14 -1
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +18 -5
- package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +4 -0
- package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +15 -2
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +32 -3
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +26 -3
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +53 -2
- package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +16 -1
- package/src/ir-to-client-js/control-flow/plan/loop.ts +40 -0
- package/src/ir-to-client-js/control-flow/shared.ts +28 -2
- package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +25 -3
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +76 -21
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +65 -1
- package/src/ir-to-client-js/control-flow.ts +11 -0
- package/src/ir-to-client-js/html-template.ts +244 -39
- package/src/ir-to-client-js/imports.ts +1 -1
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +22 -10
- package/src/ir-to-client-js/reactivity.ts +6 -0
- package/src/ir-to-client-js/types.ts +28 -3
- package/src/jsx-to-ir.ts +1059 -167
- package/src/loop-destructure.ts +9 -5
- package/src/rich-type-refusal.ts +6 -2
- package/src/strip-types.ts +47 -0
- package/src/types.ts +159 -35
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 3 (D4 + D5) of spec/callback-fidelity.md — a `.map()` callback whose
|
|
3
|
+
* body *constructs* JSX in a statement before its `return` (an imperative
|
|
4
|
+
* array-builder: `const out = []; for (…) out.push(<td/>); return <tr>{out}</tr>`).
|
|
5
|
+
*
|
|
6
|
+
* On a **JS-runtime** adapter (`acceptsCallbackBody() => true`) the body is now
|
|
7
|
+
* rendered verbatim: each JSX leaf lowers to a template-literal HTML string, the
|
|
8
|
+
* imperative control flow runs as-is, and the `{out}` element-array child is
|
|
9
|
+
* joined into the row's innerHTML. No raw JSX leaks into the plain-JS bundle.
|
|
10
|
+
*
|
|
11
|
+
* On a **DSL** adapter (whose template runtime can't run a callback body) it
|
|
12
|
+
* still refuses with BF021, always naming the `/* @client *\/` escape.
|
|
13
|
+
*
|
|
14
|
+
* D5 (keyFn = hoist): the loop key is evaluated before the body runs, so it must
|
|
15
|
+
* be derivable from the raw item — a key that reads a preamble-computed local is
|
|
16
|
+
* refused (it would compile to an unbound keyFn).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, test, expect } from 'bun:test'
|
|
20
|
+
import { compileJSX } from '../compiler'
|
|
21
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
22
|
+
|
|
23
|
+
function compile(source: string, dsl: boolean) {
|
|
24
|
+
const a = new TestAdapter()
|
|
25
|
+
// A DSL adapter's template runtime can't run a callback body verbatim.
|
|
26
|
+
if (dsl) a.acceptsCallbackBody = () => false
|
|
27
|
+
return compileJSX(source, 'List.tsx', { adapter: a })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const arrayBuilder = `
|
|
31
|
+
function Table({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
32
|
+
return <table><tbody>{rows.map((r) => {
|
|
33
|
+
const out = []
|
|
34
|
+
for (const c of r.cells) out.push(<td>{c}</td>)
|
|
35
|
+
return <tr key={r.id}>{out}</tr>
|
|
36
|
+
})}</tbody></table>
|
|
37
|
+
}
|
|
38
|
+
export { Table }
|
|
39
|
+
`
|
|
40
|
+
|
|
41
|
+
describe('.map() array-builder body — JS-runtime verbatim (Stage 3 / D4)', () => {
|
|
42
|
+
test('compiles with no BF021 on a JS-runtime adapter', () => {
|
|
43
|
+
const r = compile(arrayBuilder, false)
|
|
44
|
+
expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(0)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('no raw JSX leaks into the client bundle; the leaf is a lowered string', () => {
|
|
48
|
+
const r = compile(arrayBuilder, false)
|
|
49
|
+
const cj = r.files.find(f => f.type === 'clientJs')!.content
|
|
50
|
+
// The raw `out.push(<td>…)` must not survive — raw JSX is invalid plain JS.
|
|
51
|
+
expect(cj).not.toMatch(/out\.push\(</)
|
|
52
|
+
// …it lowers to a template-literal HTML string instead.
|
|
53
|
+
expect(cj).toMatch(/out\.push\(`<td>/)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('leaf text interpolations escape like the SSR JSX runtime (parity)', () => {
|
|
57
|
+
// A JSX-runtime SSR adapter auto-escapes `{c}`; the client's HTML-string
|
|
58
|
+
// lowering must escape the same position (renderPreamble's leaf door) or
|
|
59
|
+
// special characters in data would parse as markup client-side only.
|
|
60
|
+
// Byte-level parity is pinned by the map-array-builder-escaping fixture.
|
|
61
|
+
const r = compile(arrayBuilder, false)
|
|
62
|
+
const cj = r.files.find(f => f.type === 'clientJs')!.content
|
|
63
|
+
expect(cj).toMatch(/out\.push\(`<td>\$\{escapeText\(\(c\)\)\}<\/td>`\)/)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('the element-array child {out} is joined into the row', () => {
|
|
67
|
+
const r = compile(arrayBuilder, false)
|
|
68
|
+
const cj = r.files.find(f => f.type === 'clientJs')!.content
|
|
69
|
+
// `out` is an array of HTML strings; the child interpolation must join it,
|
|
70
|
+
// not `String([])`-comma-collapse it.
|
|
71
|
+
expect(cj).toMatch(/Array\.isArray\(out\)\s*\?\s*out\.join\(''\)/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('keyFn is hoisted from the returned root and derived from the raw item', () => {
|
|
75
|
+
const r = compile(arrayBuilder, false)
|
|
76
|
+
const cj = r.files.find(f => f.type === 'clientJs')!.content
|
|
77
|
+
expect(cj).toMatch(/\(r\)\s*=>\s*String\(r\.id\)/)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('DSL adapter refuses with BF021 naming the /* @client */ escape', () => {
|
|
81
|
+
const r = compile(arrayBuilder, true)
|
|
82
|
+
const bf021 = r.errors.filter(e => e.code === 'BF021')
|
|
83
|
+
expect(bf021).toHaveLength(1)
|
|
84
|
+
expect(bf021[0].suggestion?.message ?? '').toMatch(/@client/)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('a /* @client */-marked array-builder compiles clean on a DSL adapter', () => {
|
|
88
|
+
// The escape: the loop renders client-only (the DSL SSR adapter emits
|
|
89
|
+
// markers, the browser runs the verbatim body), so the DSL refusal lifts.
|
|
90
|
+
const src = `
|
|
91
|
+
function Table({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
92
|
+
return <table><tbody>{/* @client */ rows.map((r) => {
|
|
93
|
+
const out = []
|
|
94
|
+
for (const c of r.cells) out.push(<td>{c}</td>)
|
|
95
|
+
return <tr key={r.id}>{out}</tr>
|
|
96
|
+
})}</tbody></table>
|
|
97
|
+
}
|
|
98
|
+
export { Table }
|
|
99
|
+
`
|
|
100
|
+
const r = compile(src, true)
|
|
101
|
+
expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(0)
|
|
102
|
+
const cj = r.files.find(f => f.type === 'clientJs')!.content
|
|
103
|
+
// The browser still runs the verbatim body — the leaf is a lowered string.
|
|
104
|
+
expect(cj).toMatch(/out\.push\(`<td>/)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('.map() array-builder — {out} join is scoped to the bare identifier', () => {
|
|
109
|
+
test('only the bare {out} child joins; a property access on it does not', () => {
|
|
110
|
+
// `{out}` is the element-array child and must join; `{out.length}` is a
|
|
111
|
+
// number and must NOT get the array-join coercion (which would also
|
|
112
|
+
// double-read the expression).
|
|
113
|
+
const src = `
|
|
114
|
+
function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
115
|
+
return <table><tbody>{rows.map((r) => {
|
|
116
|
+
const out = []
|
|
117
|
+
for (const c of r.cells) out.push(<td>{c}</td>)
|
|
118
|
+
return <tr key={r.id}>{out}<td>{out.length}</td></tr>
|
|
119
|
+
})}</tbody></table>
|
|
120
|
+
}
|
|
121
|
+
export { T }
|
|
122
|
+
`
|
|
123
|
+
const r = compile(src, false)
|
|
124
|
+
const cj = r.files.find(f => f.type === 'clientJs')!.content
|
|
125
|
+
expect(cj).toMatch(/Array\.isArray\(out\)\s*\?\s*out\.join\(''\)/)
|
|
126
|
+
expect(cj).not.toMatch(/Array\.isArray\(out\.length\)/)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('.map() array-builder — D5 keyFn hoist contract', () => {
|
|
131
|
+
test('a key derived from a preamble-computed local is refused', () => {
|
|
132
|
+
// `k` is computed in the body; the key runs *before* the body, so it cannot
|
|
133
|
+
// reference `k`. Refuse rather than emit an unbound keyFn.
|
|
134
|
+
const src = `
|
|
135
|
+
function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
136
|
+
return <table><tbody>{rows.map((r) => {
|
|
137
|
+
const k = r.id + '-row'
|
|
138
|
+
const out = []
|
|
139
|
+
for (const c of r.cells) out.push(<td>{c}</td>)
|
|
140
|
+
return <tr key={k}>{out}</tr>
|
|
141
|
+
})}</tbody></table>
|
|
142
|
+
}
|
|
143
|
+
export { T }
|
|
144
|
+
`
|
|
145
|
+
const r = compile(src, false)
|
|
146
|
+
expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(1)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('a key derived from a destructured preamble local is refused', () => {
|
|
150
|
+
// The guard must see `k` even when it is bound via destructuring — else it
|
|
151
|
+
// would compile to a keyFn referencing an unbound `k`.
|
|
152
|
+
const src = `
|
|
153
|
+
function T({ rows }: { rows: { meta: { id: string }; cells: string[] }[] }) {
|
|
154
|
+
return <table><tbody>{rows.map((r) => {
|
|
155
|
+
const { id: k } = r.meta
|
|
156
|
+
const out = []
|
|
157
|
+
for (const c of r.cells) out.push(<td>{c}</td>)
|
|
158
|
+
return <tr key={k}>{out}</tr>
|
|
159
|
+
})}</tbody></table>
|
|
160
|
+
}
|
|
161
|
+
export { T }
|
|
162
|
+
`
|
|
163
|
+
const r = compile(src, false)
|
|
164
|
+
expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(1)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('.map() array-builder — leaf scope (D-E: refuse what cannot be wired)', () => {
|
|
169
|
+
test('a leaf with an event handler is refused (no silent divergence)', () => {
|
|
170
|
+
const src = `
|
|
171
|
+
function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
172
|
+
return <table><tbody>{rows.map((r) => {
|
|
173
|
+
const out = []
|
|
174
|
+
for (const c of r.cells) out.push(<td onClick={() => console.log(c)}>{c}</td>)
|
|
175
|
+
return <tr key={r.id}>{out}</tr>
|
|
176
|
+
})}</tbody></table>
|
|
177
|
+
}
|
|
178
|
+
export { T }
|
|
179
|
+
`
|
|
180
|
+
const r = compile(src, false)
|
|
181
|
+
expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(1)
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('.map() arbitrary body — regression guards (unchanged)', () => {
|
|
186
|
+
test('a value-only const preamble still compiles clean (no false positive)', () => {
|
|
187
|
+
const src = `
|
|
188
|
+
function List({ items }: { items: { id: string; name: string }[] }) {
|
|
189
|
+
return <ul>{items.map((it) => {
|
|
190
|
+
const label = it.name.toUpperCase()
|
|
191
|
+
return <li key={it.id}>{label}</li>
|
|
192
|
+
})}</ul>
|
|
193
|
+
}
|
|
194
|
+
export { List }
|
|
195
|
+
`
|
|
196
|
+
const r = compile(src, false)
|
|
197
|
+
expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(0)
|
|
198
|
+
const cj = r.files.find(f => f.type === 'clientJs')!
|
|
199
|
+
expect(cj.content).toMatch(/const label\b/)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('JSX in unreachable code after the return does not trip BF021', () => {
|
|
203
|
+
const src = `
|
|
204
|
+
function List({ items }: { items: { id: string }[] }) {
|
|
205
|
+
return <ul>{items.map((it) => {
|
|
206
|
+
return <li key={it.id}>{it.id}</li>
|
|
207
|
+
const dead = <span>{it.id}</span>
|
|
208
|
+
})}</ul>
|
|
209
|
+
}
|
|
210
|
+
export { List }
|
|
211
|
+
`
|
|
212
|
+
const r = compile(src, false)
|
|
213
|
+
expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(0)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('a plain single-return JSX body is unaffected on a DSL adapter', () => {
|
|
217
|
+
const src = `
|
|
218
|
+
function List({ items }: { items: { id: string }[] }) {
|
|
219
|
+
return <ul>{items.map((it) => { return <li key={it.id}>{it.id}</li> })}</ul>
|
|
220
|
+
}
|
|
221
|
+
export { List }
|
|
222
|
+
`
|
|
223
|
+
const r = compile(src, true)
|
|
224
|
+
expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(0)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The no-silent-divergence trichotomy, made executable (Stage 3 root cure,
|
|
3
|
+
* spec/callback-fidelity.md).
|
|
4
|
+
*
|
|
5
|
+
* For EVERY `.map()` callback body shape, exactly one of these must hold:
|
|
6
|
+
* 1. it compiles clean AND the emitted client bundle is sound
|
|
7
|
+
* (parses as plain JS, no raw JSX, no compiler-internal sentinel), or
|
|
8
|
+
* 2. the compiler raises a BF error (loud, actionable refusal).
|
|
9
|
+
*
|
|
10
|
+
* A shape that compiles clean but emits a broken bundle is a SILENT HOLE —
|
|
11
|
+
* the failure class this harness exists to make impossible to reintroduce.
|
|
12
|
+
* Shapes cannot be enumerated; axes can. The matrix below sweeps:
|
|
13
|
+
*
|
|
14
|
+
* - return kind: single element / fragment / bare identifier / ternary
|
|
15
|
+
* - loop position: top-level / nested inner map / conditional branch
|
|
16
|
+
* - array kind: prop / module-scope const / signal
|
|
17
|
+
* - leaf content: static+interpolation / event handler / component
|
|
18
|
+
* - adapter tier: JS runtime / DSL (acceptsCallbackBody = false)
|
|
19
|
+
*
|
|
20
|
+
* `KNOWN_HOLES` pins the exact current reality: entries are shapes that are
|
|
21
|
+
* silent holes TODAY, tracked so the set only ever shrinks. A fixed hole must
|
|
22
|
+
* be removed from the set (the test fails if a listed hole stops leaking —
|
|
23
|
+
* no stale entries). The root-cure commits drive this set to empty; it must
|
|
24
|
+
* never grow.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
|
|
28
|
+
import ts from 'typescript'
|
|
29
|
+
import { compileJSX } from '../compiler'
|
|
30
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
31
|
+
|
|
32
|
+
// Arm the getJS trust-boundary assertion (analyzer-context.ts) for THIS
|
|
33
|
+
// file's compiles only: any getJS call on a JSX-bearing node throws instead
|
|
34
|
+
// of splicing raw JSX into output. Scoped via beforeAll/afterAll because bun
|
|
35
|
+
// runs suite files in one process — a module-scope assignment would leak the
|
|
36
|
+
// assertion into every later file and fail pre-existing raw-JSX-via-getJS
|
|
37
|
+
// paths outside this harness's axes (e.g. the Array.from mapper lowering),
|
|
38
|
+
// which are tracked for their own segments migration rather than pinned here.
|
|
39
|
+
let prevAssertEnv: string | undefined
|
|
40
|
+
beforeAll(() => {
|
|
41
|
+
prevAssertEnv = process.env.BF_ASSERT_NO_JSX_IN_GETJS
|
|
42
|
+
process.env.BF_ASSERT_NO_JSX_IN_GETJS = '1'
|
|
43
|
+
})
|
|
44
|
+
afterAll(() => {
|
|
45
|
+
if (prevAssertEnv === undefined) delete process.env.BF_ASSERT_NO_JSX_IN_GETJS
|
|
46
|
+
else process.env.BF_ASSERT_NO_JSX_IN_GETJS = prevAssertEnv
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
interface Shape {
|
|
50
|
+
id: string
|
|
51
|
+
source: string
|
|
52
|
+
/** Run on the DSL tier too (default: JS runtime only). */
|
|
53
|
+
dslTier?: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Shapes that are silent holes today. Shrink-only — never add entries. */
|
|
57
|
+
// EMPTY — and it must stay that way. The structured-preamble migration healed
|
|
58
|
+
// the multi-root, nested-inner-map, and branch-loop holes by construction
|
|
59
|
+
// (every plan builder renders through the single `renderPreamble` door), and
|
|
60
|
+
// the Phase-1 acceptance guard closed the return-shape holes (bare identifier,
|
|
61
|
+
// ternary: no single element root → loud BF021 with restructuring guidance).
|
|
62
|
+
// A future entry here means a NEW silent hole shipped: fix the leak, don't
|
|
63
|
+
// grow the set.
|
|
64
|
+
const KNOWN_HOLES: ReadonlySet<string> = new Set([])
|
|
65
|
+
|
|
66
|
+
const shapes: Shape[] = [
|
|
67
|
+
{
|
|
68
|
+
id: 'canonical-array-builder',
|
|
69
|
+
dslTier: true,
|
|
70
|
+
source: `
|
|
71
|
+
function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
72
|
+
return <table><tbody>{rows.map((r) => {
|
|
73
|
+
const out = []
|
|
74
|
+
for (const c of r.cells) out.push(<td>{c}</td>)
|
|
75
|
+
return <tr key={r.id}>{out}</tr>
|
|
76
|
+
})}</tbody></table>
|
|
77
|
+
}
|
|
78
|
+
export { T }`,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'while-push-builder',
|
|
82
|
+
source: `
|
|
83
|
+
function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
84
|
+
return <table><tbody>{rows.map((r) => {
|
|
85
|
+
const out = []
|
|
86
|
+
let i = 0
|
|
87
|
+
while (i < r.cells.length) { out.push(<td>{r.cells[i]}</td>); i++ }
|
|
88
|
+
return <tr key={r.id}>{out}</tr>
|
|
89
|
+
})}</tbody></table>
|
|
90
|
+
}
|
|
91
|
+
export { T }`,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'conditional-push-builder',
|
|
95
|
+
source: `
|
|
96
|
+
function T({ rows }: { rows: { id: string; on: boolean; cells: string[] }[] }) {
|
|
97
|
+
return <table><tbody>{rows.map((r) => {
|
|
98
|
+
const out = []
|
|
99
|
+
for (const c of r.cells) if (r.on) out.push(<td>{c}</td>)
|
|
100
|
+
return <tr key={r.id}>{out}</tr>
|
|
101
|
+
})}</tbody></table>
|
|
102
|
+
}
|
|
103
|
+
export { T }`,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: 'return-bare-identifier',
|
|
107
|
+
dslTier: true,
|
|
108
|
+
source: `
|
|
109
|
+
function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
110
|
+
return <ul>{rows.map((r) => {
|
|
111
|
+
const out = []
|
|
112
|
+
for (const c of r.cells) out.push(<li key={r.id + c}>{c}</li>)
|
|
113
|
+
return out
|
|
114
|
+
})}</ul>
|
|
115
|
+
}
|
|
116
|
+
export { T }`,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'return-multi-root-fragment',
|
|
120
|
+
dslTier: true,
|
|
121
|
+
source: `
|
|
122
|
+
function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
123
|
+
return <table><tbody>{rows.map((r) => {
|
|
124
|
+
const extra = <td>x</td>
|
|
125
|
+
return <>{r.cells.map((c) => <td key={c}>{c}</td>)}{extra}</>
|
|
126
|
+
})}</tbody></table>
|
|
127
|
+
}
|
|
128
|
+
export { T }`,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: 'return-ternary-after-builder',
|
|
132
|
+
source: `
|
|
133
|
+
function T({ rows }: { rows: { id: string; on: boolean; cells: string[] }[] }) {
|
|
134
|
+
return <table><tbody>{rows.map((r) => {
|
|
135
|
+
const out = []
|
|
136
|
+
for (const c of r.cells) out.push(<td>{c}</td>)
|
|
137
|
+
return r.on ? <tr key={r.id}>{out}</tr> : <tr key={r.id}><td>off</td></tr>
|
|
138
|
+
})}</tbody></table>
|
|
139
|
+
}
|
|
140
|
+
export { T }`,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 'nested-inner-map-builder',
|
|
144
|
+
source: `
|
|
145
|
+
function T({ groups }: { groups: { id: string; rows: { id: string; cells: string[] }[] }[] }) {
|
|
146
|
+
return <div>{groups.map((g) => (
|
|
147
|
+
<table key={g.id}><tbody>{g.rows.map((r) => {
|
|
148
|
+
const out = []
|
|
149
|
+
for (const c of r.cells) out.push(<td>{c}</td>)
|
|
150
|
+
return <tr key={r.id}>{out}</tr>
|
|
151
|
+
})}</tbody></table>
|
|
152
|
+
))}</div>
|
|
153
|
+
}
|
|
154
|
+
export { T }`,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: 'branch-loop-builder',
|
|
158
|
+
source: `
|
|
159
|
+
'use client'
|
|
160
|
+
import { createSignal } from '@barefootjs/client'
|
|
161
|
+
function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
162
|
+
const [show] = createSignal(true)
|
|
163
|
+
return <div>{show() ? <table><tbody>{rows.map((r) => {
|
|
164
|
+
const out = []
|
|
165
|
+
for (const c of r.cells) out.push(<td>{c}</td>)
|
|
166
|
+
return <tr key={r.id}>{out}</tr>
|
|
167
|
+
})}</tbody></table> : <p>none</p>}</div>
|
|
168
|
+
}
|
|
169
|
+
export { T }`,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: 'static-module-const-array-builder',
|
|
173
|
+
source: `
|
|
174
|
+
const ROWS = [{ id: '1', cells: ['a', 'b'] }, { id: '2', cells: ['c'] }]
|
|
175
|
+
function T() {
|
|
176
|
+
return <table><tbody>{ROWS.map((r) => {
|
|
177
|
+
const out = []
|
|
178
|
+
for (const c of r.cells) out.push(<td>{c}</td>)
|
|
179
|
+
return <tr key={r.id}>{out}</tr>
|
|
180
|
+
})}</tbody></table>
|
|
181
|
+
}
|
|
182
|
+
export { T }`,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 'signal-array-builder',
|
|
186
|
+
source: `
|
|
187
|
+
'use client'
|
|
188
|
+
import { createSignal } from '@barefootjs/client'
|
|
189
|
+
function T() {
|
|
190
|
+
const [rows] = createSignal([{ id: '1', cells: ['a'] }])
|
|
191
|
+
return <table><tbody>{rows().map((r) => {
|
|
192
|
+
const out = []
|
|
193
|
+
for (const c of r.cells) out.push(<td>{c}</td>)
|
|
194
|
+
return <tr key={r.id}>{out}</tr>
|
|
195
|
+
})}</tbody></table>
|
|
196
|
+
}
|
|
197
|
+
export { T }`,
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 'leaf-with-event-handler',
|
|
201
|
+
source: `
|
|
202
|
+
function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
203
|
+
return <table><tbody>{rows.map((r) => {
|
|
204
|
+
const out = []
|
|
205
|
+
for (const c of r.cells) out.push(<td onClick={() => console.log(c)}>{c}</td>)
|
|
206
|
+
return <tr key={r.id}>{out}</tr>
|
|
207
|
+
})}</tbody></table>
|
|
208
|
+
}
|
|
209
|
+
export { T }`,
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: 'component-root-builder',
|
|
213
|
+
// Builder preamble + component root: the component would receive raw HTML
|
|
214
|
+
// strings on the client but JSX elements at SSR — refused (dom-ops row
|
|
215
|
+
// construction cannot host a string-lowered preamble).
|
|
216
|
+
source: `
|
|
217
|
+
import { Row } from './row'
|
|
218
|
+
function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
219
|
+
return <table><tbody>{rows.map((r) => {
|
|
220
|
+
const out = []
|
|
221
|
+
for (const c of r.cells) out.push(<td>{c}</td>)
|
|
222
|
+
return <Row key={r.id} cells={out} />
|
|
223
|
+
})}</tbody></table>
|
|
224
|
+
}
|
|
225
|
+
export { T }`,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: 'leaf-with-component',
|
|
229
|
+
source: `
|
|
230
|
+
import { Badge } from './badge'
|
|
231
|
+
function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
232
|
+
return <table><tbody>{rows.map((r) => {
|
|
233
|
+
const out = []
|
|
234
|
+
for (const c of r.cells) out.push(<td><Badge label={c} /></td>)
|
|
235
|
+
return <tr key={r.id}>{out}</tr>
|
|
236
|
+
})}</tbody></table>
|
|
237
|
+
}
|
|
238
|
+
export { T }`,
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: 'flatmap-block-body',
|
|
242
|
+
// flatMap block bodies ride the same segments + renderPreamble machinery.
|
|
243
|
+
source: `
|
|
244
|
+
function T({ items }: { items: { id: string; tags: string[] }[] }) {
|
|
245
|
+
return <ul>{items.flatMap((it) => {
|
|
246
|
+
return it.tags.map((t) => <li key={t}>{t}</li>)
|
|
247
|
+
})}</ul>
|
|
248
|
+
}
|
|
249
|
+
export { T }`,
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: 'flatmap-nested-in-map',
|
|
253
|
+
source: `
|
|
254
|
+
function T({ groups }: { groups: { id: string; items: { id: string; tags: string[] }[] }[] }) {
|
|
255
|
+
return <div>{groups.map((g) => (
|
|
256
|
+
<ul key={g.id}>{g.items.flatMap((it) => {
|
|
257
|
+
return it.tags.map((t) => <li key={t}>{t}</li>)
|
|
258
|
+
})}</ul>
|
|
259
|
+
))}</div>
|
|
260
|
+
}
|
|
261
|
+
export { T }`,
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: 'flatmap-expression-body',
|
|
265
|
+
// The unbraced twin of flatmap-block-body: `t => t.tags.map(...)` with no
|
|
266
|
+
// block. Pre-fix this fell through every dispatch arm to the IRExpression
|
|
267
|
+
// scalar path and spliced the raw callback — JSX included — verbatim into
|
|
268
|
+
// the client bundle (a silent SyntaxError caught only by parseErrors).
|
|
269
|
+
dslTier: true,
|
|
270
|
+
source: `
|
|
271
|
+
function T({ items }: { items: { id: string; tags: string[] }[] }) {
|
|
272
|
+
return <ul>{items.flatMap((it) => it.tags.map((t) => <li key={t}>{t}</li>))}</ul>
|
|
273
|
+
}
|
|
274
|
+
export { T }`,
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
id: 'flatmap-expression-body-parenthesized',
|
|
278
|
+
source: `
|
|
279
|
+
function T({ items }: { items: { id: string; tags: string[] }[] }) {
|
|
280
|
+
return <ul>{items.flatMap((it) => (it.tags.map((t) => <li key={t}>{t}</li>)))}</ul>
|
|
281
|
+
}
|
|
282
|
+
export { T }`,
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: 'flatmap-expression-body-signal-array',
|
|
286
|
+
source: `
|
|
287
|
+
'use client'
|
|
288
|
+
import { createSignal } from '@barefootjs/client'
|
|
289
|
+
function T() {
|
|
290
|
+
const [items] = createSignal([{ id: '1', tags: ['a'] }])
|
|
291
|
+
return <ul>{items().flatMap((it) => it.tags.map((t) => <li key={t}>{t}</li>))}</ul>
|
|
292
|
+
}
|
|
293
|
+
export { T }`,
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: 'map-jsx-inside-unrecognized-call',
|
|
297
|
+
// An inline JSX literal in a body shape no dispatch arm recognizes (a call
|
|
298
|
+
// wrapping JSX). The structural net refuses loudly instead of letting the
|
|
299
|
+
// scalar fallback splice the raw JSX into the bundle.
|
|
300
|
+
dslTier: true,
|
|
301
|
+
source: `
|
|
302
|
+
declare function wrap(x: unknown): unknown
|
|
303
|
+
function T({ items }: { items: { id: string }[] }) {
|
|
304
|
+
return <ul>{items.map((it) => wrap(<li key={it.id}>{it.id}</li>))}</ul>
|
|
305
|
+
}
|
|
306
|
+
export { T }`,
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
id: 'flatmap-leaf-in-template-literal',
|
|
310
|
+
// A leaf inside a template literal is refused (segment boundary would
|
|
311
|
+
// split the literal's lexical state) — same rule as map preambles.
|
|
312
|
+
source: `
|
|
313
|
+
function T({ items }: { items: { id: string }[] }) {
|
|
314
|
+
return <ul>{items.flatMap((it) => {
|
|
315
|
+
const s = \`x\${<b>{it.id}</b>}\`
|
|
316
|
+
return [<li key={it.id}>{s}</li>]
|
|
317
|
+
})}</ul>
|
|
318
|
+
}
|
|
319
|
+
export { T }`,
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: 'value-only-preamble',
|
|
323
|
+
dslTier: true,
|
|
324
|
+
source: `
|
|
325
|
+
function T({ items }: { items: { id: string; name: string }[] }) {
|
|
326
|
+
return <ul>{items.map((it) => {
|
|
327
|
+
const label = it.name.toUpperCase()
|
|
328
|
+
return <li key={it.id}>{label}</li>
|
|
329
|
+
})}</ul>
|
|
330
|
+
}
|
|
331
|
+
export { T }`,
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
id: 'client-marked-builder-on-dsl',
|
|
335
|
+
dslTier: true,
|
|
336
|
+
source: `
|
|
337
|
+
function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
338
|
+
return <table><tbody>{/* @client */ rows.map((r) => {
|
|
339
|
+
const out = []
|
|
340
|
+
for (const c of r.cells) out.push(<td>{c}</td>)
|
|
341
|
+
return <tr key={r.id}>{out}</tr>
|
|
342
|
+
})}</tbody></table>
|
|
343
|
+
}
|
|
344
|
+
export { T }`,
|
|
345
|
+
},
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
interface Soundness {
|
|
349
|
+
hasBfError: boolean
|
|
350
|
+
sentinelLeak: boolean
|
|
351
|
+
rawJsxLeak: boolean
|
|
352
|
+
parseErrors: number
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function assess(source: string, dsl: boolean): Soundness {
|
|
356
|
+
const a = new TestAdapter()
|
|
357
|
+
if (dsl) a.acceptsCallbackBody = () => false
|
|
358
|
+
const r = compileJSX(source, 'T.tsx', { adapter: a })
|
|
359
|
+
const cj = r.files.find((f) => f.type === 'clientJs')?.content ?? ''
|
|
360
|
+
|
|
361
|
+
// Emitted client JS must be plain JS: parse it with JSX disabled and count
|
|
362
|
+
// syntactic diagnostics. Raw JSX (`out.push(<td>…)`) is not valid plain JS.
|
|
363
|
+
const sf = ts.createSourceFile('bundle.js', cj, ts.ScriptTarget.ES2022, /*setParentNodes*/ false, ts.ScriptKind.JS)
|
|
364
|
+
const parseErrors = (sf as unknown as { parseDiagnostics: unknown[] }).parseDiagnostics.length
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
hasBfError: r.errors.some((e) => e.severity !== 'warning'),
|
|
368
|
+
sentinelLeak: /__BF_JSX_\d+__/.test(cj),
|
|
369
|
+
rawJsxLeak: /\bpush\(</.test(cj),
|
|
370
|
+
parseErrors,
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function isSound(s: Soundness): boolean {
|
|
375
|
+
return !s.sentinelLeak && !s.rawJsxLeak && s.parseErrors === 0
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
describe('.map() body trichotomy — sound bundle XOR loud error, never silence', () => {
|
|
379
|
+
for (const shape of shapes) {
|
|
380
|
+
const tiers = shape.dslTier ? [false, true] : [false]
|
|
381
|
+
for (const dsl of tiers) {
|
|
382
|
+
const tier = dsl ? 'DSL' : 'JS-runtime'
|
|
383
|
+
// Holes are tracked on the JS-runtime tier (DSL refuses via gating).
|
|
384
|
+
const isKnownHole = !dsl && KNOWN_HOLES.has(shape.id)
|
|
385
|
+
|
|
386
|
+
test(`${shape.id} (${tier})${isKnownHole ? ' [known hole]' : ''}`, () => {
|
|
387
|
+
const s = assess(shape.source, dsl)
|
|
388
|
+
if (isKnownHole) {
|
|
389
|
+
// Pin the hole precisely: it must still be SILENT (no error) and
|
|
390
|
+
// UNSOUND. When a root-cure commit fixes it, this assertion fails,
|
|
391
|
+
// forcing the entry's removal — the set can only shrink.
|
|
392
|
+
expect(s.hasBfError).toBe(false)
|
|
393
|
+
expect(isSound(s)).toBe(false)
|
|
394
|
+
} else {
|
|
395
|
+
// The trichotomy: sound clean compile, or a loud error.
|
|
396
|
+
expect(s.hasBfError || isSound(s)).toBe(true)
|
|
397
|
+
if (s.hasBfError) {
|
|
398
|
+
// Even a refused compile must not leak the sentinel into output.
|
|
399
|
+
expect(s.sentinelLeak).toBe(false)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
})
|