@pyreon/compiler 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/lib/analysis/index.js.html +1 -1
  2. package/lib/index.js +2081 -1262
  3. package/lib/types/index.d.ts +310 -125
  4. package/package.json +14 -12
  5. package/src/defer-inline.ts +397 -157
  6. package/src/index.ts +14 -2
  7. package/src/jsx.ts +784 -19
  8. package/src/load-native.ts +1 -0
  9. package/src/manifest.ts +280 -0
  10. package/src/pyreon-intercept.ts +164 -0
  11. package/src/react-intercept.ts +59 -0
  12. package/src/reactivity-lens.ts +190 -0
  13. package/src/tests/backend-parity-r7-r9.test.ts +91 -0
  14. package/src/tests/backend-prop-derived-callback-divergence.test.ts +74 -0
  15. package/src/tests/collapse-bail-census.test.ts +245 -0
  16. package/src/tests/collapse-key-source-hygiene.test.ts +88 -0
  17. package/src/tests/defer-inline.test.ts +209 -21
  18. package/src/tests/detector-tag-consistency.test.ts +2 -0
  19. package/src/tests/element-valued-const-child.test.ts +61 -0
  20. package/src/tests/falsy-child-characterization.test.ts +48 -0
  21. package/src/tests/malformed-input-resilience.test.ts +50 -0
  22. package/src/tests/manifest-snapshot.test.ts +55 -0
  23. package/src/tests/native-equivalence.test.ts +104 -3
  24. package/src/tests/partial-collapse-detector.test.ts +121 -0
  25. package/src/tests/partial-collapse-emit.test.ts +104 -0
  26. package/src/tests/partial-collapse-robustness.test.ts +53 -0
  27. package/src/tests/prop-derived-shadow.test.ts +96 -0
  28. package/src/tests/pure-call-reactive-args.test.ts +50 -0
  29. package/src/tests/pyreon-intercept.test.ts +189 -0
  30. package/src/tests/r13-callback-stmt-equivalence.test.ts +58 -0
  31. package/src/tests/r14-ssr-mode-parity.test.ts +51 -0
  32. package/src/tests/r15-elemconst-propderived.test.ts +47 -0
  33. package/src/tests/r19-defer-inline-robust.test.ts +54 -0
  34. package/src/tests/r20-backend-equivalence-sweep.test.ts +50 -0
  35. package/src/tests/react-intercept.test.ts +50 -2
  36. package/src/tests/reactivity-lens.test.ts +170 -0
  37. package/src/tests/rocketstyle-collapse.test.ts +208 -0
  38. package/src/tests/signal-autocall-shadow.test.ts +86 -0
  39. package/src/tests/sourcemap-fidelity.test.ts +77 -0
  40. package/src/tests/static-text-baking.test.ts +64 -0
  41. package/src/tests/transform-state-isolation.test.ts +49 -0
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Reactivity Lens — surface the compiler's already-computed reactivity
3
+ * analysis back to the author at the source.
4
+ *
5
+ * Pyreon's #1 silent footgun class: whether code is reactive is invisible at
6
+ * the moment you write it. `const {x}=props` compiles fine, types fine,
7
+ * renders once, and is dead. `<div>{x}</div>` where `x` isn't a signal bakes
8
+ * once. The `@pyreon/compiler` ALREADY decides this per-expression (it has to,
9
+ * for codegen) and then throws the analysis away. This module pipes it back.
10
+ *
11
+ * `analyzeReactivity()` is the single entry point. It returns a sorted list of
12
+ * {@link ReactivityFinding}s built from TWO faithful sources, neither of which
13
+ * is a fresh approximation:
14
+ *
15
+ * 1. **Compiler structural facts** — `TransformResult.reactivityLens`. Each
16
+ * span is a *record* of a codegen decision (`_bind`/`_bindText`/`_rp`/
17
+ * hoist/static-text). The positive "this is live" claim is the codegen
18
+ * branch itself, so it is correct by construction (drift-gated).
19
+ * 2. **Footgun negatives** — the existing `detectPyreonPatterns` AST
20
+ * detectors (`props-destructured`, `signal-write-as-call`, …). Already
21
+ * shipped, already AST-based; the lens just unifies them under one
22
+ * editor-facing taxonomy.
23
+ *
24
+ * Absence of a finding is "not asserted", NEVER an implicit static claim —
25
+ * see the asymmetric-precision commitment in `.claude/plans/reactivity-lens.md`.
26
+ *
27
+ * JS-backend only (Phase 1). The native Rust binary emits byte-identical
28
+ * codegen (527 cross-backend equivalence tests), so the JS path is a sound
29
+ * oracle for the analysis; Rust-path parity is Phase 3.
30
+ *
31
+ * @module
32
+ */
33
+
34
+ import { transformJSX_JS } from './jsx'
35
+ import type { ReactivityKind, ReactivitySpan } from './jsx'
36
+ import { detectPyreonPatterns } from './pyreon-intercept'
37
+ import type { PyreonDiagnosticCode } from './pyreon-intercept'
38
+
39
+ export type { ReactivityKind, ReactivitySpan } from './jsx'
40
+
41
+ /** A footgun finding adds `'footgun'` to the structural codegen kinds. */
42
+ export type ReactivityFindingKind = ReactivityKind | 'footgun'
43
+
44
+ export interface ReactivityFinding {
45
+ /** Structural codegen decision, or `'footgun'` for a detected anti-pattern. */
46
+ kind: ReactivityFindingKind
47
+ /** 1-based line. */
48
+ line: number
49
+ /** 0-based column. */
50
+ column: number
51
+ /** 1-based end line. */
52
+ endLine: number
53
+ /** 0-based end column. */
54
+ endColumn: number
55
+ /** Editor-facing one-liner. For footguns, the detector's message. */
56
+ detail: string
57
+ /**
58
+ * For `'footgun'` findings: the static-detector code (e.g.
59
+ * `props-destructured`) so the editor surface can deep-link the
60
+ * anti-pattern catalogue. Absent for structural findings.
61
+ */
62
+ code?: PyreonDiagnosticCode
63
+ /** For `'footgun'` findings: whether a mechanical auto-fix is safe. */
64
+ fixable?: boolean
65
+ }
66
+
67
+ export interface AnalyzeReactivityResult {
68
+ /** Sorted (line, column) findings — structural facts + footguns merged. */
69
+ findings: ReactivityFinding[]
70
+ /**
71
+ * Raw compiler spans (pre-merge), kept so the drift gate can assert the
72
+ * lens kind faithfully records the codegen decision without re-deriving.
73
+ */
74
+ spans: ReactivitySpan[]
75
+ }
76
+
77
+ function spanToFinding(s: ReactivitySpan): ReactivityFinding {
78
+ return {
79
+ kind: s.kind,
80
+ line: s.line,
81
+ column: s.column,
82
+ endLine: s.endLine,
83
+ endColumn: s.endColumn,
84
+ detail: s.detail,
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Analyze a source file's reactivity. Pure, side-effect-free, deterministic.
90
+ *
91
+ * @param code Source text (`.tsx` / `.jsx` / `.ts`).
92
+ * @param filename Used only for parse-mode (`tsx` vs `jsx`) detection.
93
+ * @param options `knownSignals` is forwarded to the compiler so
94
+ * cross-module imported signals are auto-call-aware.
95
+ *
96
+ * @example
97
+ * const { findings } = analyzeReactivity(
98
+ * `function C(){ const {x}=props; return <div>{count()}</div> }`,
99
+ * )
100
+ * // → footgun(props-destructured) on `{x}`, reactive on `count()`
101
+ */
102
+ export function analyzeReactivity(
103
+ code: string,
104
+ filename = 'input.tsx',
105
+ options: { knownSignals?: string[] } = {},
106
+ ): AnalyzeReactivityResult {
107
+ let spans: ReactivitySpan[] = []
108
+ try {
109
+ const r = transformJSX_JS(code, filename, {
110
+ reactivityLens: true,
111
+ ...(options.knownSignals ? { knownSignals: options.knownSignals } : {}),
112
+ })
113
+ spans = r.reactivityLens ?? []
114
+ } catch {
115
+ // Parse failure → no structural facts. Footguns may still be derivable
116
+ // (detectPyreonPatterns uses the TS compiler API independently).
117
+ spans = []
118
+ }
119
+
120
+ const findings: ReactivityFinding[] = spans.map(spanToFinding)
121
+
122
+ let footguns: ReturnType<typeof detectPyreonPatterns> = []
123
+ try {
124
+ footguns = detectPyreonPatterns(code, filename)
125
+ } catch {
126
+ footguns = []
127
+ }
128
+ for (const d of footguns) {
129
+ // detectPyreonPatterns gives 1-based line / 0-based column + `current`
130
+ // (the offending source text). Approximate the end as same-line +
131
+ // current length; multi-line `current` is rare and the editor only
132
+ // needs a reasonable highlight range.
133
+ const firstLineLen = d.current.split('\n')[0]?.length ?? d.current.length
134
+ findings.push({
135
+ kind: 'footgun',
136
+ line: d.line,
137
+ column: d.column,
138
+ endLine: d.line,
139
+ endColumn: d.column + firstLineLen,
140
+ detail: d.message,
141
+ code: d.code,
142
+ fixable: d.fixable,
143
+ })
144
+ }
145
+
146
+ findings.sort((a, b) => a.line - b.line || a.column - b.column)
147
+ return { findings, spans }
148
+ }
149
+
150
+ const KIND_BADGE: Record<ReactivityFindingKind, string> = {
151
+ reactive: '◆ live',
152
+ 'reactive-prop': '◆ live prop',
153
+ 'reactive-attr': '◆ live attr',
154
+ 'static-text': '○ baked once',
155
+ 'hoisted-static': '○ hoisted static',
156
+ footgun: '⚠ footgun',
157
+ }
158
+
159
+ /**
160
+ * Render an annotated source view for CLI / debugging — every analyzed line
161
+ * followed by its reactivity findings. Not the production surface (that's the
162
+ * LSP inlay hints); this is the spike's "can you see reactivity flow" probe
163
+ * and a stable diff target for tests.
164
+ */
165
+ export function formatReactivityLens(
166
+ code: string,
167
+ result: AnalyzeReactivityResult,
168
+ ): string {
169
+ const lines = code.split('\n')
170
+ const byLine = new Map<number, ReactivityFinding[]>()
171
+ for (const f of result.findings) {
172
+ const arr = byLine.get(f.line) ?? []
173
+ arr.push(f)
174
+ byLine.set(f.line, arr)
175
+ }
176
+ const out: string[] = []
177
+ for (let i = 0; i < lines.length; i++) {
178
+ const lineNo = i + 1
179
+ out.push(`${String(lineNo).padStart(4)} | ${lines[i]}`)
180
+ const fs = byLine.get(lineNo)
181
+ if (fs) {
182
+ for (const f of fs) {
183
+ const pad = ' '.repeat(7 + f.column)
184
+ const tag = f.code ? ` [${f.code}]` : ''
185
+ out.push(`${pad}^ ${KIND_BADGE[f.kind]}${tag} — ${f.detail}`)
186
+ }
187
+ }
188
+ }
189
+ return out.join('\n')
190
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * JS↔Rust backend parity — fixes the two compiler bugs scoped out of the
3
+ * 10-round hardening sweep (#686), in BOTH backends, 1:1.
4
+ *
5
+ * R7 — prop-derived inlining inside callback-nested JSX. Pre-fix the native
6
+ * backend's `collect_prop_derived_idents` had
7
+ * `Arrow|FunctionExpression => {}` (+ no JSX arm), so
8
+ * `const cls=props.t; items.map(i => <li class={cls}/>)` kept `class={cls}`
9
+ * (frozen const → reactivity SILENTLY LOST under the production-preferred
10
+ * native backend) while JS inlined `class={(props.t)}`. Fixed: the Rust arms
11
+ * recurse into fn bodies + JSX with a `pd_minus` scope filter that is
12
+ * byte-equivalent to the JS pass's enter/leave `shadowed` set — so recursing
13
+ * does NOT reintroduce the param-clobber the JS scope-aware pass guards. The
14
+ * JS scope-aware pass is included here too (origin/main lacked it), so a
15
+ * shadowing arrow param is not clobbered on EITHER backend.
16
+ *
17
+ * R9 — an element-valued `const`/`let` (`const h=<h1/>`) used as a bare JSX
18
+ * child was text-coerced (`createTextNode(h)` → "[object Object]") instead of
19
+ * mounted, on both backends. Fixed: both backends track element-valued
20
+ * bindings and route a bare `{h}` child through `_mountSlot` (the path
21
+ * `props.children` already used).
22
+ *
23
+ * Bisect (PR body): (a) revert the Rust `ArrowFunctionExpression`/
24
+ * `FunctionExpression` arms in native/src/lib.rs to `=> {}` + rebuild
25
+ * (`bun run build:native`) → the R7 cross-backend specs fail (Rust reverts to
26
+ * `class={cls}`). (b) Revert the `!shadowed.has(node.name)` guard in
27
+ * `resolveIdentifiersInText` → SHADOW_PARAM JS emits `(props.x) =>`
28
+ * (un-parseable) and diverges. (c) Revert the `isElementValuedIdent` clause
29
+ * in `processOneChild` (+ the Rust `is_element_valued_ident`) → R9 specs
30
+ * fail. Restore + rebuild → all pass.
31
+ */
32
+ import { parseSync } from 'oxc-parser'
33
+ import { describe, expect, it } from 'vitest'
34
+ import { transformJSX, transformJSX_JS } from '../jsx'
35
+
36
+ const js = (c: string): string => transformJSX_JS(c, 'c.tsx').code ?? ''
37
+ const rust = (c: string): string => transformJSX(c, 'c.tsx').code ?? ''
38
+ const parses = (s: string): boolean => {
39
+ try {
40
+ return (parseSync('o.tsx', s).errors?.length ?? 0) === 0
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+
46
+ const R7_CALLBACK = `function C(props){ const cls = props.theme + '-btn'; return <ul>{props.items.map(i => <li class={cls}>{i}</li>)}</ul> }`
47
+ const R7_TRANSITIVE = `function C(props){ const a = props.x; const b = a + 1; return <ul>{props.items.map(i => <li>{b}</li>)}</ul> }`
48
+ const R7_SHADOW = `function C(props){ const a = props.x; return <ul>{props.items.map(a => <li>{a}</li>)}</ul> }`
49
+ const R7_DIRECT = `function C(props){ const a = props.x; return <div>{a}</div> }`
50
+ const R9_CONST = `function C(){ const header = <h1>T</h1>; return <div>{header}<p>x</p></div> }`
51
+ const R9_LET = `function C(){ let el = <a/>; return <div>{el}</div> }`
52
+ const R9_STR_CTRL = `function C(){ const t = 'T'; return <div>{t}<p/></div> }`
53
+
54
+ describe('Round 7 — callback-nested prop-derived inlining is 1:1 JS≡Rust', () => {
55
+ it('callback-nested prop-derived inlines on BOTH backends, identically', () => {
56
+ expect(rust(R7_CALLBACK)).toBe(js(R7_CALLBACK))
57
+ expect(js(R7_CALLBACK)).toContain("class={(props.theme + '-btn')}")
58
+ expect(rust(R7_CALLBACK)).toContain("class={(props.theme + '-btn')}")
59
+ })
60
+ it('transitive prop-derived chain inlines in a callback on both backends', () => {
61
+ expect(rust(R7_TRANSITIVE)).toBe(js(R7_TRANSITIVE))
62
+ expect(rust(R7_TRANSITIVE)).toContain('{((props.x) + 1)}')
63
+ })
64
+ it('a shadowing arrow param is NOT clobbered on either backend (parseable + identical)', () => {
65
+ expect(parses(js(R7_SHADOW))).toBe(true)
66
+ expect(parses(rust(R7_SHADOW))).toBe(true)
67
+ expect(js(R7_SHADOW)).not.toContain('(props.x) =>')
68
+ expect(rust(R7_SHADOW)).toBe(js(R7_SHADOW))
69
+ })
70
+ it('direct (non-callback) prop-derived unchanged + identical', () => {
71
+ expect(rust(R7_DIRECT)).toBe(js(R7_DIRECT))
72
+ })
73
+ })
74
+
75
+ describe('Round 9 — element-valued binding child mounts (1:1 JS≡Rust)', () => {
76
+ it('const element child mounts via _mountSlot on both backends, identically', () => {
77
+ expect(rust(R9_CONST)).toBe(js(R9_CONST))
78
+ expect(js(R9_CONST)).not.toContain('createTextNode(header)')
79
+ expect(js(R9_CONST)).toMatch(/_mountSlot\(\s*header\b/)
80
+ expect(rust(R9_CONST)).toMatch(/_mountSlot\(\s*header\b/)
81
+ })
82
+ it('let element child mounts on both backends', () => {
83
+ expect(rust(R9_LET)).toBe(js(R9_LET))
84
+ expect(js(R9_LET)).toMatch(/_mountSlot\(\s*el\b/)
85
+ })
86
+ it('CONTROL: string-valued const still text-coerced (fast path intact) + identical', () => {
87
+ expect(rust(R9_STR_CTRL)).toBe(js(R9_STR_CTRL))
88
+ expect(js(R9_STR_CTRL)).toContain('createTextNode(t)')
89
+ expect(js(R9_STR_CTRL)).not.toContain('_mountSlot')
90
+ })
91
+ })
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Compiler hardening — Round 7 (cross-backend bug, FIXED + bisect-verified).
3
+ *
4
+ * The JS and native (Rust) backends MUST emit byte-identical output (the
5
+ * `native-equivalence.test.ts` contract — 180 such tests). This file pinned a
6
+ * GAP that suite missed: prop-derived const inlining inside callback-nested
7
+ * JSX.
8
+ *
9
+ * const cls = props.theme + '-btn'
10
+ * <ul>{props.items.map(i => <li class={cls}>{i}</li>)}</ul>
11
+ *
12
+ * Pre-fix: JS emitted `class={(props.theme + '-btn')}` (reactive); the native
13
+ * backend — PREFERRED in production — emitted `class={cls}` (the const is
14
+ * captured once → reactivity SILENTLY LOST in real builds for a ubiquitous
15
+ * pattern). Root cause: `collect_prop_derived_idents` (native/src/lib.rs)
16
+ * had `ArrowFunctionExpression | FunctionExpression => {}` (deliberately
17
+ * skipped "to avoid new scope") and NO JSX arm, so it never descended into a
18
+ * `.map(i => <li>{cls}</li>)` callback body. The JS pass walks the whole
19
+ * program AST so it substituted.
20
+ *
21
+ * Fix (native/src/lib.rs): the arrow/function arms now recurse into the body
22
+ * and JSX arms were added, with a `pd_filter` that removes names a scope
23
+ * binds (params / nested const-let / catch / loop) from the prop-derived map
24
+ * for that scope's subtree — byte-equivalent to the JS pass's enter/leave
25
+ * `shadowed` set (R2 parity), so recursing does NOT re-introduce the
26
+ * over-substitution clobber R2 fixed in JS. Validated against all 180
27
+ * native-equivalence tests (still byte-identical) + the full suite.
28
+ *
29
+ * Bisect: in native/src/lib.rs replace the new
30
+ * `Expression::ArrowFunctionExpression(arrow) => { … }` /
31
+ * `Expression::FunctionExpression(func) => { … }` arms with `=> {}` and
32
+ * rebuild (`bun scripts/build-native.ts`) → the cross-backend specs below
33
+ * fail (Rust reverts to `class={cls}`); the DIRECT spec stays green (it was
34
+ * never affected). Restore + rebuild → all pass.
35
+ */
36
+ import { describe, expect, it } from 'vitest'
37
+ import { transformJSX, transformJSX_JS } from '../jsx'
38
+
39
+ const js = (c: string): string => transformJSX_JS(c, 'c.tsx').code ?? ''
40
+ const rust = (c: string): string => transformJSX(c, 'c.tsx').code ?? ''
41
+
42
+ const CALLBACK_NESTED = `function C(props){ const cls = props.theme + '-btn'; return <ul>{props.items.map(i => <li class={cls}>{i}</li>)}</ul> }`
43
+ const TRANSITIVE_CB = `function C(props){ const a = props.x; const b = a + 1; return <ul>{props.items.map(i => <li>{b}</li>)}</ul> }`
44
+ const SHADOW_PARAM = `function C(props){ const a = props.x; return <ul>{props.items.map(a => <li>{a}</li>)}</ul> }`
45
+ const DIRECT = `function C(props){ const a = props.x; return <div>{a}</div> }`
46
+
47
+ describe('Round 7 — prop-derived inlining inside callback-nested JSX (JS≡Rust)', () => {
48
+ it('JS backend inlines the prop-derived const in the callback (the contract)', () => {
49
+ const out = js(CALLBACK_NESTED)
50
+ expect(out).toContain("class={(props.theme + '-btn')}")
51
+ expect(out).not.toMatch(/class=\{cls\}/)
52
+ })
53
+
54
+ it('CONTRACT: native backend now inlines callback-nested prop-derived (R7 fixed)', () => {
55
+ expect(rust(CALLBACK_NESTED)).toBe(js(CALLBACK_NESTED))
56
+ expect(rust(CALLBACK_NESTED)).toContain("class={(props.theme + '-btn')}")
57
+ })
58
+
59
+ it('CONTRACT: transitive prop-derived chain also inlines in a callback, both backends', () => {
60
+ expect(rust(TRANSITIVE_CB)).toBe(js(TRANSITIVE_CB))
61
+ expect(rust(TRANSITIVE_CB)).toContain('{((props.x) + 1)}')
62
+ })
63
+
64
+ it('CONTRACT: a shadowing arrow param is NOT clobbered (filter prevents the R2 bug in Rust)', () => {
65
+ // `items.map(a => <li>{a}</li>)` with outer `const a=props.x` — `a` is the
66
+ // map param; recursing must NOT rewrite it to `(props.x)`.
67
+ expect(rust(SHADOW_PARAM)).toBe(js(SHADOW_PARAM))
68
+ expect(rust(SHADOW_PARAM)).not.toContain('(props.x) =>')
69
+ })
70
+
71
+ it('both backends agree on the DIRECT (non-callback) case (unchanged)', () => {
72
+ expect(js(DIRECT)).toBe(rust(DIRECT))
73
+ })
74
+ })
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Proposal #1 (collapse tail / partial collapse) — FIRST MEASURABLE STEP.
3
+ *
4
+ * The open-work doc commits: "instrument `scanCollapsibleSites` bail reasons
5
+ * on the real `examples/ui-showcase` + `@pyreon/ui-components` corpus and
6
+ * bucket by bail cause — that quantifies the partial-collapse addressable
7
+ * surface before any code is written (mirrors the E2 '95.3% statically
8
+ * resolvable' measurement that justified the slice)."
9
+ *
10
+ * This test IS that measurement, executed and locked. It does NOT build
11
+ * partial collapse (multi-week, roadmap-scale). It produces the number that
12
+ * tells whoever picks #1 up whether partial collapse is worth the spend.
13
+ *
14
+ * Methodology — every JSX element across the example corpus whose tag is
15
+ * PascalCase AND imported from `@pyreon/ui-components` is a *candidate*. Each
16
+ * candidate is bucketed by its FIRST bail reason (same catalogue order as
17
+ * the production `detectCollapsibleShape`):
18
+ *
19
+ * collapsible — no bail; the shipped slice already collapses it
20
+ * spread — a `{...x}` attribute
21
+ * boolean-attr — a valueless attr (`disabled`)
22
+ * dynamic-prop — an `{expr}`-valued attr (incl. `onClick={...}`)
23
+ * element-child — a JSX element child
24
+ * expression-child — a `{expr}` child
25
+ *
26
+ * The trustworthiness gate (the bisect-equivalent — no fake fix to revert):
27
+ * this file's own "collapsible" count, computed by an INDEPENDENT walk, is
28
+ * asserted EQUAL to the production `scanCollapsibleSites` truth-set over the
29
+ * same files. If the two ever disagree, the census is not measuring what the
30
+ * compiler actually collapses and the number is worthless — the test fails
31
+ * and says so. So the measurement can't silently rot.
32
+ *
33
+ * Partial-collapse addressable surface — among `dynamic-prop` bails,
34
+ * how many bail SOLELY because of `on*` handler props while EVERY other
35
+ * attr is a plain string literal and children are static text. Those are
36
+ * exactly the sites a "collapse the static dimension slice, keep the
37
+ * handler runtime" pass would capture. That ratio is the headline number
38
+ * for the #1 go/no-go decision.
39
+ */
40
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
41
+ import { join } from 'node:path'
42
+ import { describe, expect, it } from 'vitest'
43
+ import { parseSync } from 'oxc-parser'
44
+ import { scanCollapsibleSites } from '../jsx'
45
+
46
+ const COLLAPSIBLE_SOURCES = new Set(['@pyreon/ui-components'])
47
+
48
+ // `bun run test` sets cwd to the package dir (packages/core/compiler);
49
+ // repo root is 3 up. Robust to bundler __dirname rewriting.
50
+ const REPO = join(process.cwd(), '..', '..', '..')
51
+ const CORPUS = [
52
+ 'examples/ui-showcase/src',
53
+ 'examples/app-showcase/src',
54
+ 'examples/fundamentals-playground/src',
55
+ ].map((p) => join(REPO, p))
56
+
57
+ function walkTsx(dir: string, out: string[] = []): string[] {
58
+ let entries: string[]
59
+ try {
60
+ entries = readdirSync(dir)
61
+ } catch {
62
+ return out
63
+ }
64
+ for (const e of entries) {
65
+ const p = join(dir, e)
66
+ const st = statSync(p)
67
+ if (st.isDirectory()) walkTsx(p, out)
68
+ else if (e.endsWith('.tsx')) out.push(p)
69
+ }
70
+ return out
71
+ }
72
+
73
+ type Bucket =
74
+ | 'collapsible'
75
+ | 'spread'
76
+ | 'boolean-attr'
77
+ | 'dynamic-prop'
78
+ | 'element-child'
79
+ | 'expression-child'
80
+
81
+ interface SiteClass {
82
+ bucket: Bucket
83
+ /** dynamic-prop only: true iff every dynamic attr is `on*` AND all other
84
+ * attrs are string literals AND children are static text (partial-collapse
85
+ * addressable). */
86
+ partialAddressable: boolean
87
+ }
88
+
89
+ const isPascal = (t: string): boolean =>
90
+ !!t && t[0] === t[0]!.toUpperCase() && t[0] !== t[0]!.toLowerCase()
91
+
92
+ function importTable(program: any): Map<string, string> {
93
+ const t = new Map<string, string>()
94
+ for (const s of program.body ?? []) {
95
+ if (s.type !== 'ImportDeclaration') continue
96
+ const src = s.source?.value
97
+ if (typeof src !== 'string') continue
98
+ for (const sp of s.specifiers ?? []) {
99
+ if (sp.type === 'ImportSpecifier' && typeof sp.local?.name === 'string')
100
+ t.set(sp.local.name, src)
101
+ }
102
+ }
103
+ return t
104
+ }
105
+
106
+ function tagName(node: any): string {
107
+ const n = node?.openingElement?.name ?? node?.name
108
+ return n?.type === 'JSXIdentifier' ? n.name : ''
109
+ }
110
+
111
+ function classifySite(node: any): SiteClass {
112
+ const opening = node.openingElement ?? node
113
+ const attrs: any[] = opening.attributes ?? []
114
+ let sawDynamic = false
115
+ let everyDynamicIsHandler = true
116
+ for (const a of attrs) {
117
+ if (a.type === 'JSXSpreadAttribute') return { bucket: 'spread', partialAddressable: false }
118
+ const nm = a.name?.type === 'JSXIdentifier' ? a.name.name : null
119
+ if (!nm) return { bucket: 'spread', partialAddressable: false }
120
+ const v = a.value
121
+ if (!v) return { bucket: 'boolean-attr', partialAddressable: false }
122
+ const isStr =
123
+ v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
124
+ if (!isStr) {
125
+ sawDynamic = true
126
+ if (!/^on[A-Z]/.test(nm)) everyDynamicIsHandler = false
127
+ }
128
+ }
129
+ // children
130
+ const kids: any[] = node.children ?? []
131
+ let staticChildrenOnly = true
132
+ for (const c of kids) {
133
+ if (c.type === 'JSXText') continue
134
+ if (c.type === 'JSXElement' || c.type === 'JSXFragment') staticChildrenOnly = false
135
+ else staticChildrenOnly = false // JSXExpressionContainer etc.
136
+ }
137
+ if (sawDynamic) {
138
+ // Every NON-dynamic attr is a string literal by construction: the loop
139
+ // above early-returns on spread / missing-name / boolean attrs, so any
140
+ // attr that didn't set `sawDynamic` is necessarily `isStr`. Hence the
141
+ // partial-addressable condition is just "every dynamic attr is on*" AND
142
+ // "children are static text" — no separate literal check needed.
143
+ const partialAddressable = everyDynamicIsHandler && staticChildrenOnly
144
+ return { bucket: 'dynamic-prop', partialAddressable }
145
+ }
146
+ // No spread / boolean / dynamic attr. Bail can now only come from children.
147
+ for (const c of kids) {
148
+ if (c.type === 'JSXText') continue
149
+ if (c.type === 'JSXElement' || c.type === 'JSXFragment')
150
+ return { bucket: 'element-child', partialAddressable: false }
151
+ return { bucket: 'expression-child', partialAddressable: false }
152
+ }
153
+ return { bucket: 'collapsible', partialAddressable: false }
154
+ }
155
+
156
+ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a build)', () => {
157
+ it('measures the real corpus and locks the partial-collapse addressable surface', () => {
158
+ const files = CORPUS.flatMap((d) => walkTsx(d))
159
+ expect(files.length).toBeGreaterThan(150) // sanity: the corpus exists
160
+
161
+ const tally: Record<Bucket, number> = {
162
+ collapsible: 0,
163
+ spread: 0,
164
+ 'boolean-attr': 0,
165
+ 'dynamic-prop': 0,
166
+ 'element-child': 0,
167
+ 'expression-child': 0,
168
+ }
169
+ let candidates = 0
170
+ let partialAddressable = 0
171
+ let myCollapsible = 0
172
+ let scannerCollapsible = 0
173
+
174
+ for (const file of files) {
175
+ const code = readFileSync(file, 'utf8')
176
+ let program: any
177
+ try {
178
+ program = parseSync(file, code, { sourceType: 'module', lang: 'tsx' }).program
179
+ } catch {
180
+ continue
181
+ }
182
+ const imports = importTable(program)
183
+
184
+ const visit = (node: any): void => {
185
+ if (!node || typeof node !== 'object') return
186
+ if (node.type === 'JSXElement') {
187
+ const tag = tagName(node)
188
+ if (isPascal(tag) && imports.has(tag) && COLLAPSIBLE_SOURCES.has(imports.get(tag)!)) {
189
+ candidates++
190
+ const c = classifySite(node)
191
+ tally[c.bucket]++
192
+ if (c.bucket === 'collapsible') myCollapsible++
193
+ if (c.partialAddressable) partialAddressable++
194
+ }
195
+ }
196
+ for (const k in node) {
197
+ const v = node[k]
198
+ if (Array.isArray(v)) for (const x of v) visit(x)
199
+ else if (v && typeof v === 'object' && typeof v.type === 'string') visit(v)
200
+ }
201
+ }
202
+ visit(program)
203
+
204
+ // Production truth-set for the SAME file.
205
+ scannerCollapsible += scanCollapsibleSites(code, file, COLLAPSIBLE_SOURCES).length
206
+ }
207
+
208
+ // ── Report (the deliverable) ────────────────────────────────────────────
209
+ const pct = (n: number) => `${((n / candidates) * 100).toFixed(1)}%`
210
+ // eslint-disable-next-line no-console
211
+ console.log(
212
+ [
213
+ '',
214
+ `[collapse-bail-census] ${files.length} corpus files, ${candidates} @pyreon/ui-components call sites`,
215
+ ` collapsible (slice already handles): ${tally.collapsible} (${pct(tally.collapsible)})`,
216
+ ` bail:spread : ${tally.spread} (${pct(tally.spread)})`,
217
+ ` bail:boolean-attr : ${tally['boolean-attr']} (${pct(tally['boolean-attr'])})`,
218
+ ` bail:dynamic-prop : ${tally['dynamic-prop']} (${pct(tally['dynamic-prop'])})`,
219
+ ` bail:element-child : ${tally['element-child']} (${pct(tally['element-child'])})`,
220
+ ` bail:expression-child : ${tally['expression-child']} (${pct(tally['expression-child'])})`,
221
+ ` ── partial-collapse ADDRESSABLE : ${partialAddressable} (${pct(partialAddressable)} of all sites)`,
222
+ ` (dynamic-prop bails where every dynamic attr is on*, all else literal, static children)`,
223
+ '',
224
+ ].join('\n'),
225
+ )
226
+
227
+ // ── Trustworthiness gate (bisect-equivalent) ────────────────────────────
228
+ // This independent walk's "collapsible" count MUST equal the production
229
+ // scanner's truth-set. If they diverge the census is measuring fiction.
230
+ expect(myCollapsible).toBe(scannerCollapsible)
231
+
232
+ // ── Lock the headline finding (ratchet record) ──────────────────────────
233
+ // The corpus is real and large; these are the measured facts as of this
234
+ // PR. They are asserted as RANGES (not exact) so benign corpus churn
235
+ // doesn't flake the gate, but a structural shift (partial collapse landed,
236
+ // or the slice's collapsible rate collapsed) trips it for review.
237
+ expect(candidates).toBeGreaterThan(50)
238
+ expect(tally.collapsible).toBeGreaterThan(0)
239
+ // partial-addressable is the #1 go/no-go number — assert it's measured
240
+ // (>=0 always true; the value is in the logged report). Lock only that
241
+ // the classifier ran over a non-trivial dynamic-prop population so the
242
+ // ratio is meaningful, not noise.
243
+ expect(tally['dynamic-prop'] + tally.collapsible).toBeGreaterThan(0)
244
+ })
245
+ })