@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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +2081 -1262
- package/lib/types/index.d.ts +310 -125
- package/package.json +14 -12
- package/src/defer-inline.ts +397 -157
- package/src/index.ts +14 -2
- package/src/jsx.ts +784 -19
- package/src/load-native.ts +1 -0
- package/src/manifest.ts +280 -0
- package/src/pyreon-intercept.ts +164 -0
- package/src/react-intercept.ts +59 -0
- package/src/reactivity-lens.ts +190 -0
- package/src/tests/backend-parity-r7-r9.test.ts +91 -0
- package/src/tests/backend-prop-derived-callback-divergence.test.ts +74 -0
- package/src/tests/collapse-bail-census.test.ts +245 -0
- package/src/tests/collapse-key-source-hygiene.test.ts +88 -0
- package/src/tests/defer-inline.test.ts +209 -21
- package/src/tests/detector-tag-consistency.test.ts +2 -0
- package/src/tests/element-valued-const-child.test.ts +61 -0
- package/src/tests/falsy-child-characterization.test.ts +48 -0
- package/src/tests/malformed-input-resilience.test.ts +50 -0
- package/src/tests/manifest-snapshot.test.ts +55 -0
- package/src/tests/native-equivalence.test.ts +104 -3
- package/src/tests/partial-collapse-detector.test.ts +121 -0
- package/src/tests/partial-collapse-emit.test.ts +104 -0
- package/src/tests/partial-collapse-robustness.test.ts +53 -0
- package/src/tests/prop-derived-shadow.test.ts +96 -0
- package/src/tests/pure-call-reactive-args.test.ts +50 -0
- package/src/tests/pyreon-intercept.test.ts +189 -0
- package/src/tests/r13-callback-stmt-equivalence.test.ts +58 -0
- package/src/tests/r14-ssr-mode-parity.test.ts +51 -0
- package/src/tests/r15-elemconst-propderived.test.ts +47 -0
- package/src/tests/r19-defer-inline-robust.test.ts +54 -0
- package/src/tests/r20-backend-equivalence-sweep.test.ts +50 -0
- package/src/tests/react-intercept.test.ts +50 -2
- package/src/tests/reactivity-lens.test.ts +170 -0
- package/src/tests/rocketstyle-collapse.test.ts +208 -0
- package/src/tests/signal-autocall-shadow.test.ts +86 -0
- package/src/tests/sourcemap-fidelity.test.ts +77 -0
- package/src/tests/static-text-baking.test.ts +64 -0
- 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
|
+
})
|