@pyreon/compiler 0.23.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +357 -5
- package/lib/types/index.d.ts +94 -1
- package/package.json +12 -12
- package/src/index.ts +2 -0
- package/src/jsx.ts +320 -3
- package/src/lpih.ts +270 -0
- package/src/pyreon-intercept.ts +9 -1
- package/src/tests/collapse-bail-census.test.ts +101 -16
- package/src/tests/dynamic-collapse-detector.test.ts +164 -0
- package/src/tests/dynamic-collapse-emit.test.ts +192 -0
- package/src/tests/dynamic-collapse-scan.test.ts +111 -0
- package/src/tests/lpih.test.ts +404 -0
|
@@ -84,6 +84,13 @@ interface SiteClass {
|
|
|
84
84
|
* attrs are string literals AND children are static text (partial-collapse
|
|
85
85
|
* addressable). */
|
|
86
86
|
partialAddressable: boolean
|
|
87
|
+
/** dynamic-prop only: true iff EXACTLY ONE dynamic attr is a ternary of
|
|
88
|
+
* two string literals AND every OTHER non-literal attr is an `on*`
|
|
89
|
+
* handler (which compose orthogonally via the handler-combined
|
|
90
|
+
* emit), AND children are static text. Counts the subset addressable
|
|
91
|
+
* by the dynamic-prop collapse PR sequence (PRs #765-#767 plus the
|
|
92
|
+
* handler-combined follow-up). */
|
|
93
|
+
dynamicTernaryAddressable: boolean
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
const isPascal = (t: string): boolean =>
|
|
@@ -113,17 +120,47 @@ function classifySite(node: any): SiteClass {
|
|
|
113
120
|
const attrs: any[] = opening.attributes ?? []
|
|
114
121
|
let sawDynamic = false
|
|
115
122
|
let everyDynamicIsHandler = true
|
|
123
|
+
// Dynamic-prop addressable tracking: count ternaries + check shape.
|
|
124
|
+
// Exactly one ternary-of-two-literals + every other non-literal attr
|
|
125
|
+
// is either a ternary or an `on*` handler (handlers compose via the
|
|
126
|
+
// combined `_rsCollapseDynH` emit) → addressable. Note no
|
|
127
|
+
// `sawHandler` tracking: the original PR 3 no-handler restriction
|
|
128
|
+
// was lifted by the handler-combined follow-up; handlers no longer
|
|
129
|
+
// disqualify a site from `dynamicTernaryAddressable`.
|
|
130
|
+
let ternaryCount = 0
|
|
131
|
+
let everyDynamicIsTernary = true
|
|
116
132
|
for (const a of attrs) {
|
|
117
|
-
if (a.type === 'JSXSpreadAttribute')
|
|
133
|
+
if (a.type === 'JSXSpreadAttribute')
|
|
134
|
+
return { bucket: 'spread', partialAddressable: false, dynamicTernaryAddressable: false }
|
|
118
135
|
const nm = a.name?.type === 'JSXIdentifier' ? a.name.name : null
|
|
119
|
-
if (!nm)
|
|
136
|
+
if (!nm)
|
|
137
|
+
return { bucket: 'spread', partialAddressable: false, dynamicTernaryAddressable: false }
|
|
120
138
|
const v = a.value
|
|
121
|
-
if (!v)
|
|
139
|
+
if (!v)
|
|
140
|
+
return {
|
|
141
|
+
bucket: 'boolean-attr',
|
|
142
|
+
partialAddressable: false,
|
|
143
|
+
dynamicTernaryAddressable: false,
|
|
144
|
+
}
|
|
122
145
|
const isStr =
|
|
123
146
|
v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
|
|
124
147
|
if (!isStr) {
|
|
125
148
|
sawDynamic = true
|
|
126
|
-
|
|
149
|
+
const isHandler = /^on[A-Z]/.test(nm)
|
|
150
|
+
if (!isHandler) everyDynamicIsHandler = false
|
|
151
|
+
// Probe for the ternary-of-two-literals shape (PR 2 detector's
|
|
152
|
+
// structural shape).
|
|
153
|
+
const expr = v.type === 'JSXExpressionContainer' ? v.expression : null
|
|
154
|
+
const isLitStr = (n: any): boolean =>
|
|
155
|
+
n &&
|
|
156
|
+
(n.type === 'StringLiteral' || (n.type === 'Literal' && typeof n.value === 'string'))
|
|
157
|
+
const isTernaryOfLits =
|
|
158
|
+
expr &&
|
|
159
|
+
expr.type === 'ConditionalExpression' &&
|
|
160
|
+
isLitStr(expr.consequent) &&
|
|
161
|
+
isLitStr(expr.alternate)
|
|
162
|
+
if (isTernaryOfLits) ternaryCount++
|
|
163
|
+
else if (!isHandler) everyDynamicIsTernary = false
|
|
127
164
|
}
|
|
128
165
|
}
|
|
129
166
|
// children
|
|
@@ -135,22 +172,41 @@ function classifySite(node: any): SiteClass {
|
|
|
135
172
|
else staticChildrenOnly = false // JSXExpressionContainer etc.
|
|
136
173
|
}
|
|
137
174
|
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
175
|
const partialAddressable = everyDynamicIsHandler && staticChildrenOnly
|
|
144
|
-
|
|
176
|
+
// Dynamic-collapse claims: EXACTLY 1 ternary, every OTHER dynamic
|
|
177
|
+
// attr is either a ternary or an `on*` handler (no plain dynamic
|
|
178
|
+
// shapes like `state={getValue()}`), static children. The
|
|
179
|
+
// handler-combined follow-up (this PR) lifted the no-handler
|
|
180
|
+
// restriction by routing handler-bearing dynamic sites to the
|
|
181
|
+
// `_rsCollapseDynH` runtime helper instead of bailing — closes
|
|
182
|
+
// the bulk of the 15.4% dynamic-prop bucket (previously the
|
|
183
|
+
// strict no-handler scope only addressed 0.2% of sites).
|
|
184
|
+
//
|
|
185
|
+
// The `everyDynamicIsTernary` flag here is computed in the loop
|
|
186
|
+
// above as "every non-handler dynamic attr is a ternary"; combined
|
|
187
|
+
// with `ternaryCount === 1` + `staticChildrenOnly` it precisely
|
|
188
|
+
// matches what `detectDynamicCollapsibleShape` + `tryDynamicCollapse`
|
|
189
|
+
// claim. Handlers are NO LONGER excluded — they compose orthogonally.
|
|
190
|
+
const dynamicTernaryAddressable =
|
|
191
|
+
ternaryCount === 1 && everyDynamicIsTernary && staticChildrenOnly
|
|
192
|
+
return { bucket: 'dynamic-prop', partialAddressable, dynamicTernaryAddressable }
|
|
145
193
|
}
|
|
146
194
|
// No spread / boolean / dynamic attr. Bail can now only come from children.
|
|
147
195
|
for (const c of kids) {
|
|
148
196
|
if (c.type === 'JSXText') continue
|
|
149
197
|
if (c.type === 'JSXElement' || c.type === 'JSXFragment')
|
|
150
|
-
return {
|
|
151
|
-
|
|
198
|
+
return {
|
|
199
|
+
bucket: 'element-child',
|
|
200
|
+
partialAddressable: false,
|
|
201
|
+
dynamicTernaryAddressable: false,
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
bucket: 'expression-child',
|
|
205
|
+
partialAddressable: false,
|
|
206
|
+
dynamicTernaryAddressable: false,
|
|
207
|
+
}
|
|
152
208
|
}
|
|
153
|
-
return { bucket: 'collapsible', partialAddressable: false }
|
|
209
|
+
return { bucket: 'collapsible', partialAddressable: false, dynamicTernaryAddressable: false }
|
|
154
210
|
}
|
|
155
211
|
|
|
156
212
|
describe('proposal #1 — collapse-tail bail-reason census (measurement, not a build)', () => {
|
|
@@ -168,6 +224,7 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
|
|
|
168
224
|
}
|
|
169
225
|
let candidates = 0
|
|
170
226
|
let partialAddressable = 0
|
|
227
|
+
let dynamicTernaryAddressable = 0
|
|
171
228
|
let myCollapsible = 0
|
|
172
229
|
let scannerCollapsible = 0
|
|
173
230
|
|
|
@@ -191,6 +248,7 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
|
|
|
191
248
|
tally[c.bucket]++
|
|
192
249
|
if (c.bucket === 'collapsible') myCollapsible++
|
|
193
250
|
if (c.partialAddressable) partialAddressable++
|
|
251
|
+
if (c.dynamicTernaryAddressable) dynamicTernaryAddressable++
|
|
194
252
|
}
|
|
195
253
|
}
|
|
196
254
|
for (const k in node) {
|
|
@@ -220,14 +278,27 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
|
|
|
220
278
|
` bail:expression-child : ${tally['expression-child']} (${pct(tally['expression-child'])})`,
|
|
221
279
|
` ── partial-collapse ADDRESSABLE : ${partialAddressable} (${pct(partialAddressable)} of all sites)`,
|
|
222
280
|
` (dynamic-prop bails where every dynamic attr is on*, all else literal, static children)`,
|
|
281
|
+
` ── dynamic-collapse ADDRESSABLE : ${dynamicTernaryAddressable} (${pct(dynamicTernaryAddressable)} of all sites)`,
|
|
282
|
+
` (dynamic-prop bails where EXACTLY ONE attr is a ternary-of-two-string-literals,`,
|
|
283
|
+
` every other non-literal attr is on* (handlers compose via _rsCollapseDynH),`,
|
|
284
|
+
` static children — dynamic-prop sequence #765-#767 + handler-combined follow-up)`,
|
|
223
285
|
'',
|
|
224
286
|
].join('\n'),
|
|
225
287
|
)
|
|
226
288
|
|
|
227
289
|
// ── Trustworthiness gate (bisect-equivalent) ────────────────────────────
|
|
228
|
-
// This independent walk's "collapsible" count MUST equal the
|
|
229
|
-
// scanner's truth-set. If they diverge the census is
|
|
230
|
-
|
|
290
|
+
// This independent walk's "collapsible-equivalent" count MUST equal the
|
|
291
|
+
// production scanner's truth-set. If they diverge the census is
|
|
292
|
+
// measuring fiction.
|
|
293
|
+
//
|
|
294
|
+
// Per PR 3 (#767) the scanner emits TWO `CollapsibleSite` entries per
|
|
295
|
+
// dynamic-prop site (one per literal value — the resolver pre-renders
|
|
296
|
+
// both); the compiler emit still produces ONE collapsed call site. So
|
|
297
|
+
// the per-site classifier count + 2× the dynamic-addressable count
|
|
298
|
+
// equals the scanner's per-resolution count. If they diverge, either
|
|
299
|
+
// the classifier and scanner disagree on which dynamic sites are
|
|
300
|
+
// addressable, OR the scanner's expansion drifted from this formula.
|
|
301
|
+
expect(myCollapsible + 2 * dynamicTernaryAddressable).toBe(scannerCollapsible)
|
|
231
302
|
|
|
232
303
|
// ── Lock the headline finding (ratchet record) ──────────────────────────
|
|
233
304
|
// The corpus is real and large; these are the measured facts as of this
|
|
@@ -241,5 +312,19 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
|
|
|
241
312
|
// the classifier ran over a non-trivial dynamic-prop population so the
|
|
242
313
|
// ratio is meaningful, not noise.
|
|
243
314
|
expect(tally['dynamic-prop'] + tally.collapsible).toBeGreaterThan(0)
|
|
315
|
+
// PR 4 of the dynamic-prop partial-collapse build: lock that the
|
|
316
|
+
// dynamic-collapse classifier ran over a meaningful population
|
|
317
|
+
// (dynamic-prop bucket non-zero). The addressable count is in the
|
|
318
|
+
// log; we DON'T assert it >0 here because the strict no-handler
|
|
319
|
+
// PR 3 scope is honestly small in real-world corpora (real Buttons
|
|
320
|
+
// with `state={cond ? ... : ...}` almost always also carry
|
|
321
|
+
// `onClick` → BAIL until the handler-combined follow-up). The
|
|
322
|
+
// dynamic-prop bucket itself is the size of the future surface;
|
|
323
|
+
// PR 3's no-handler subset is the first measurable step.
|
|
324
|
+
expect(tally['dynamic-prop']).toBeGreaterThan(0)
|
|
325
|
+
// The dynamic-addressable count can be 0 in a clean run (no
|
|
326
|
+
// matching sites in the corpus); just lock that the counter is
|
|
327
|
+
// wired and consistent with the bucket.
|
|
328
|
+
expect(dynamicTernaryAddressable).toBeLessThanOrEqual(tally['dynamic-prop'])
|
|
244
329
|
})
|
|
245
330
|
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR 2 of the dynamic-prop partial-collapse build (`.claude/plans/open-work-2026-q3.md`
|
|
3
|
+
* → #1 dynamic-prop bucket = 15.3% of all real-corpus sites; the
|
|
4
|
+
* next-bigger bite after the just-shipped `on*`-handler partial-collapse
|
|
5
|
+
* via `detectPartialCollapsibleShape` + `_rsCollapseH` + emit).
|
|
6
|
+
*
|
|
7
|
+
* Mirrors `detectPartialCollapsibleShape`'s "extend the bail catalogue
|
|
8
|
+
* with ONE relaxation" pattern (see that detector's docstring + tests).
|
|
9
|
+
* The single relaxation: a `JSXExpressionContainer` whose expression is
|
|
10
|
+
* a `ConditionalExpression` with BOTH branches being `StringLiteral` is
|
|
11
|
+
* acceptable as a "ternary-of-two-literals" dynamic prop. Captured as a
|
|
12
|
+
* `DynamicCollapsibleProp` with cond source span + the two literal
|
|
13
|
+
* values, so PR 3's resolver can pre-render BOTH values via the
|
|
14
|
+
* existing SSR pipeline and PR 3's emit can dispatch via the cond.
|
|
15
|
+
*
|
|
16
|
+
* Contract under test:
|
|
17
|
+
*
|
|
18
|
+
* - literal-prop + ONE ternary-of-two-literals + optional `on*` handlers
|
|
19
|
+
* + static-text children → { props, dynamicProp, handlers, childrenText }
|
|
20
|
+
* - ZERO ternaries (literal-only) → null (defers to full / on*-only paths)
|
|
21
|
+
* - 2+ ternaries → null (multi-axis combinatorics is separable scope)
|
|
22
|
+
* - ternary with ANY non-literal branch (template literal, identifier,
|
|
23
|
+
* non-string literal, computed expr) → null
|
|
24
|
+
* - spread / boolean attr / element child / expression child → null
|
|
25
|
+
* - cond span (`condStart`/`condEnd`) slices the EXACT source of the
|
|
26
|
+
* ternary's test expression (load-bearing for PR 3's emit, which
|
|
27
|
+
* re-emits `code.slice(condStart, condEnd)` into `_rsCollapseDyn`)
|
|
28
|
+
*
|
|
29
|
+
* Bisect-verify (documented in the PR body): replace the body of
|
|
30
|
+
* `detectDynamicCollapsibleShape` with `return null` → the POSITIVE
|
|
31
|
+
* specs fail with `expected null to be …`; the NEGATIVE specs still
|
|
32
|
+
* pass. Restore → all pass. That asymmetry proves the positive
|
|
33
|
+
* assertions are load-bearing on the ternary-relaxation logic.
|
|
34
|
+
*/
|
|
35
|
+
import { describe, expect, it } from 'vitest'
|
|
36
|
+
import { parseSync } from 'oxc-parser'
|
|
37
|
+
import { detectDynamicCollapsibleShape } from '../jsx'
|
|
38
|
+
|
|
39
|
+
function firstJsxElement(code: string): any {
|
|
40
|
+
const { program } = parseSync('input.tsx', code, { sourceType: 'module', lang: 'tsx' })
|
|
41
|
+
let found: any = null
|
|
42
|
+
const visit = (node: any): void => {
|
|
43
|
+
if (found || !node || typeof node !== 'object') return
|
|
44
|
+
if (node.type === 'JSXElement') {
|
|
45
|
+
found = node
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
for (const k in node) {
|
|
49
|
+
const v = node[k]
|
|
50
|
+
if (Array.isArray(v)) for (const c of v) visit(c)
|
|
51
|
+
else if (v && typeof v === 'object' && typeof v.type === 'string') visit(v)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
visit(program)
|
|
55
|
+
return found
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const detect = (code: string) => detectDynamicCollapsibleShape(firstJsxElement(code), 'Button')
|
|
59
|
+
|
|
60
|
+
describe('detectDynamicCollapsibleShape — PR 2 (ternary-of-two-literals dynamic-prop subset)', () => {
|
|
61
|
+
// ── POSITIVE: the dynamic-collapsible subset ────────────────────────────
|
|
62
|
+
it('claims a literal-prop site with ONE ternary-of-two-literals', () => {
|
|
63
|
+
const code = 'const x = <Button state={cond ? "primary" : "secondary"} size="medium">Save</Button>'
|
|
64
|
+
const r = detect(code)
|
|
65
|
+
expect(r).not.toBeNull()
|
|
66
|
+
expect(r!.props).toEqual({ size: 'medium' })
|
|
67
|
+
expect(r!.childrenText).toBe('Save')
|
|
68
|
+
expect(r!.handlers).toEqual([])
|
|
69
|
+
expect(r!.dynamicProp.name).toBe('state')
|
|
70
|
+
expect(r!.dynamicProp.valueTruthy).toBe('primary')
|
|
71
|
+
expect(r!.dynamicProp.valueFalsy).toBe('secondary')
|
|
72
|
+
expect(code.slice(r!.dynamicProp.condStart, r!.dynamicProp.condEnd)).toBe('cond')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('captures the EXACT cond span for a complex condition', () => {
|
|
76
|
+
// The condStart/condEnd MUST slice the original source of the test
|
|
77
|
+
// expression so PR 3's emit can re-thread it into the dispatcher
|
|
78
|
+
// verbatim (paren-wrapped to keep it a single expr like the
|
|
79
|
+
// on*-handler emit does for arrow bodies).
|
|
80
|
+
const code = 'const x = <Btn state={user.role === "admin" ? "primary" : "danger"}>Go</Btn>'
|
|
81
|
+
const r = detect(code)
|
|
82
|
+
expect(r).not.toBeNull()
|
|
83
|
+
expect(code.slice(r!.dynamicProp.condStart, r!.dynamicProp.condEnd)).toBe(
|
|
84
|
+
'user.role === "admin"',
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('composes with on*-handler relaxation — one ternary + one handler', () => {
|
|
89
|
+
// Real-corpus shape: a Button with state={cond ? ... : ...} almost
|
|
90
|
+
// always also has an onClick. PR 3's emit will route to a combined
|
|
91
|
+
// helper when handlers are non-empty; this PR (detector) just
|
|
92
|
+
// carries both for the dispatcher's sake.
|
|
93
|
+
const code =
|
|
94
|
+
'const x = <Button state={cond ? "primary" : "secondary"} onClick={go}>Save</Button>'
|
|
95
|
+
const r = detect(code)
|
|
96
|
+
expect(r).not.toBeNull()
|
|
97
|
+
expect(r!.dynamicProp.name).toBe('state')
|
|
98
|
+
expect(r!.handlers.map((h) => h.name)).toEqual(['onClick'])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('handles ternary on a non-state dim prop (size, variant, …)', () => {
|
|
102
|
+
const code = 'const x = <Button size={isLarge ? "large" : "medium"}>S</Button>'
|
|
103
|
+
const r = detect(code)
|
|
104
|
+
expect(r).not.toBeNull()
|
|
105
|
+
expect(r!.dynamicProp.name).toBe('size')
|
|
106
|
+
expect(r!.dynamicProp.valueTruthy).toBe('large')
|
|
107
|
+
expect(r!.dynamicProp.valueFalsy).toBe('medium')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('trims static-text children (parity with the rest of the family)', () => {
|
|
111
|
+
const code =
|
|
112
|
+
'const x = <Button state={c ? "a" : "b"}>\n Save\n</Button>'
|
|
113
|
+
const r = detect(code)
|
|
114
|
+
expect(r).not.toBeNull()
|
|
115
|
+
expect(r!.childrenText).toBe('Save')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// ── NEGATIVE: every uncertain shape bails (null) ────────────────────────
|
|
119
|
+
it('returns null for ZERO ternaries (defers to full / on*-only paths)', () => {
|
|
120
|
+
// Load-bearing separation: a fully-literal site is the FULL-collapse
|
|
121
|
+
// shape; on*-handler-only is the partial-collapse shape. The
|
|
122
|
+
// dynamic-prop detector must NOT claim either, so the three
|
|
123
|
+
// detectors never both/all-three fire on one site.
|
|
124
|
+
expect(detect('const x = <Button state="primary">Save</Button>')).toBeNull()
|
|
125
|
+
expect(detect('const x = <Button state="primary" onClick={h}>Save</Button>')).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('returns null for 2+ ternaries (multi-axis combinatorics is separable scope)', () => {
|
|
129
|
+
const code =
|
|
130
|
+
'const x = <Button state={a ? "x" : "y"} size={b ? "small" : "large"}>S</Button>'
|
|
131
|
+
expect(detect(code)).toBeNull()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('returns null for a ternary with a NON-string-literal branch', () => {
|
|
135
|
+
// TemplateLiteral, Identifier, numeric/boolean literal — none of
|
|
136
|
+
// these are statically resolvable to a known dimension value.
|
|
137
|
+
expect(detect('const x = <Button state={c ? `pri` : "sec"}>S</Button>')).toBeNull()
|
|
138
|
+
expect(detect('const x = <Button state={c ? maybePrimary : "sec"}>S</Button>')).toBeNull()
|
|
139
|
+
expect(detect('const x = <Button state={c ? "pri" : 1}>S</Button>')).toBeNull()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('returns null for a non-ternary dynamic prop alongside literals', () => {
|
|
143
|
+
// Signal-call / function-call / arbitrary expression — not statically
|
|
144
|
+
// enumerable, no resolution possible.
|
|
145
|
+
expect(detect('const x = <Button state={getMy()} size="medium">S</Button>')).toBeNull()
|
|
146
|
+
expect(detect('const x = <Button state={sig()} size="medium">S</Button>')).toBeNull()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('returns null for a spread attribute', () => {
|
|
150
|
+
expect(detect('const x = <Button {...rest} state={c ? "a" : "b"}>X</Button>')).toBeNull()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('returns null for a boolean attribute', () => {
|
|
154
|
+
expect(detect('const x = <Button disabled state={c ? "a" : "b"}>X</Button>')).toBeNull()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('returns null for an element child', () => {
|
|
158
|
+
expect(detect('const x = <Button state={c ? "a" : "b"}><span /></Button>')).toBeNull()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('returns null for an expression child', () => {
|
|
162
|
+
expect(detect('const x = <Button state={c ? "a" : "b"}>{label}</Button>')).toBeNull()
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR 3 of the dynamic-prop partial-collapse build — the compiler EMIT
|
|
3
|
+
* half: `tryRocketstyleCollapse` falls through to `tryDynamicCollapse`
|
|
4
|
+
* when BOTH the full and the `on*`-handler-partial paths bail. Emits
|
|
5
|
+
* `__rsCollapseDyn(html, [stride-2 classes], () => cond ? 0 : 1, () =>
|
|
6
|
+
* __pyrMode() === "dark")` consumed by PR 1's runtime helper `_rsCollapseDyn`
|
|
7
|
+
* (#765).
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the existing `partial-collapse-emit.test.ts` harness exactly
|
|
10
|
+
* (stubbed resolved-`sites` map — the resolver/plugin scan is PR 4's
|
|
11
|
+
* gate; this proves the emit contract in isolation).
|
|
12
|
+
*
|
|
13
|
+
* Bisect-verify (PR body): revert the fallback chain
|
|
14
|
+
* (`return tryPartialCollapse(...) || tryDynamicCollapse(...)` →
|
|
15
|
+
* `return tryPartialCollapse(...)`) → the dynamic-emit specs fail
|
|
16
|
+
* (`__rsCollapseDyn(` absent) while the FULL + PARTIAL specs still
|
|
17
|
+
* pass (proving the dynamic fallthrough is the only delta).
|
|
18
|
+
* Restore → all pass.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, expect, it } from 'vitest'
|
|
21
|
+
import { rocketstyleCollapseKey, transformJSX } from '../jsx'
|
|
22
|
+
|
|
23
|
+
// Per-value resolved sites — the dynamic emit looks up BOTH literals
|
|
24
|
+
// via separate keys. `templateHtml` MUST be byte-identical across
|
|
25
|
+
// values for the dispatcher to share one `_tpl` (cross-value template
|
|
26
|
+
// parity bail).
|
|
27
|
+
const TPL = '<button>Save</button>'
|
|
28
|
+
|
|
29
|
+
const PRIMARY = {
|
|
30
|
+
templateHtml: TPL,
|
|
31
|
+
lightClass: 'pyr-pri-L',
|
|
32
|
+
darkClass: 'pyr-pri-D',
|
|
33
|
+
rules: ['.pyr-pri-L{color:red}', '.pyr-pri-D{color:darkred}'],
|
|
34
|
+
ruleKey: 'bundle-pri',
|
|
35
|
+
}
|
|
36
|
+
const SECONDARY = {
|
|
37
|
+
templateHtml: TPL,
|
|
38
|
+
lightClass: 'pyr-sec-L',
|
|
39
|
+
darkClass: 'pyr-sec-D',
|
|
40
|
+
rules: ['.pyr-sec-L{color:blue}', '.pyr-sec-D{color:darkblue}'],
|
|
41
|
+
ruleKey: 'bundle-sec',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function collapseOpt(
|
|
45
|
+
candidates: string[],
|
|
46
|
+
sites: Record<string, { templateHtml: string; lightClass: string; darkClass: string; rules: string[]; ruleKey: string }>,
|
|
47
|
+
) {
|
|
48
|
+
return {
|
|
49
|
+
collapseRocketstyle: {
|
|
50
|
+
candidates: new Set(candidates),
|
|
51
|
+
sites: new Map(Object.entries(sites)),
|
|
52
|
+
mode: { name: 'useMode', source: '@pyreon/ui-core' },
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('compiler — dynamic-prop collapse emission (PR 3, ternary-of-two-literals)', () => {
|
|
58
|
+
it('emits __rsCollapseDyn with stride-2 value-major classes + cond dispatcher', () => {
|
|
59
|
+
const truthyKey = rocketstyleCollapseKey('Button', { state: 'primary', size: 'medium' }, 'Save')
|
|
60
|
+
const falsyKey = rocketstyleCollapseKey('Button', { state: 'secondary', size: 'medium' }, 'Save')
|
|
61
|
+
const src =
|
|
62
|
+
'const x = <Button state={isPrimary ? "primary" : "secondary"} size="medium">Save</Button>'
|
|
63
|
+
const { code } = transformJSX(
|
|
64
|
+
src,
|
|
65
|
+
'A.tsx',
|
|
66
|
+
collapseOpt(['Button'], { [truthyKey]: PRIMARY, [falsyKey]: SECONDARY }),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
// Stride-2 value-major class layout: `[v0_light, v0_dark, v1_light, v1_dark]`.
|
|
70
|
+
// v0 = consequent (cond → 0), v1 = alternate (cond → 1) — matches
|
|
71
|
+
// `_rsCollapseDyn` doc + bisect-verified in PR 1 (#765).
|
|
72
|
+
expect(code).toContain(
|
|
73
|
+
'__rsCollapseDyn("<button>Save</button>", ' +
|
|
74
|
+
'["pyr-pri-L","pyr-pri-D","pyr-sec-L","pyr-sec-D"], ' +
|
|
75
|
+
'() => (isPrimary) ? 0 : 1, ' +
|
|
76
|
+
'() => __pyrMode() === "dark")',
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('imports the _rsCollapseDyn helper (and NOT the full / H helpers when not used)', () => {
|
|
81
|
+
const truthyKey = rocketstyleCollapseKey('Button', { state: 'primary' }, 'Go')
|
|
82
|
+
const falsyKey = rocketstyleCollapseKey('Button', { state: 'secondary' }, 'Go')
|
|
83
|
+
const src = 'const x = <Button state={c ? "primary" : "secondary"}>Go</Button>'
|
|
84
|
+
const { code } = transformJSX(
|
|
85
|
+
src,
|
|
86
|
+
'B.tsx',
|
|
87
|
+
collapseOpt(['Button'], { [truthyKey]: PRIMARY, [falsyKey]: SECONDARY }),
|
|
88
|
+
)
|
|
89
|
+
// Conditional import — dynamic-only modules pull `_rsCollapseDyn`
|
|
90
|
+
// ONLY (tree-shake-friendly per-feature granularity).
|
|
91
|
+
expect(code).toContain('import { _rsCollapseDyn as __rsCollapseDyn } from "@pyreon/runtime-dom";')
|
|
92
|
+
expect(code).not.toContain('_rsCollapse as __rsCollapse')
|
|
93
|
+
expect(code).not.toContain('_rsCollapseH as __rsCollapseH')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('unions BOTH values\' rule bundles via injectRules (de-duped by ruleKey)', () => {
|
|
97
|
+
const truthyKey = rocketstyleCollapseKey('Button', { state: 'primary' }, 'X')
|
|
98
|
+
const falsyKey = rocketstyleCollapseKey('Button', { state: 'secondary' }, 'X')
|
|
99
|
+
const src = 'const x = <Button state={c ? "primary" : "secondary"}>X</Button>'
|
|
100
|
+
const { code } = transformJSX(
|
|
101
|
+
src,
|
|
102
|
+
'C.tsx',
|
|
103
|
+
collapseOpt(['Button'], { [truthyKey]: PRIMARY, [falsyKey]: SECONDARY }),
|
|
104
|
+
)
|
|
105
|
+
// Each value's rule bundle injected separately (different ruleKeys
|
|
106
|
+
// ⇒ no dedupe). Same idempotent injectRules contract as the full
|
|
107
|
+
// path — styler dedupes by key at runtime.
|
|
108
|
+
expect(code).toContain('"bundle-pri"')
|
|
109
|
+
expect(code).toContain('"bundle-sec"')
|
|
110
|
+
expect(code).toContain('__rsSheet.injectRules(')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('preserves complex cond source verbatim (paren-wrapped for safe re-emission)', () => {
|
|
114
|
+
const truthyKey = rocketstyleCollapseKey('Btn', { state: 'primary' }, 'Hi')
|
|
115
|
+
const falsyKey = rocketstyleCollapseKey('Btn', { state: 'danger' }, 'Hi')
|
|
116
|
+
const src = 'const x = <Btn state={user.role === "admin" ? "primary" : "danger"}>Hi</Btn>'
|
|
117
|
+
const { code } = transformJSX(
|
|
118
|
+
src,
|
|
119
|
+
'D.tsx',
|
|
120
|
+
collapseOpt(['Btn'], {
|
|
121
|
+
[truthyKey]: { ...PRIMARY },
|
|
122
|
+
[falsyKey]: { ...SECONDARY, lightClass: 'pyr-dng-L', darkClass: 'pyr-dng-D' },
|
|
123
|
+
}),
|
|
124
|
+
)
|
|
125
|
+
// The paren-wrap (`(user.role === "admin")`) keeps the cond a single
|
|
126
|
+
// expression — same shape as the on*-handler emit re-emits arrow bodies.
|
|
127
|
+
expect(code).toContain('() => (user.role === "admin") ? 0 : 1')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// ── Conservative-bail discipline ───────────────────────────────────────
|
|
131
|
+
it('BAILS when EITHER expanded site is missing from the resolved map', () => {
|
|
132
|
+
const truthyKey = rocketstyleCollapseKey('Button', { state: 'primary' }, 'S')
|
|
133
|
+
// Only the truthy half resolved — falsy is absent (resolver returned
|
|
134
|
+
// null for that variant). Half-resolved ⇒ keep the normal mount.
|
|
135
|
+
const src = 'const x = <Button state={c ? "primary" : "secondary"}>S</Button>'
|
|
136
|
+
const { code } = transformJSX(
|
|
137
|
+
src,
|
|
138
|
+
'E.tsx',
|
|
139
|
+
collapseOpt(['Button'], { [truthyKey]: PRIMARY }),
|
|
140
|
+
)
|
|
141
|
+
expect(code).not.toContain('__rsCollapseDyn(')
|
|
142
|
+
// Normal mount preserved — `<Button …>` JSX still appears (or its
|
|
143
|
+
// standard `h()` form post-transform).
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('BAILS when the structural template diverges across values', () => {
|
|
147
|
+
const truthyKey = rocketstyleCollapseKey('Button', { state: 'primary' }, 'D')
|
|
148
|
+
const falsyKey = rocketstyleCollapseKey('Button', { state: 'secondary' }, 'D')
|
|
149
|
+
const src = 'const x = <Button state={c ? "primary" : "secondary"}>D</Button>'
|
|
150
|
+
const { code } = transformJSX(
|
|
151
|
+
src,
|
|
152
|
+
'F.tsx',
|
|
153
|
+
collapseOpt(['Button'], {
|
|
154
|
+
[truthyKey]: { ...PRIMARY, templateHtml: '<button>D</button>' },
|
|
155
|
+
[falsyKey]: { ...SECONDARY, templateHtml: '<button data-extra>D</button>' }, // divergent
|
|
156
|
+
}),
|
|
157
|
+
)
|
|
158
|
+
expect(code).not.toContain('__rsCollapseDyn(')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('BAILS when the dynamic site ALSO has on*-handlers (PR 3 scope: no-handler only)', () => {
|
|
162
|
+
const truthyKey = rocketstyleCollapseKey('Button', { state: 'primary' }, 'H')
|
|
163
|
+
const falsyKey = rocketstyleCollapseKey('Button', { state: 'secondary' }, 'H')
|
|
164
|
+
const src =
|
|
165
|
+
'const x = <Button state={c ? "primary" : "secondary"} onClick={go}>H</Button>'
|
|
166
|
+
const { code } = transformJSX(
|
|
167
|
+
src,
|
|
168
|
+
'G.tsx',
|
|
169
|
+
collapseOpt(['Button'], { [truthyKey]: PRIMARY, [falsyKey]: SECONDARY }),
|
|
170
|
+
)
|
|
171
|
+
expect(code).not.toContain('__rsCollapseDyn(')
|
|
172
|
+
expect(code).not.toContain('__rsCollapseH(')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// ── Regression: FULL + on*-PARTIAL paths byte-unchanged ────────────────
|
|
176
|
+
it('FULL-collapse path byte-unchanged: no-handler literal site emits plain __rsCollapse', () => {
|
|
177
|
+
const key = rocketstyleCollapseKey('Button', { state: 'primary' }, 'Save')
|
|
178
|
+
const src = 'const x = <Button state="primary">Save</Button>'
|
|
179
|
+
const { code } = transformJSX(src, 'H.tsx', collapseOpt(['Button'], { [key]: PRIMARY }))
|
|
180
|
+
expect(code).toContain('__rsCollapse(')
|
|
181
|
+
expect(code).not.toContain('__rsCollapseH(')
|
|
182
|
+
expect(code).not.toContain('__rsCollapseDyn(')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('PARTIAL on*-handler path byte-unchanged: literal site + handler emits __rsCollapseH', () => {
|
|
186
|
+
const key = rocketstyleCollapseKey('Button', { state: 'primary' }, 'Save')
|
|
187
|
+
const src = 'const x = <Button state="primary" onClick={go}>Save</Button>'
|
|
188
|
+
const { code } = transformJSX(src, 'I.tsx', collapseOpt(['Button'], { [key]: PRIMARY }))
|
|
189
|
+
expect(code).toContain('__rsCollapseH(')
|
|
190
|
+
expect(code).not.toContain('__rsCollapseDyn(')
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR 3 of the dynamic-prop partial-collapse build — `scanCollapsibleSites`
|
|
3
|
+
* extension. The plugin-side scan (`@pyreon/vite-plugin`) calls this to
|
|
4
|
+
* learn WHICH (component, props, text) tuples need resolution. For
|
|
5
|
+
* dynamic-prop sites it must expand into TWO `CollapsibleSite` entries
|
|
6
|
+
* (one per literal value) so the resolver pre-renders both via the
|
|
7
|
+
* existing SSR pipeline AND the compiler emit (PR 3 `tryDynamicCollapse`)
|
|
8
|
+
* looks up both via identical key construction.
|
|
9
|
+
*
|
|
10
|
+
* Key invariant: the keys this scan emits must EQUAL the keys
|
|
11
|
+
* `tryDynamicCollapse` computes from the same JSX — same load-bearing
|
|
12
|
+
* separation as the existing full / on*-handler scan↔emit invariants
|
|
13
|
+
* (`scanCollapsibleSites` ↔ `detectCollapsibleShape`).
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from 'vitest'
|
|
16
|
+
import { rocketstyleCollapseKey, scanCollapsibleSites } from '../jsx'
|
|
17
|
+
|
|
18
|
+
const COLLAPSIBLE = new Set(['@pyreon/ui-components'])
|
|
19
|
+
|
|
20
|
+
describe('scanCollapsibleSites — dynamic-prop expansion (PR 3)', () => {
|
|
21
|
+
it('expands a single ternary site into TWO CollapsibleSite entries (one per literal)', () => {
|
|
22
|
+
const src = `
|
|
23
|
+
import { Button } from '@pyreon/ui-components'
|
|
24
|
+
const x = <Button state={cond ? "primary" : "secondary"} size="medium">Save</Button>
|
|
25
|
+
`
|
|
26
|
+
const sites = scanCollapsibleSites(src, 'A.tsx', COLLAPSIBLE)
|
|
27
|
+
expect(sites).toHaveLength(2)
|
|
28
|
+
const byState = new Map(sites.map((s) => [s.props.state, s]))
|
|
29
|
+
expect(byState.get('primary')!.props).toEqual({ state: 'primary', size: 'medium' })
|
|
30
|
+
expect(byState.get('secondary')!.props).toEqual({ state: 'secondary', size: 'medium' })
|
|
31
|
+
expect(byState.get('primary')!.childrenText).toBe('Save')
|
|
32
|
+
expect(byState.get('secondary')!.childrenText).toBe('Save')
|
|
33
|
+
// Both entries share the same component / source / importedName —
|
|
34
|
+
// only `props.state` differs.
|
|
35
|
+
expect(byState.get('primary')!.componentName).toBe('Button')
|
|
36
|
+
expect(byState.get('secondary')!.componentName).toBe('Button')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('emits keys IDENTICAL to what tryDynamicCollapse will look up', () => {
|
|
40
|
+
// The cross-detector invariant: the scan and the emit MUST agree
|
|
41
|
+
// on the key for each expanded value. Drift would cause the emit
|
|
42
|
+
// to miss its own pre-resolved sites and bail to normal mount.
|
|
43
|
+
const src = `
|
|
44
|
+
import { Button } from '@pyreon/ui-components'
|
|
45
|
+
const x = <Button state={c ? "primary" : "secondary"}>Go</Button>
|
|
46
|
+
`
|
|
47
|
+
const sites = scanCollapsibleSites(src, 'B.tsx', COLLAPSIBLE)
|
|
48
|
+
expect(sites).toHaveLength(2)
|
|
49
|
+
const truthyKey = rocketstyleCollapseKey('Button', { state: 'primary' }, 'Go')
|
|
50
|
+
const falsyKey = rocketstyleCollapseKey('Button', { state: 'secondary' }, 'Go')
|
|
51
|
+
const keys = sites.map((s) => s.key).sort()
|
|
52
|
+
expect(keys).toEqual([truthyKey, falsyKey].sort())
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('EXPANDS handler-combined dynamic sites too (handler-combined emit unlocks the 15.4% bucket)', () => {
|
|
56
|
+
// The follow-up emit (`__rsCollapseDynH` from `tryDynamicCollapse`)
|
|
57
|
+
// handles ternary-plus-handler sites via the combined runtime
|
|
58
|
+
// helper. The scan emits both literal-value expansions identically
|
|
59
|
+
// — handlers don't affect the resolver's input (componentName,
|
|
60
|
+
// props, childrenText). Handlers are re-attached by the runtime
|
|
61
|
+
// helper, not by the resolver.
|
|
62
|
+
//
|
|
63
|
+
// Previously the scan SKIPPED handler-combined sites (matching
|
|
64
|
+
// the PR 3 emit's no-handler scope); the follow-up lifts that
|
|
65
|
+
// restriction so the resolver pre-renders both values for
|
|
66
|
+
// handler-bearing sites too.
|
|
67
|
+
const src = `
|
|
68
|
+
import { Button } from '@pyreon/ui-components'
|
|
69
|
+
const x = <Button state={c ? "primary" : "secondary"} onClick={go}>H</Button>
|
|
70
|
+
`
|
|
71
|
+
const sites = scanCollapsibleSites(src, 'C.tsx', COLLAPSIBLE)
|
|
72
|
+
expect(sites).toHaveLength(2)
|
|
73
|
+
const byState = new Map(sites.map((s) => [s.props.state, s]))
|
|
74
|
+
expect(byState.get('primary')!.childrenText).toBe('H')
|
|
75
|
+
expect(byState.get('secondary')!.childrenText).toBe('H')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('does not double-emit when the FULL detector already claims the site (literal-only)', () => {
|
|
79
|
+
// A fully-literal site is the FULL-collapse shape — claimed by
|
|
80
|
+
// detectCollapsibleShape; the dynamic-fallthrough branch in the
|
|
81
|
+
// scan only runs when the full detector returned null.
|
|
82
|
+
const src = `
|
|
83
|
+
import { Button } from '@pyreon/ui-components'
|
|
84
|
+
const x = <Button state="primary" size="medium">Save</Button>
|
|
85
|
+
`
|
|
86
|
+
const sites = scanCollapsibleSites(src, 'D.tsx', COLLAPSIBLE)
|
|
87
|
+
expect(sites).toHaveLength(1)
|
|
88
|
+
expect(sites[0]!.props).toEqual({ state: 'primary', size: 'medium' })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('emits 4 entries for a module with 2 ternary sites (no dedupe across distinct sites)', () => {
|
|
92
|
+
const src = `
|
|
93
|
+
import { Button } from '@pyreon/ui-components'
|
|
94
|
+
const a = <Button state={c1 ? "primary" : "secondary"}>A</Button>
|
|
95
|
+
const b = <Button state={c2 ? "danger" : "success"}>B</Button>
|
|
96
|
+
`
|
|
97
|
+
const sites = scanCollapsibleSites(src, 'E.tsx', COLLAPSIBLE)
|
|
98
|
+
expect(sites).toHaveLength(4)
|
|
99
|
+
const states = sites.map((s) => `${s.props.state}/${s.childrenText}`).sort()
|
|
100
|
+
expect(states).toEqual(['danger/B', 'primary/A', 'secondary/A', 'success/B'])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('skips multi-ternary site entirely (separable scope, not this PR)', () => {
|
|
104
|
+
const src = `
|
|
105
|
+
import { Button } from '@pyreon/ui-components'
|
|
106
|
+
const x = <Button state={a ? "x" : "y"} size={b ? "small" : "large"}>S</Button>
|
|
107
|
+
`
|
|
108
|
+
const sites = scanCollapsibleSites(src, 'F.tsx', COLLAPSIBLE)
|
|
109
|
+
expect(sites).toHaveLength(0)
|
|
110
|
+
})
|
|
111
|
+
})
|