@barefootjs/jsx 0.26.2 → 0.26.3
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 +662 -223
- package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +2 -0
- 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/loop.d.ts +12 -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.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.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +19 -1
- package/dist/ir-to-client-js/html-template.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/types.d.ts +4 -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 +96 -32
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +94 -0
- package/src/__tests__/client-js-generation.test.ts +10 -3
- package/src/__tests__/compiler-stress-1244.test.ts +12 -1
- package/src/__tests__/event-delegation-scope-2367.test.ts +108 -0
- package/src/__tests__/flatmap-segments.test.ts +80 -0
- package/src/__tests__/map-arbitrary-body.test.ts +226 -0
- package/src/__tests__/map-body-no-silent-divergence.test.ts +360 -0
- package/src/__tests__/map-multi-return-body.test.ts +172 -0
- 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 +3 -2
- package/src/ir-to-client-js/collect-elements.ts +3 -3
- package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +2 -0
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +7 -2
- 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 +16 -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 +17 -2
- package/src/ir-to-client-js/control-flow/plan/loop.ts +12 -0
- package/src/ir-to-client-js/control-flow/shared.ts +2 -1
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +9 -2
- package/src/ir-to-client-js/control-flow.ts +11 -0
- package/src/ir-to-client-js/html-template.ts +133 -37
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +22 -10
- package/src/ir-to-client-js/types.ts +4 -3
- package/src/jsx-to-ir.ts +690 -164
- 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 +110 -35
|
@@ -0,0 +1,360 @@
|
|
|
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-leaf-in-template-literal',
|
|
265
|
+
// A leaf inside a template literal is refused (segment boundary would
|
|
266
|
+
// split the literal's lexical state) — same rule as map preambles.
|
|
267
|
+
source: `
|
|
268
|
+
function T({ items }: { items: { id: string }[] }) {
|
|
269
|
+
return <ul>{items.flatMap((it) => {
|
|
270
|
+
const s = \`x\${<b>{it.id}</b>}\`
|
|
271
|
+
return [<li key={it.id}>{s}</li>]
|
|
272
|
+
})}</ul>
|
|
273
|
+
}
|
|
274
|
+
export { T }`,
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
id: 'value-only-preamble',
|
|
278
|
+
dslTier: true,
|
|
279
|
+
source: `
|
|
280
|
+
function T({ items }: { items: { id: string; name: string }[] }) {
|
|
281
|
+
return <ul>{items.map((it) => {
|
|
282
|
+
const label = it.name.toUpperCase()
|
|
283
|
+
return <li key={it.id}>{label}</li>
|
|
284
|
+
})}</ul>
|
|
285
|
+
}
|
|
286
|
+
export { T }`,
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
id: 'client-marked-builder-on-dsl',
|
|
290
|
+
dslTier: true,
|
|
291
|
+
source: `
|
|
292
|
+
function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
|
|
293
|
+
return <table><tbody>{/* @client */ rows.map((r) => {
|
|
294
|
+
const out = []
|
|
295
|
+
for (const c of r.cells) out.push(<td>{c}</td>)
|
|
296
|
+
return <tr key={r.id}>{out}</tr>
|
|
297
|
+
})}</tbody></table>
|
|
298
|
+
}
|
|
299
|
+
export { T }`,
|
|
300
|
+
},
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
interface Soundness {
|
|
304
|
+
hasBfError: boolean
|
|
305
|
+
sentinelLeak: boolean
|
|
306
|
+
rawJsxLeak: boolean
|
|
307
|
+
parseErrors: number
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function assess(source: string, dsl: boolean): Soundness {
|
|
311
|
+
const a = new TestAdapter()
|
|
312
|
+
if (dsl) a.acceptsCallbackBody = () => false
|
|
313
|
+
const r = compileJSX(source, 'T.tsx', { adapter: a })
|
|
314
|
+
const cj = r.files.find((f) => f.type === 'clientJs')?.content ?? ''
|
|
315
|
+
|
|
316
|
+
// Emitted client JS must be plain JS: parse it with JSX disabled and count
|
|
317
|
+
// syntactic diagnostics. Raw JSX (`out.push(<td>…)`) is not valid plain JS.
|
|
318
|
+
const sf = ts.createSourceFile('bundle.js', cj, ts.ScriptTarget.ES2022, /*setParentNodes*/ false, ts.ScriptKind.JS)
|
|
319
|
+
const parseErrors = (sf as unknown as { parseDiagnostics: unknown[] }).parseDiagnostics.length
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
hasBfError: r.errors.some((e) => e.severity !== 'warning'),
|
|
323
|
+
sentinelLeak: /__BF_JSX_\d+__/.test(cj),
|
|
324
|
+
rawJsxLeak: /\bpush\(</.test(cj),
|
|
325
|
+
parseErrors,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function isSound(s: Soundness): boolean {
|
|
330
|
+
return !s.sentinelLeak && !s.rawJsxLeak && s.parseErrors === 0
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
describe('.map() body trichotomy — sound bundle XOR loud error, never silence', () => {
|
|
334
|
+
for (const shape of shapes) {
|
|
335
|
+
const tiers = shape.dslTier ? [false, true] : [false]
|
|
336
|
+
for (const dsl of tiers) {
|
|
337
|
+
const tier = dsl ? 'DSL' : 'JS-runtime'
|
|
338
|
+
// Holes are tracked on the JS-runtime tier (DSL refuses via gating).
|
|
339
|
+
const isKnownHole = !dsl && KNOWN_HOLES.has(shape.id)
|
|
340
|
+
|
|
341
|
+
test(`${shape.id} (${tier})${isKnownHole ? ' [known hole]' : ''}`, () => {
|
|
342
|
+
const s = assess(shape.source, dsl)
|
|
343
|
+
if (isKnownHole) {
|
|
344
|
+
// Pin the hole precisely: it must still be SILENT (no error) and
|
|
345
|
+
// UNSOUND. When a root-cure commit fixes it, this assertion fails,
|
|
346
|
+
// forcing the entry's removal — the set can only shrink.
|
|
347
|
+
expect(s.hasBfError).toBe(false)
|
|
348
|
+
expect(isSound(s)).toBe(false)
|
|
349
|
+
} else {
|
|
350
|
+
// The trichotomy: sound clean compile, or a loud error.
|
|
351
|
+
expect(s.hasBfError || isSound(s)).toBe(true)
|
|
352
|
+
if (s.hasBfError) {
|
|
353
|
+
// Even a refused compile must not leak the sentinel into output.
|
|
354
|
+
expect(s.sentinelLeak).toBe(false)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
})
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 2 of spec/callback-fidelity.md — folding a `.map()` callback whose
|
|
3
|
+
* body is an if/else-if chain or `switch` (including fallthrough case labels),
|
|
4
|
+
* optionally preceded by a leading-`const`/`let` preamble, into a nested
|
|
5
|
+
* `IRConditional`, instead of the prior silent verbatim leak. The preamble
|
|
6
|
+
* fold is adapter-gated: a JS runtime folds it, a DSL adapter refuses (BF021 +
|
|
7
|
+
* `/* @client *\/`). Also pins the conservative bail for shapes the fold can't
|
|
8
|
+
* carry — a branch-local local, or a preamble with a null-returning branch —
|
|
9
|
+
* so nothing is dropped silently.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, test, expect } from 'bun:test'
|
|
13
|
+
import { compileJSX } from '../compiler'
|
|
14
|
+
import { extractMultiReturnJsxBranches } from '../analyzer'
|
|
15
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
16
|
+
import * as ts from 'typescript'
|
|
17
|
+
|
|
18
|
+
const adapter = new TestAdapter()
|
|
19
|
+
|
|
20
|
+
function clientJs(source: string): string {
|
|
21
|
+
const result = compileJSX(source, 'List.tsx', { adapter })
|
|
22
|
+
expect(result.errors).toHaveLength(0)
|
|
23
|
+
const cj = result.files.find(f => f.type === 'clientJs')
|
|
24
|
+
expect(cj).toBeDefined()
|
|
25
|
+
return cj!.content
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const wrap = (body: string) => `
|
|
29
|
+
function List({ items }: { items: { id: string; on: boolean; kind: string }[] }) {
|
|
30
|
+
return <ul>{items.map((it) => ${body})}</ul>
|
|
31
|
+
}
|
|
32
|
+
export { List }
|
|
33
|
+
`
|
|
34
|
+
|
|
35
|
+
describe('.map() multi-return body fold (Stage 2)', () => {
|
|
36
|
+
test('if/else-if chain folds to a conditional — no raw JSX leak', () => {
|
|
37
|
+
const js = clientJs(wrap(`{
|
|
38
|
+
if (it.kind === 'a') return <li key={it.id}>A</li>
|
|
39
|
+
else if (it.kind === 'b') return <li key={it.id}>B</li>
|
|
40
|
+
return <li key={it.id}>C</li>
|
|
41
|
+
}`))
|
|
42
|
+
// The raw \`if (...) return <li ...>\` must not survive into the callback.
|
|
43
|
+
expect(js).not.toMatch(/return <li/)
|
|
44
|
+
// A ternary chain over the branch conditions is emitted instead.
|
|
45
|
+
expect(js).toMatch(/kind === 'a'/)
|
|
46
|
+
expect(js).toMatch(/kind === 'b'/)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('switch (with default) folds with a parenthesized strict-equality condition', () => {
|
|
50
|
+
const js = clientJs(wrap(`{
|
|
51
|
+
switch (it.kind) {
|
|
52
|
+
case 'a': return <b key={it.id}>A</b>
|
|
53
|
+
default: return <span key={it.id}>D</span>
|
|
54
|
+
}
|
|
55
|
+
}`))
|
|
56
|
+
expect(js).not.toMatch(/switch\s*\(/)
|
|
57
|
+
// Both operands parenthesized so a low-precedence case keeps === semantics.
|
|
58
|
+
expect(js).toMatch(/\(it\(\)\.kind\) === \('a'\)/)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('switch with fallthrough case labels folds with an OR condition', () => {
|
|
62
|
+
const js = clientJs(wrap(`{
|
|
63
|
+
switch (it.kind) {
|
|
64
|
+
case 'a':
|
|
65
|
+
case 'b':
|
|
66
|
+
return <b key={it.id}>AB</b>
|
|
67
|
+
default:
|
|
68
|
+
return <span key={it.id}>D</span>
|
|
69
|
+
}
|
|
70
|
+
}`))
|
|
71
|
+
expect(js).not.toMatch(/switch\s*\(/)
|
|
72
|
+
expect(js).not.toMatch(/return <(b|span)/)
|
|
73
|
+
// Both fallthrough labels OR-joined into one branch condition.
|
|
74
|
+
expect(js).toMatch(/=== \('a'\)/)
|
|
75
|
+
expect(js).toMatch(/=== \('b'\)/)
|
|
76
|
+
expect(js).toMatch(/\|\|/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('conservative bail — no silent drop', () => {
|
|
80
|
+
function extract(body: string, allowPreamble = false) {
|
|
81
|
+
const sf = ts.createSourceFile('t.tsx', `const f = (it: any) => ${body}`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
82
|
+
let block: ts.Block | undefined
|
|
83
|
+
const visit = (n: ts.Node) => {
|
|
84
|
+
if (ts.isArrowFunction(n) && ts.isBlock(n.body)) block = n.body
|
|
85
|
+
ts.forEachChild(n, visit)
|
|
86
|
+
}
|
|
87
|
+
visit(sf)
|
|
88
|
+
return extractMultiReturnJsxBranches(block!, allowPreamble)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test('a branch-local const bails (would otherwise be dropped)', () => {
|
|
92
|
+
const r = extract(`{ if (it.on) { const x = it.kind; return <b>{x}</b> } return <span>Z</span> }`)
|
|
93
|
+
expect(r).toBeNull()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('a switch case with an extra statement bails', () => {
|
|
97
|
+
const r = extract(`{ switch (it.kind) { case 'a': { const y = it.id; return <b>{y}</b> } default: return <span>D</span> } }`)
|
|
98
|
+
expect(r).toBeNull()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('a switch case with a break before the return bails (return unreachable)', () => {
|
|
102
|
+
// `case 'a': break; return <A/>` — the break makes the return
|
|
103
|
+
// unreachable at runtime (the case exits to undefined), so it must not
|
|
104
|
+
// fold as if it rendered <A/>. (#2378 review.)
|
|
105
|
+
const r = extract(`{ switch (it.kind) { case 'a': break; return <b>A</b>; default: return <span>D</span> } }`)
|
|
106
|
+
expect(r).toBeNull()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('a leading const is a preamble only when allowPreamble is set', () => {
|
|
110
|
+
const body = `{ const label = it.kind; if (it.on) return <b>{label}</b>; return <span>{label}</span> }`
|
|
111
|
+
// The helper-function inliner passes no allowPreamble — bails.
|
|
112
|
+
expect(extract(body)).toBeNull()
|
|
113
|
+
// The .map() body fold opts in — collects the leading const as a preamble.
|
|
114
|
+
const r = extract(body, true)
|
|
115
|
+
expect(r).not.toBeNull()
|
|
116
|
+
expect(r!.preamble?.length).toBe(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('a leading `var` preamble is not collected (var hoisting semantics)', () => {
|
|
120
|
+
// Only `const`/`let` are collectable; `var` hoists differently, so the
|
|
121
|
+
// per-iteration preamble emit wouldn't preserve semantics. (#2379 review.)
|
|
122
|
+
const body = `{ var label = it.kind; if (it.on) return <b>{label}</b>; return <span>{label}</span> }`
|
|
123
|
+
expect(extract(body, true)).toBeNull()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('a preamble with a null-returning branch bails (mapArrayAnchored hazard)', () => {
|
|
127
|
+
// `const f = …; if (!f) return null; return <div key={fid}>…</div>` — the
|
|
128
|
+
// null early-return routes the loop through the anchored conditional-item
|
|
129
|
+
// runtime, where a preamble local breaks keyed reactivity (the
|
|
130
|
+
// pivot-table demo shape). Fold only the all-JSX preamble case.
|
|
131
|
+
const body = `{ const f = it.field; if (!f) return null; return <div key={it.id}>{f}</div> }`
|
|
132
|
+
expect(extract(body, true)).toBeNull()
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('leading-const preamble is adapter-gated (JS folds, DSL refuses)', () => {
|
|
137
|
+
const preambleBody = `{
|
|
138
|
+
const label = it.kind.toUpperCase()
|
|
139
|
+
if (it.on) return <b key={it.id}>{label}</b>
|
|
140
|
+
return <span key={it.id}>{label}</span>
|
|
141
|
+
}`
|
|
142
|
+
|
|
143
|
+
function compileWith(source: string, dsl: boolean) {
|
|
144
|
+
const a = new TestAdapter()
|
|
145
|
+
// Model a DSL adapter: its template runtime can't run a callback body
|
|
146
|
+
// verbatim, so `acceptsCallbackBody` reports false for every kind.
|
|
147
|
+
if (dsl) a.acceptsCallbackBody = () => false
|
|
148
|
+
return compileJSX(source, 'List.tsx', { adapter: a })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
test('a JS-runtime adapter folds the preamble (const emitted, no error)', () => {
|
|
152
|
+
const r = compileWith(wrap(preambleBody), false)
|
|
153
|
+
expect(r.errors).toHaveLength(0)
|
|
154
|
+
const cj = r.files.find(f => f.type === 'clientJs')!
|
|
155
|
+
expect(cj.content).toMatch(/const label\b/)
|
|
156
|
+
expect(cj.content).not.toMatch(/return <(b|span)/)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('a DSL adapter refuses with BF021 + the /* @client */ escape', () => {
|
|
160
|
+
const r = compileWith(wrap(preambleBody), true)
|
|
161
|
+
const bf021 = r.errors.filter(e => e.code === 'BF021')
|
|
162
|
+
expect(bf021).toHaveLength(1)
|
|
163
|
+
expect(bf021[0].suggestion?.message).toContain('@client')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('/* @client */ suppresses the DSL refusal', () => {
|
|
167
|
+
const clientSource = wrap(preambleBody).replace('items.map', '/* @client */ items.map')
|
|
168
|
+
const r = compileWith(clientSource, true)
|
|
169
|
+
expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(0)
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
})
|
|
@@ -125,13 +125,31 @@ describe('Unsupported Expression Error (BF021)', () => {
|
|
|
125
125
|
expect(ir!.type).toBe('element')
|
|
126
126
|
})
|
|
127
127
|
|
|
128
|
-
test('compileJSX
|
|
129
|
-
|
|
128
|
+
test('compileJSX surfaces IR-phase BF021 for a DSL target (no acceptsCallbackBody)', () => {
|
|
129
|
+
// Model a DSL adapter: its template runtime can't run an off-subset
|
|
130
|
+
// predicate verbatim, so the Phase-1 diagnostic must surface in the result.
|
|
131
|
+
// Real DSL adapters leave `acceptsCallbackBody` unset; the gate
|
|
132
|
+
// (`?.(kind) ?? false`) treats unset and a false-returning predicate
|
|
133
|
+
// identically, so override it to decline every callback kind.
|
|
134
|
+
const dslAdapter = new TestAdapter()
|
|
135
|
+
dslAdapter.acceptsCallbackBody = () => false
|
|
136
|
+
const result = compileJSX(unsupportedSource, 'TodoList.tsx', { adapter: dslAdapter })
|
|
130
137
|
const bf021 = result.errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
|
|
131
138
|
|
|
132
139
|
expect(bf021).toHaveLength(1)
|
|
133
140
|
expect(bf021[0].severity).toBe('error')
|
|
134
141
|
expect(bf021[0].message).toContain('Expression cannot be compiled to marked template')
|
|
142
|
+
expect(bf021[0].suggestion?.message).toContain('@client')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('compileJSX does NOT raise BF021 for a JS-runtime target (fidelity)', () => {
|
|
146
|
+
// A JS runtime (Hono / CSR — the default TestAdapter, extending JsxAdapter)
|
|
147
|
+
// runs the predicate verbatim, so an off-subset body is not a universal
|
|
148
|
+
// error. It stays in the array string for the runtime to evaluate.
|
|
149
|
+
// See spec/callback-fidelity.md.
|
|
150
|
+
const result = compileJSX(unsupportedSource, 'TodoList.tsx', { adapter })
|
|
151
|
+
const bf021 = result.errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
|
|
152
|
+
expect(bf021).toHaveLength(0)
|
|
135
153
|
})
|
|
136
154
|
})
|
|
137
155
|
|
|
@@ -130,6 +130,37 @@ export type TemplatePrimitiveRegistry = Record<string, TemplatePrimitiveEmit>
|
|
|
130
130
|
*/
|
|
131
131
|
export type TemplateCallAcceptor = (calleeName: string) => boolean
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* The collection-method callbacks whose body the compiler may hand to a
|
|
135
|
+
* backend to run. Used by {@link CallbackBodyAcceptor}. See
|
|
136
|
+
* `spec/callback-fidelity.md`.
|
|
137
|
+
*/
|
|
138
|
+
export type CallbackBodyKind =
|
|
139
|
+
| 'filter'
|
|
140
|
+
| 'sort'
|
|
141
|
+
| 'map'
|
|
142
|
+
| 'flatMap'
|
|
143
|
+
| 'find'
|
|
144
|
+
| 'some'
|
|
145
|
+
| 'every'
|
|
146
|
+
| 'reduce'
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Predicate: can this adapter's runtime render an *off-subset* callback body
|
|
150
|
+
* of the given kind verbatim? A callback whose body the compiler can't express
|
|
151
|
+
* as a template / ParsedExpr subtree is only renderable at SSR by a backend
|
|
152
|
+
* whose template runtime is a full JS engine.
|
|
153
|
+
*
|
|
154
|
+
* JS-runtime adapters (Hono SSR, CSR — anything extending `JsxAdapter`) return
|
|
155
|
+
* true, so the compiler keeps the callback inlined for the runtime to execute
|
|
156
|
+
* instead of raising a universal Phase-1 diagnostic. DSL adapters (Go, Perl,
|
|
157
|
+
* …) leave this undefined; an off-subset body then raises the usual diagnostic
|
|
158
|
+
* with the `/* @client *\/` escape, and the user opts that piece into
|
|
159
|
+
* client-only rendering. Granular by kind so a DSL adapter may later accept a
|
|
160
|
+
* subset (e.g. `filter` but not `sort`). See `spec/callback-fidelity.md`.
|
|
161
|
+
*/
|
|
162
|
+
export type CallbackBodyAcceptor = (kind: CallbackBodyKind) => boolean
|
|
163
|
+
|
|
133
164
|
export interface TemplateAdapter {
|
|
134
165
|
name: string
|
|
135
166
|
extension: string
|
|
@@ -201,6 +232,15 @@ export interface TemplateAdapter {
|
|
|
201
232
|
*/
|
|
202
233
|
acceptsTemplateCall?: TemplateCallAcceptor
|
|
203
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Whether this adapter's runtime can render an off-subset callback body
|
|
237
|
+
* (`filter`/`sort`/`find`/… predicate or comparator the compiler can't
|
|
238
|
+
* lower to a template / ParsedExpr) verbatim. JS-runtime adapters set this
|
|
239
|
+
* (via `JsxAdapter`); DSL adapters leave it undefined and instead surface
|
|
240
|
+
* the diagnostic + `/* @client *\/` escape. See `spec/callback-fidelity.md`.
|
|
241
|
+
*/
|
|
242
|
+
acceptsCallbackBody?: CallbackBodyAcceptor
|
|
243
|
+
|
|
204
244
|
// Main entry point - generates complete template from IR
|
|
205
245
|
generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput
|
|
206
246
|
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
} from '../types.ts'
|
|
13
13
|
import { BF_SCOPE, BF_SLOT, BF_COND } from '@barefootjs/shared'
|
|
14
14
|
import { BaseAdapter } from './interface.ts'
|
|
15
|
+
import type { CallbackBodyAcceptor } from './interface.ts'
|
|
15
16
|
import { ENV_SIGNAL_CLIENT_FACTORY } from './env-signal.ts'
|
|
16
17
|
import { formatParamWithType, findReachableNames } from '../module-exports.ts'
|
|
17
18
|
|
|
@@ -26,6 +27,15 @@ export abstract class JsxAdapter extends BaseAdapter {
|
|
|
26
27
|
/** Subclasses define whether to use typed values for type-safe output */
|
|
27
28
|
protected abstract jsxConfig: JsxAdapterConfig
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* JS-runtime adapters (Hono SSR, CSR, the test adapter) render an off-subset
|
|
32
|
+
* callback body by running it verbatim, so the compiler need not raise a
|
|
33
|
+
* universal Phase-1 diagnostic for a `filter`/`sort`/… body it can't lower.
|
|
34
|
+
* DSL adapters extend `BaseAdapter` and leave this undefined.
|
|
35
|
+
* See `spec/callback-fidelity.md`.
|
|
36
|
+
*/
|
|
37
|
+
acceptsCallbackBody: CallbackBodyAcceptor = () => true
|
|
38
|
+
|
|
29
39
|
// ===========================================================================
|
|
30
40
|
// Import Formatting
|
|
31
41
|
// ===========================================================================
|
|
@@ -57,8 +57,12 @@ export function collectLoopBoundNames(ir: ComponentIR): Set<string> {
|
|
|
57
57
|
for (const nested of node.nestedComponents ?? []) {
|
|
58
58
|
for (const child of nested.children) visit(child)
|
|
59
59
|
}
|
|
60
|
-
for (const
|
|
61
|
-
visit(
|
|
60
|
+
for (const seg of node.flatMapCallback?.segments ?? []) {
|
|
61
|
+
if (seg.kind === 'jsx') visit(seg.ir)
|
|
62
|
+
}
|
|
63
|
+
// Preamble leaves (array-builder bodies) are nested IR the same way.
|
|
64
|
+
for (const seg of node.preamble?.segments ?? []) {
|
|
65
|
+
if (seg.kind === 'jsx') visit(seg.ir)
|
|
62
66
|
}
|
|
63
67
|
break
|
|
64
68
|
case 'conditional':
|
|
@@ -87,16 +87,11 @@ export type ArrayMethod =
|
|
|
87
87
|
*/
|
|
88
88
|
export type SortMethod = 'sort' | 'toSorted'
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
* force a spec-or-args runtime check at every call site. The method name
|
|
96
|
-
* is threaded through so adapters can pick the fold direction (left for
|
|
97
|
-
* `reduce`, right for `reduceRight`).
|
|
98
|
-
*/
|
|
99
|
-
export type ReduceMethod = 'reduce' | 'reduceRight'
|
|
90
|
+
// NB: there is no structured `reduce` / `reduceRight` emitter type here.
|
|
91
|
+
// #2018 P5 removed the folded `ReduceOp` form — a `.reduce(fn, init)` now
|
|
92
|
+
// arrives as a generic `call` whose reducer body + initial value serialize
|
|
93
|
+
// to the runtime evaluator, so no dedicated `reduceMethod()` dispatcher (or
|
|
94
|
+
// `ReduceMethod` type) exists.
|
|
100
95
|
|
|
101
96
|
export type LiteralType = 'string' | 'number' | 'boolean' | 'null'
|
|
102
97
|
|