@pyreon/compiler 0.18.0 → 0.19.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 +1660 -1241
- package/lib/types/index.d.ts +221 -127
- package/package.json +13 -12
- package/src/defer-inline.ts +397 -157
- package/src/index.ts +12 -1
- package/src/jsx.ts +117 -4
- 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/defer-inline.test.ts +209 -21
- package/src/tests/detector-tag-consistency.test.ts +2 -0
- package/src/tests/manifest-snapshot.test.ts +55 -0
- package/src/tests/native-equivalence.test.ts +104 -3
- package/src/tests/pyreon-intercept.test.ts +189 -0
- package/src/tests/react-intercept.test.ts +50 -2
- package/src/tests/reactivity-lens.test.ts +170 -0
|
@@ -333,8 +333,8 @@ describe('detectReactPatterns', () => {
|
|
|
333
333
|
expect(d!.fixable).toBe(true)
|
|
334
334
|
})
|
|
335
335
|
|
|
336
|
-
test('detects .value assignment on signal
|
|
337
|
-
const code = 'count.value = 5'
|
|
336
|
+
test('detects .value assignment on a declared signal', () => {
|
|
337
|
+
const code = 'const count = signal(0)\ncount.value = 5'
|
|
338
338
|
const diags = detectReactPatterns(code)
|
|
339
339
|
const d = diags.find((d) => d.code === 'dot-value-signal')
|
|
340
340
|
expect(d).toBeDefined()
|
|
@@ -343,6 +343,54 @@ describe('detectReactPatterns', () => {
|
|
|
343
343
|
expect(d!.fixable).toBe(false)
|
|
344
344
|
})
|
|
345
345
|
|
|
346
|
+
// ─── dot-value-signal precision (FP-free): only flag tracked signals ──────
|
|
347
|
+
|
|
348
|
+
test('flags .value write on signal(), computed(), useSignal(), createSignal()', () => {
|
|
349
|
+
for (const factory of ['signal', 'computed', 'useSignal', 'createSignal']) {
|
|
350
|
+
const code = `const s = ${factory}(0)\ns.value = 1`
|
|
351
|
+
const diags = detectReactPatterns(code)
|
|
352
|
+
const d = diags.find((d) => d.code === 'dot-value-signal')
|
|
353
|
+
expect(d, `expected dot-value-signal for ${factory}`).toBeDefined()
|
|
354
|
+
expect(d!.suggested).toContain('s.set(1)')
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('does NOT flag input.value = "" (DOM element, not a signal)', () => {
|
|
359
|
+
const code = `const input = document.querySelector("input")\ninput.value = ""`
|
|
360
|
+
const diags = detectReactPatterns(code)
|
|
361
|
+
expect(diags.find((d) => d.code === 'dot-value-signal')).toBeUndefined()
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
test('does NOT flag cell.value = x (data object, not a signal)', () => {
|
|
365
|
+
const code = `const cell = sheet.getCell(1, 1)\ncell.value = "hello"`
|
|
366
|
+
const diags = detectReactPatterns(code)
|
|
367
|
+
expect(diags.find((d) => d.code === 'dot-value-signal')).toBeUndefined()
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
test('does NOT flag o.value = y (loop option object, not a signal)', () => {
|
|
371
|
+
const code = `for (const o of options) { o.value = y }`
|
|
372
|
+
const diags = detectReactPatterns(code)
|
|
373
|
+
expect(diags.find((d) => d.code === 'dot-value-signal')).toBeUndefined()
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('does NOT flag ref.current.value = z (ref pattern, not a signal)', () => {
|
|
377
|
+
const code = `const ref = useRef(null)\nref.current.value = 42`
|
|
378
|
+
const diags = detectReactPatterns(code)
|
|
379
|
+
expect(diags.find((d) => d.code === 'dot-value-signal')).toBeUndefined()
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
test('does NOT flag bare X.value = n with no signal declaration', () => {
|
|
383
|
+
const code = 'count.value = 5'
|
|
384
|
+
const diags = detectReactPatterns(code)
|
|
385
|
+
expect(diags.find((d) => d.code === 'dot-value-signal')).toBeUndefined()
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
test('does NOT flag .value write on a let/var (mutable, unreliable)', () => {
|
|
389
|
+
const code = `let maybe = signal(0)\nmaybe = 5\nmaybe.value = 1`
|
|
390
|
+
const diags = detectReactPatterns(code)
|
|
391
|
+
expect(diags.find((d) => d.code === 'dot-value-signal')).toBeUndefined()
|
|
392
|
+
})
|
|
393
|
+
|
|
346
394
|
test('detects .map() in JSX expression', () => {
|
|
347
395
|
const code = 'const el = <ul>{items.map(item => <li>{item}</li>)}</ul>'
|
|
348
396
|
const diags = detectReactPatterns(code)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { transformJSX_JS } from '../jsx'
|
|
2
|
+
import { analyzeReactivity, formatReactivityLens } from '../reactivity-lens'
|
|
3
|
+
import type { ReactivityFinding } from '../reactivity-lens'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reactivity Lens — unit + drift gate.
|
|
7
|
+
*
|
|
8
|
+
* The load-bearing correctness contract: a lens span is a FAITHFUL RECORD of
|
|
9
|
+
* a codegen decision, never an approximation. Two invariants are gated here:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Additive** — collecting the lens does NOT change emitted `code`
|
|
12
|
+
* (kill-criterion a). Bisect: if a future edit makes lens collection
|
|
13
|
+
* mutate codegen, `additive` fails.
|
|
14
|
+
* 2. **Drift** — every positive `reactive*` span's OUTPUT carries the
|
|
15
|
+
* matching codegen token (`_bind`/`_bindText`/`_rp`), and every
|
|
16
|
+
* `static-text` span's text is NOT reactively bound (kill-criterion b).
|
|
17
|
+
* Bisect: reverting the `lens(...)` call at any instrumented site drops
|
|
18
|
+
* the corresponding span and the matching `expect(kinds).toContain(...)`
|
|
19
|
+
* fails — documented per-fixture below.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function kindsAt(code: string): string[] {
|
|
23
|
+
return analyzeReactivity(code).findings.map((f) => f.kind)
|
|
24
|
+
}
|
|
25
|
+
function find(code: string, kind: string): ReactivityFinding[] {
|
|
26
|
+
return analyzeReactivity(code).findings.filter((f) => f.kind === kind)
|
|
27
|
+
}
|
|
28
|
+
function sliceFinding(code: string, f: ReactivityFinding): string {
|
|
29
|
+
// Re-derive the byte slice from line/col for a human-readable assertion.
|
|
30
|
+
const lines = code.split('\n')
|
|
31
|
+
const line = lines[f.line - 1] ?? ''
|
|
32
|
+
return f.endLine === f.line
|
|
33
|
+
? line.slice(f.column, f.endColumn)
|
|
34
|
+
: line.slice(f.column)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('reactivity-lens — additive contract (kill-criterion a)', () => {
|
|
38
|
+
const FIXTURES = [
|
|
39
|
+
`function C(){ return <div>{count()}</div> }`,
|
|
40
|
+
`function C(p){ return <span>{p.label}</span> }`,
|
|
41
|
+
`function C(){ return <Box title={n()} /> }`,
|
|
42
|
+
`function C(){ return <div class="static">hi</div> }`,
|
|
43
|
+
`function C(){ return <a class={() => cls()}>x</a> }`,
|
|
44
|
+
`const x = 1; function C(){ const {a}=props; return <i>{a}</i> }`,
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
it('lens collection NEVER changes emitted code (byte-identical)', () => {
|
|
48
|
+
for (const src of FIXTURES) {
|
|
49
|
+
const off = transformJSX_JS(src, 'f.tsx').code
|
|
50
|
+
const on = transformJSX_JS(src, 'f.tsx', { reactivityLens: true }).code
|
|
51
|
+
expect(on).toBe(off)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('lens field is absent unless opted in', () => {
|
|
56
|
+
const r = transformJSX_JS(FIXTURES[0]!, 'f.tsx')
|
|
57
|
+
expect(r.reactivityLens).toBeUndefined()
|
|
58
|
+
const r2 = transformJSX_JS(FIXTURES[0]!, 'f.tsx', { reactivityLens: true })
|
|
59
|
+
expect(Array.isArray(r2.reactivityLens)).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('reactivity-lens — drift gate (positive claim = codegen record)', () => {
|
|
64
|
+
it('reactive text: {count()} → reactive span + _bind in output', () => {
|
|
65
|
+
const src = `function C(){ return <div>{count()}</div> }`
|
|
66
|
+
const reactive = find(src, 'reactive')
|
|
67
|
+
expect(reactive.length).toBe(1)
|
|
68
|
+
expect(sliceFinding(src, reactive[0]!)).toBe('count()')
|
|
69
|
+
// Drift proof: the codegen actually emitted a reactive binding.
|
|
70
|
+
const out = transformJSX_JS(src, 'f.tsx').code
|
|
71
|
+
expect(out).toMatch(/_bind(Text|Direct)?\(/)
|
|
72
|
+
expect(kindsAt(src)).not.toContain('static-text')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('static text: {p.x}-free plain identifier → static-text, NOT reactive', () => {
|
|
76
|
+
const src = `function C(){ const label = "hi"; return <div>{label}</div> }`
|
|
77
|
+
const k = kindsAt(src)
|
|
78
|
+
expect(k).toContain('static-text')
|
|
79
|
+
expect(k).not.toContain('reactive')
|
|
80
|
+
const st = find(src, 'static-text')[0]!
|
|
81
|
+
expect(sliceFinding(src, st)).toBe('label')
|
|
82
|
+
// Drift proof: codegen baked it (no reactive binding helper for this).
|
|
83
|
+
const out = transformJSX_JS(src, 'f.tsx').code
|
|
84
|
+
expect(out).not.toMatch(/_bind\(\(\) => \{ \w+\.data = label \}/)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('reactive prop: <Box title={n()} /> → reactive-prop + _rp in output', () => {
|
|
88
|
+
const src = `function C(){ return <Box title={n()} /> }`
|
|
89
|
+
const rp = find(src, 'reactive-prop')
|
|
90
|
+
expect(rp.length).toBe(1)
|
|
91
|
+
expect(sliceFinding(src, rp[0]!)).toBe('n()')
|
|
92
|
+
expect(transformJSX_JS(src, 'f.tsx').code).toContain('_rp(() =>')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('hoisted static: static JSX in a non-template position → hoisted-static + module preamble', () => {
|
|
96
|
+
// A top-level returned static element becomes a `_tpl()` clone (template
|
|
97
|
+
// path) — that's not a hoist, and the lens correctly stays silent (no
|
|
98
|
+
// span = "not asserted"). maybeHoist only fires for static JSX in an
|
|
99
|
+
// expression slot of a non-DOM (component) parent; that's the faithful
|
|
100
|
+
// trigger and what the lens records.
|
|
101
|
+
const src = `function C(){ return <Comp>{<b class="x">hi</b>}</Comp> }`
|
|
102
|
+
const hs = find(src, 'hoisted-static')
|
|
103
|
+
expect(hs.length).toBeGreaterThanOrEqual(1)
|
|
104
|
+
expect(transformJSX_JS(src, 'f.tsx').code).toMatch(/const _\$h\d+ =/)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('reactive attr: class={() => cls()} → reactive-attr', () => {
|
|
108
|
+
const src = `function C(){ return <a class={() => cls()}>x</a> }`
|
|
109
|
+
const ra = find(src, 'reactive-attr')
|
|
110
|
+
expect(ra.length).toBe(1)
|
|
111
|
+
expect(ra[0]!.detail).toContain('class')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('reactivity-lens — footgun merge (existing detectPyreonPatterns)', () => {
|
|
116
|
+
it('param-destructured props surface a footgun finding with the detector code', () => {
|
|
117
|
+
// detectPyreonPatterns catches the PARAMETER-destructure shape
|
|
118
|
+
// `({ name })`. The body-scope `const {x}=props` shape is the static
|
|
119
|
+
// layer's known cliff (doc-only anti-pattern, no reliable AST detector)
|
|
120
|
+
// — the lens's structural `static-text`/`reactive` signals are what
|
|
121
|
+
// compensate for that downstream. This asserts the merge surfaces
|
|
122
|
+
// whatever the existing detector finds, faithfully.
|
|
123
|
+
const src = `function C({ name }){ return <div>{name}</div> }`
|
|
124
|
+
const fg = find(src, 'footgun')
|
|
125
|
+
expect(fg.length).toBeGreaterThanOrEqual(1)
|
|
126
|
+
expect(fg.some((f) => f.code === 'props-destructured')).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('findings are sorted by (line, column)', () => {
|
|
130
|
+
const src = [
|
|
131
|
+
`function C(props){`,
|
|
132
|
+
` const { a } = props`,
|
|
133
|
+
` return <div>{count()}</div>`,
|
|
134
|
+
`}`,
|
|
135
|
+
].join('\n')
|
|
136
|
+
const { findings } = analyzeReactivity(src)
|
|
137
|
+
for (let i = 1; i < findings.length; i++) {
|
|
138
|
+
const prev = findings[i - 1]!
|
|
139
|
+
const cur = findings[i]!
|
|
140
|
+
expect(
|
|
141
|
+
prev.line < cur.line ||
|
|
142
|
+
(prev.line === cur.line && prev.column <= cur.column),
|
|
143
|
+
).toBe(true)
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('reactivity-lens — zero false "live" on idiomatic code (kill-criterion b)', () => {
|
|
149
|
+
it('purely static component yields no reactive* findings', () => {
|
|
150
|
+
const src = `function Card(){ return <div class="card"><h2>Title</h2><p>Body</p></div> }`
|
|
151
|
+
const k = kindsAt(src)
|
|
152
|
+
expect(k).not.toContain('reactive')
|
|
153
|
+
expect(k).not.toContain('reactive-prop')
|
|
154
|
+
expect(k).not.toContain('reactive-attr')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('parse failure → empty, never throws', () => {
|
|
158
|
+
const r = analyzeReactivity(`function C( { return <div`)
|
|
159
|
+
expect(Array.isArray(r.findings)).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('reactivity-lens — formatter', () => {
|
|
164
|
+
it('renders annotated source with kind badges', () => {
|
|
165
|
+
const src = `function C(){ return <div>{count()}</div> }`
|
|
166
|
+
const out = formatReactivityLens(src, analyzeReactivity(src))
|
|
167
|
+
expect(out).toContain('live')
|
|
168
|
+
expect(out).toContain('1 |')
|
|
169
|
+
})
|
|
170
|
+
})
|