@pyreon/compiler 0.24.5 → 0.24.6

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 (64) hide show
  1. package/package.json +11 -13
  2. package/src/defer-inline.ts +0 -686
  3. package/src/event-names.ts +0 -65
  4. package/src/index.ts +0 -61
  5. package/src/island-audit.ts +0 -675
  6. package/src/jsx.ts +0 -2792
  7. package/src/load-native.ts +0 -156
  8. package/src/lpih.ts +0 -270
  9. package/src/manifest.ts +0 -280
  10. package/src/project-scanner.ts +0 -214
  11. package/src/pyreon-intercept.ts +0 -1029
  12. package/src/react-intercept.ts +0 -1217
  13. package/src/reactivity-lens.ts +0 -190
  14. package/src/ssg-audit.ts +0 -513
  15. package/src/test-audit.ts +0 -435
  16. package/src/tests/backend-parity-r7-r9.test.ts +0 -91
  17. package/src/tests/backend-prop-derived-callback-divergence.test.ts +0 -74
  18. package/src/tests/collapse-bail-census.test.ts +0 -330
  19. package/src/tests/collapse-key-source-hygiene.test.ts +0 -88
  20. package/src/tests/component-child-no-wrap.test.ts +0 -204
  21. package/src/tests/defer-inline.test.ts +0 -387
  22. package/src/tests/depth-stress.test.ts +0 -16
  23. package/src/tests/detector-tag-consistency.test.ts +0 -101
  24. package/src/tests/dynamic-collapse-detector.test.ts +0 -164
  25. package/src/tests/dynamic-collapse-emit.test.ts +0 -192
  26. package/src/tests/dynamic-collapse-scan.test.ts +0 -111
  27. package/src/tests/element-valued-const-child.test.ts +0 -61
  28. package/src/tests/falsy-child-characterization.test.ts +0 -48
  29. package/src/tests/island-audit.test.ts +0 -524
  30. package/src/tests/jsx.test.ts +0 -2908
  31. package/src/tests/load-native.test.ts +0 -53
  32. package/src/tests/lpih.test.ts +0 -404
  33. package/src/tests/malformed-input-resilience.test.ts +0 -50
  34. package/src/tests/manifest-snapshot.test.ts +0 -55
  35. package/src/tests/native-equivalence.test.ts +0 -924
  36. package/src/tests/partial-collapse-detector.test.ts +0 -121
  37. package/src/tests/partial-collapse-emit.test.ts +0 -104
  38. package/src/tests/partial-collapse-robustness.test.ts +0 -53
  39. package/src/tests/project-scanner.test.ts +0 -269
  40. package/src/tests/prop-derived-shadow.test.ts +0 -96
  41. package/src/tests/pure-call-reactive-args.test.ts +0 -50
  42. package/src/tests/pyreon-intercept.test.ts +0 -816
  43. package/src/tests/r13-callback-stmt-equivalence.test.ts +0 -58
  44. package/src/tests/r14-ssr-mode-parity.test.ts +0 -51
  45. package/src/tests/r15-elemconst-propderived.test.ts +0 -47
  46. package/src/tests/r19-defer-inline-robust.test.ts +0 -54
  47. package/src/tests/r20-backend-equivalence-sweep.test.ts +0 -50
  48. package/src/tests/react-intercept.test.ts +0 -1104
  49. package/src/tests/reactivity-lens.test.ts +0 -170
  50. package/src/tests/rocketstyle-collapse.test.ts +0 -208
  51. package/src/tests/runtime/control-flow.test.ts +0 -159
  52. package/src/tests/runtime/dom-properties.test.ts +0 -138
  53. package/src/tests/runtime/events.test.ts +0 -301
  54. package/src/tests/runtime/harness.ts +0 -94
  55. package/src/tests/runtime/pr-352-shapes.test.ts +0 -121
  56. package/src/tests/runtime/reactive-props.test.ts +0 -81
  57. package/src/tests/runtime/signals.test.ts +0 -129
  58. package/src/tests/runtime/whitespace.test.ts +0 -106
  59. package/src/tests/signal-autocall-shadow.test.ts +0 -86
  60. package/src/tests/sourcemap-fidelity.test.ts +0 -77
  61. package/src/tests/ssg-audit.test.ts +0 -402
  62. package/src/tests/static-text-baking.test.ts +0 -64
  63. package/src/tests/test-audit.test.ts +0 -549
  64. package/src/tests/transform-state-isolation.test.ts +0 -49
@@ -1,816 +0,0 @@
1
- import { detectPyreonPatterns, hasPyreonPatterns } from '../pyreon-intercept'
2
-
3
- describe('detectPyreonPatterns', () => {
4
- describe('for-missing-by', () => {
5
- it('flags <For each={...}> without a `by` prop', () => {
6
- const code = `
7
- const items = signal([1, 2, 3])
8
- const UI = () => <For each={items()}>{(n) => <li>{n}</li>}</For>
9
- `
10
- const diags = detectPyreonPatterns(code)
11
- expect(diags).toHaveLength(1)
12
- expect(diags[0]!.code).toBe('for-missing-by')
13
- expect(diags[0]!.message).toContain('keyed reconciler')
14
- })
15
-
16
- it('does NOT flag <For> that carries a `by`', () => {
17
- const code = `
18
- const UI = () => <For each={items()} by={(i) => i.id}>{(i) => <li>{i.name}</li>}</For>
19
- `
20
- const diags = detectPyreonPatterns(code)
21
- expect(diags.filter((d) => d.code === 'for-missing-by')).toEqual([])
22
- })
23
- })
24
-
25
- describe('for-with-key', () => {
26
- it('flags <For key={...}> as the wrong keying prop', () => {
27
- const code = `
28
- const UI = () => <For each={items()} key={(i) => i.id}>{(i) => <li>{i.name}</li>}</For>
29
- `
30
- const diags = detectPyreonPatterns(code)
31
- const withKey = diags.find((d) => d.code === 'for-with-key')
32
- expect(withKey).toBeDefined()
33
- expect(withKey!.suggested).toContain('by={')
34
- // fixable stays `false` until a `migrate_pyreon` tool ships; see
35
- // the top-of-file note on detectPyreonPatterns.
36
- expect(withKey!.fixable).toBe(false)
37
- })
38
-
39
- it('does not ALSO flag for-missing-by when for-with-key fires', () => {
40
- // Otherwise consumers would see two entries for the same mistake.
41
- const code = `<For each={items} key={(i) => i.id}>{(i) => <li />}</For>`
42
- const diags = detectPyreonPatterns(code)
43
- expect(diags.filter((d) => d.code === 'for-missing-by')).toEqual([])
44
- expect(diags.filter((d) => d.code === 'for-with-key')).toHaveLength(1)
45
- })
46
- })
47
-
48
- describe('props-destructured', () => {
49
- it('flags destructured props on arrow component functions', () => {
50
- const code = `
51
- const Greeting = ({ name }: { name: string }) => <div>Hello {name}</div>
52
- `
53
- const diags = detectPyreonPatterns(code)
54
- expect(diags).toHaveLength(1)
55
- expect(diags[0]!.code).toBe('props-destructured')
56
- expect(diags[0]!.message).toContain('ONCE')
57
- })
58
-
59
- it('flags destructured props on function declarations that render JSX', () => {
60
- const code = `
61
- function Greeting({ name }: { name: string }) {
62
- return <div>Hello {name}</div>
63
- }
64
- `
65
- const diags = detectPyreonPatterns(code)
66
- expect(diags.filter((d) => d.code === 'props-destructured')).toHaveLength(1)
67
- })
68
-
69
- it('does NOT flag destructured params on non-component callbacks', () => {
70
- const code = `
71
- const handler = ({ value }: { value: string }) => console.log(value)
72
- const reduce = ({ a, b }: { a: number; b: number }) => a + b
73
- `
74
- const diags = detectPyreonPatterns(code)
75
- expect(diags.filter((d) => d.code === 'props-destructured')).toEqual([])
76
- })
77
-
78
- it('does NOT flag components that accept a single `props` parameter', () => {
79
- const code = `
80
- const Greeting = (props: { name: string }) => <div>Hello {props.name}</div>
81
- `
82
- const diags = detectPyreonPatterns(code)
83
- expect(diags.filter((d) => d.code === 'props-destructured')).toEqual([])
84
- })
85
- })
86
-
87
- describe('props-destructured-body', () => {
88
- const only = (code: string) =>
89
- detectPyreonPatterns(code).filter((d) => d.code === 'props-destructured-body')
90
-
91
- it('flags `const { x } = props` in an arrow component body', () => {
92
- const code = `
93
- const Greeting = (props: { name: string }) => {
94
- const { name } = props
95
- return <div>Hello {name}</div>
96
- }
97
- `
98
- const diags = only(code)
99
- expect(diags).toHaveLength(1)
100
- expect(diags[0]!.code).toBe('props-destructured-body')
101
- expect(diags[0]!.message).toContain('ONCE')
102
- expect(diags[0]!.fixable).toBe(false)
103
- })
104
-
105
- it('flags it in a function-declaration component', () => {
106
- const code = `
107
- function Greeting(props: { name: string }) {
108
- const { name } = props
109
- return <div>Hello {name}</div>
110
- }
111
- `
112
- expect(only(code)).toHaveLength(1)
113
- })
114
-
115
- it('flags let / var / alias / default / rest / nested shapes', () => {
116
- const code = `
117
- const A = (props: any) => { let { a } = props; return <i>{a}</i> }
118
- const B = (props: any) => { var { b } = props; return <i>{b}</i> }
119
- const C = (props: any) => { const { c: cc } = props; return <i>{cc}</i> }
120
- const D = (props: any) => { const { d = 1 } = props; return <i>{d}</i> }
121
- const E = (props: any) => { const { ...rest } = props; return <i>{rest.x}</i> }
122
- const F = (props: any) => { const { f: { g } } = props; return <i>{g}</i> }
123
- `
124
- expect(only(code)).toHaveLength(6)
125
- })
126
-
127
- it('flags a destructure nested inside a body-scope if-block (still synchronous)', () => {
128
- const code = `
129
- const Gate = (props: any) => {
130
- if (props.cond) { const { x } = props; return <i>{x}</i> }
131
- return <i />
132
- }
133
- `
134
- expect(only(code)).toHaveLength(1)
135
- })
136
-
137
- it('unwraps `as` / `satisfies` / `!` / parens on the initializer', () => {
138
- const code = `
139
- const A = (props: any) => { const { a } = props as Props; return <i>{a}</i> }
140
- const B = (props: any) => { const { b } = (props); return <i>{b}</i> }
141
- const C = (props: any) => { const { c } = props!; return <i>{c}</i> }
142
- const D = (props: any) => { const { d } = props satisfies Props; return <i>{d}</i> }
143
- `
144
- expect(only(code)).toHaveLength(4)
145
- })
146
-
147
- it('does NOT flag `const x = props` (alias, no destructure)', () => {
148
- const code = `
149
- const Greeting = (props: any) => { const p = props; return <div>{p.name}</div> }
150
- `
151
- expect(only(code)).toEqual([])
152
- })
153
-
154
- it('does NOT flag a destructure off a non-props identifier', () => {
155
- const code = `
156
- const Greeting = (props: any) => {
157
- const store = useStore()
158
- const { name } = store
159
- return <div>{name}{props.x}</div>
160
- }
161
- `
162
- expect(only(code)).toEqual([])
163
- })
164
-
165
- it('does NOT flag `const { x } = props.nested` (member, out of canonical scope by design)', () => {
166
- const code = `
167
- const Greeting = (props: any) => { const { x } = props.nested; return <div>{x}</div> }
168
- `
169
- expect(only(code)).toEqual([])
170
- })
171
-
172
- it('does NOT flag destructures inside nested functions (handler / effect / returned accessor)', () => {
173
- const code = `
174
- const Handler = (props: any) => {
175
- const onClick = () => { const { id } = props; doThing(id) }
176
- effect(() => { const { y } = props; track(y) })
177
- return <button onClick={onClick}>{() => { const { z } = props; return z }}</button>
178
- }
179
- `
180
- expect(only(code)).toEqual([])
181
- })
182
-
183
- it('does NOT flag the returned reactive-accessor fix shape', () => {
184
- const code = `
185
- const Greeting = (props: any) =>
186
- (() => { const { name } = props; return <div>Hello {name}</div> })
187
- `
188
- // The body is an arrow expression returning a nested accessor — the
189
- // destructure lives inside the nested fn, which re-reads props.
190
- expect(only(code)).toEqual([])
191
- })
192
-
193
- it('does NOT flag the parameter-destructure shape (props-destructured owns it)', () => {
194
- const code = `
195
- const Greeting = ({ name }: { name: string }) => <div>Hello {name}</div>
196
- `
197
- const diags = detectPyreonPatterns(code)
198
- expect(diags.filter((d) => d.code === 'props-destructured-body')).toEqual([])
199
- expect(diags.filter((d) => d.code === 'props-destructured')).toHaveLength(1)
200
- })
201
-
202
- it('does NOT flag non-component helpers that destructure an arg named props', () => {
203
- const code = `
204
- function mergeProps(props: any) { const { a, b } = props; return a + b }
205
- const reducer = (props: any) => { const { x } = props; return x }
206
- `
207
- expect(only(code)).toEqual([])
208
- })
209
-
210
- it('does NOT flag a lowercase JSX-returning function (not component-shaped)', () => {
211
- const code = `
212
- const renderRow = (props: any) => { const { cell } = props; return <td>{cell}</td> }
213
- `
214
- expect(only(code)).toEqual([])
215
- })
216
- })
217
-
218
- describe('process-dev-gate', () => {
219
- it('flags typeof process + NODE_ENV production gates', () => {
220
- const code = `
221
- if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
222
- console.warn('dev only')
223
- }
224
- `
225
- const diags = detectPyreonPatterns(code)
226
- expect(diags).toHaveLength(1)
227
- expect(diags[0]!.code).toBe('process-dev-gate')
228
- expect(diags[0]!.suggested).toContain('import.meta.env')
229
- expect(diags[0]!.fixable).toBe(false)
230
- })
231
-
232
- it('flags the reversed operand order', () => {
233
- const code = `
234
- const IS_DEV = process.env.NODE_ENV !== 'production' && typeof process !== 'undefined'
235
- `
236
- const diags = detectPyreonPatterns(code)
237
- expect(diags.filter((d) => d.code === 'process-dev-gate')).toHaveLength(1)
238
- })
239
-
240
- it('does NOT flag plain typeof process checks (server-side code is fine)', () => {
241
- const code = `
242
- if (typeof process !== 'undefined') {
243
- process.exit(0)
244
- }
245
- `
246
- const diags = detectPyreonPatterns(code)
247
- expect(diags).toEqual([])
248
- })
249
- })
250
-
251
- describe('empty-theme', () => {
252
- it('flags `.theme({})` as a no-op chain', () => {
253
- const code = `
254
- const Button = rocketstyle('button').attrs({ tag: 'button' }).theme({})
255
- `
256
- const diags = detectPyreonPatterns(code)
257
- expect(diags).toHaveLength(1)
258
- expect(diags[0]!.code).toBe('empty-theme')
259
- expect(diags[0]!.fixable).toBe(false)
260
- })
261
-
262
- it('does NOT flag `.theme(...)` with actual content', () => {
263
- const code = `
264
- const Button = rocketstyle('button').theme({ color: 'red' })
265
- `
266
- const diags = detectPyreonPatterns(code)
267
- expect(diags).toEqual([])
268
- })
269
- })
270
-
271
- describe('raw-add-event-listener / raw-remove-event-listener', () => {
272
- it('flags window.addEventListener with useEventListener suggestion', () => {
273
- const code = `
274
- const Panel = () => {
275
- window.addEventListener('resize', () => console.log('resize'))
276
- return <div />
277
- }
278
- `
279
- const diags = detectPyreonPatterns(code)
280
- const add = diags.find((d) => d.code === 'raw-add-event-listener')
281
- expect(add).toBeDefined()
282
- expect(add!.suggested).toContain('useEventListener')
283
- })
284
-
285
- it('flags document.removeEventListener', () => {
286
- const code = `
287
- document.removeEventListener('click', handler)
288
- `
289
- const diags = detectPyreonPatterns(code)
290
- expect(diags.find((d) => d.code === 'raw-remove-event-listener')).toBeDefined()
291
- })
292
-
293
- it('does NOT flag addEventListener on host-owned nested paths (e.g. editor.dom)', () => {
294
- // `view.dom.ownerDocument...` and similar framework-host chains
295
- // are intentional — the rule should only flag bare window/document
296
- // and obvious DOM-element identifiers.
297
- const code = `
298
- view.dom.ownerDocument.addEventListener('click', h)
299
- `
300
- const diags = detectPyreonPatterns(code)
301
- expect(diags).toEqual([])
302
- })
303
- })
304
-
305
- describe('date-math-random-id', () => {
306
- it('flags Date.now() + Math.random() ID patterns', () => {
307
- const code = `
308
- const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
309
- `
310
- const diags = detectPyreonPatterns(code)
311
- // The binary expression and any enclosing template expressions can both
312
- // match — dedupe by line to keep the contract observable.
313
- const atLine = [...new Set(diags.filter((d) => d.code === 'date-math-random-id').map((d) => d.line))]
314
- expect(atLine.length).toBeGreaterThanOrEqual(1)
315
- })
316
-
317
- it('flags template-literal variants', () => {
318
- const code = 'const id = `${Date.now()}-${Math.random()}`'
319
- const diags = detectPyreonPatterns(code)
320
- expect(diags.find((d) => d.code === 'date-math-random-id')).toBeDefined()
321
- })
322
-
323
- it('does NOT flag Date.now() alone', () => {
324
- const code = `const now = Date.now()`
325
- const diags = detectPyreonPatterns(code)
326
- expect(diags).toEqual([])
327
- })
328
- })
329
-
330
- describe('on-click-undefined', () => {
331
- it('flags explicit onClick={undefined}', () => {
332
- const code = `<button onClick={undefined}>Go</button>`
333
- const diags = detectPyreonPatterns(code)
334
- expect(diags).toHaveLength(1)
335
- expect(diags[0]!.code).toBe('on-click-undefined')
336
- expect(diags[0]!.fixable).toBe(false)
337
- })
338
-
339
- it('flags other on* handlers set to undefined', () => {
340
- const code = `<input onInput={undefined} />`
341
- const diags = detectPyreonPatterns(code)
342
- expect(diags.find((d) => d.code === 'on-click-undefined')).toBeDefined()
343
- })
344
-
345
- it('does NOT flag onClick={cond ? handler : undefined} (conditional is safe)', () => {
346
- const code = `<button onClick={condition ? handler : undefined}>Go</button>`
347
- const diags = detectPyreonPatterns(code)
348
- expect(diags.filter((d) => d.code === 'on-click-undefined')).toEqual([])
349
- })
350
- })
351
-
352
- describe('hasPyreonPatterns (regex pre-filter)', () => {
353
- it('returns true for every detected pattern', () => {
354
- const samples = [
355
- `<For each={x}>{(i) => <li />}</For>`,
356
- `typeof process !== 'undefined'`,
357
- `.theme({})`,
358
- `window.addEventListener('x', h)`,
359
- `const id = \`\${Date.now()}-\${Math.random()}\``,
360
- `<button onClick={undefined}>x</button>`,
361
- `const X = ({ name }) => <div>{name}</div>`,
362
- ]
363
- for (const s of samples) {
364
- expect(hasPyreonPatterns(s)).toBe(true)
365
- }
366
- })
367
-
368
- it('returns false for unrelated code', () => {
369
- expect(hasPyreonPatterns(`const x = 1 + 2`)).toBe(false)
370
- expect(hasPyreonPatterns(`console.log('ok')`)).toBe(false)
371
- })
372
- })
373
-
374
- describe('combined scenarios', () => {
375
- it('finds every distinct pattern in a multi-issue file', () => {
376
- const code = `
377
- const List = ({ items }) => <For each={items}>{(i) => <li />}</For>
378
- const flag = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
379
- window.addEventListener('resize', () => {})
380
- const Styled = rocketstyle('div').theme({})
381
- const id = Date.now() + Math.random()
382
- const Btn = () => <button onClick={undefined}>x</button>
383
- `
384
- const diags = detectPyreonPatterns(code)
385
- const codes = new Set(diags.map((d) => d.code))
386
- expect(codes.has('for-missing-by')).toBe(true)
387
- expect(codes.has('props-destructured')).toBe(true)
388
- expect(codes.has('process-dev-gate')).toBe(true)
389
- expect(codes.has('raw-add-event-listener')).toBe(true)
390
- expect(codes.has('empty-theme')).toBe(true)
391
- expect(codes.has('date-math-random-id')).toBe(true)
392
- expect(codes.has('on-click-undefined')).toBe(true)
393
- })
394
-
395
- it('returns an empty array for idiomatic Pyreon code', () => {
396
- const code = `
397
- import { signal, effect } from '@pyreon/reactivity'
398
- import { useEventListener } from '@pyreon/hooks'
399
-
400
- const Counter = (props: { initial?: number }) => {
401
- const count = signal(props.initial ?? 0)
402
- useEventListener(window, 'keydown', () => count.update((n) => n + 1))
403
- return (
404
- <For each={items()} by={(i) => i.id}>
405
- {(i) => <li>{i.name}: {count()}</li>}
406
- </For>
407
- )
408
- }
409
- `
410
- expect(detectPyreonPatterns(code)).toEqual([])
411
- })
412
- })
413
-
414
- describe('fixable contract — ALL Pyreon codes are fixable:false', () => {
415
- // Binding invariant: until a `migrate_pyreon` tool exists, every
416
- // Pyreon diagnostic must report `fixable: false`. Claiming a code
417
- // is auto-fixable while no migrator handles it would mislead
418
- // consumers building on the flag. Flip to `true` only when the
419
- // companion migrator lands in a subsequent PR.
420
- it('never emits a Pyreon diagnostic with fixable: true', () => {
421
- const snippets = [
422
- `<For each={x} key={(i) => i.id}>{(i) => <li />}</For>`, // for-with-key
423
- `<For each={x}>{(i) => <li />}</For>`, // for-missing-by
424
- `const X = ({ y }) => <div>{y}</div>`, // props-destructured
425
- `typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'`, // process-dev-gate
426
- `rocketstyle('div').theme({})`, // empty-theme
427
- `window.addEventListener('x', h)`, // raw-add-event-listener
428
- `document.removeEventListener('x', h)`, // raw-remove-event-listener
429
- 'const id = `${Date.now()}-${Math.random()}`', // date-math-random-id
430
- `<button onClick={undefined}>x</button>`, // on-click-undefined
431
- ]
432
- for (const code of snippets) {
433
- const diags = detectPyreonPatterns(code)
434
- for (const d of diags) {
435
- expect(d.fixable, `${d.code} must be fixable:false`).toBe(false)
436
- }
437
- }
438
- })
439
- })
440
-
441
- describe('diagnostic shape', () => {
442
- it('emits 1-based line + 0-based column with trimmed current/suggested', () => {
443
- const code = `\n\n <For each={items}>{(i) => <li />}</For>`
444
- const diags = detectPyreonPatterns(code)
445
- expect(diags[0]!.line).toBe(3)
446
- expect(diags[0]!.column).toBeGreaterThanOrEqual(0)
447
- expect(diags[0]!.current).not.toMatch(/^\s/)
448
- expect(diags[0]!.suggested).not.toMatch(/^\s/)
449
- })
450
-
451
- it('sorts diagnostics by line ascending', () => {
452
- const code = `
453
- const A = () => <button onClick={undefined} />
454
- const B = () => <For each={x} />
455
- const C = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
456
- `
457
- const diags = detectPyreonPatterns(code)
458
- const lines = diags.map((d) => d.line)
459
- expect(lines).toEqual([...lines].sort((a, b) => a - b))
460
- })
461
- })
462
-
463
- describe('signal-write-as-call', () => {
464
- it('flags `sig(value)` when sig was declared as a signal', () => {
465
- const code = `
466
- import { signal } from '@pyreon/reactivity'
467
- const count = signal(0)
468
- function inc() { count(count() + 1) }
469
- `
470
- const diags = detectPyreonPatterns(code)
471
- const hits = diags.filter((d) => d.code === 'signal-write-as-call')
472
- expect(hits).toHaveLength(1)
473
- expect(hits[0]!.message).toContain('signal()')
474
- expect(hits[0]!.suggested).toContain('count.set(')
475
- })
476
-
477
- it('does NOT flag `sig()` (zero args — that is the read API)', () => {
478
- const code = `
479
- const count = signal(0)
480
- function read() { return count() }
481
- `
482
- const diags = detectPyreonPatterns(code)
483
- expect(diags.filter((d) => d.code === 'signal-write-as-call')).toEqual([])
484
- })
485
-
486
- it('does NOT flag `sig.set(value)` (the proper write API)', () => {
487
- const code = `
488
- const count = signal(0)
489
- function set(v) { count.set(v) }
490
- `
491
- const diags = detectPyreonPatterns(code)
492
- expect(diags.filter((d) => d.code === 'signal-write-as-call')).toEqual([])
493
- })
494
-
495
- it('does NOT flag calls on identifiers that are not signal-bound', () => {
496
- const code = `
497
- const handler = (v) => console.log(v)
498
- handler(42)
499
- `
500
- const diags = detectPyreonPatterns(code)
501
- expect(diags.filter((d) => d.code === 'signal-write-as-call')).toEqual([])
502
- })
503
-
504
- it('flags `computed(value)` shape too — same misread of the API', () => {
505
- const code = `
506
- const doubled = computed(() => count() * 2)
507
- function bug() { doubled(99) }
508
- `
509
- const diags = detectPyreonPatterns(code)
510
- expect(diags.filter((d) => d.code === 'signal-write-as-call')).toHaveLength(1)
511
- })
512
- })
513
-
514
- describe('static-return-null-conditional', () => {
515
- it('flags `if (cond) return null` at the top of a component body', () => {
516
- const code = `
517
- function TabPanel({ id }) {
518
- if (!isActive(id)) return null
519
- return <div class="panel">content</div>
520
- }
521
- `
522
- const diags = detectPyreonPatterns(code)
523
- const hits = diags.filter((d) => d.code === 'static-return-null-conditional')
524
- expect(hits).toHaveLength(1)
525
- expect(hits[0]!.message).toContain('run ONCE')
526
- expect(hits[0]!.suggested).toContain('=> {')
527
- })
528
-
529
- it('flags the block-form `if (cond) { return null }` too', () => {
530
- const code = `
531
- function Modal() {
532
- if (!isOpen()) {
533
- return null
534
- }
535
- return <div class="modal">…</div>
536
- }
537
- `
538
- const diags = detectPyreonPatterns(code)
539
- expect(
540
- diags.filter((d) => d.code === 'static-return-null-conditional'),
541
- ).toHaveLength(1)
542
- })
543
-
544
- it('does NOT flag non-component functions returning null', () => {
545
- const code = `
546
- function findUser(id) {
547
- if (!id) return null
548
- return { id }
549
- }
550
- `
551
- const diags = detectPyreonPatterns(code)
552
- expect(diags.filter((d) => d.code === 'static-return-null-conditional')).toEqual([])
553
- })
554
-
555
- it('does NOT flag the recommended reactive-accessor pattern', () => {
556
- const code = `
557
- function TabPanel() {
558
- return (() => {
559
- if (!isActive()) return null
560
- return <div>content</div>
561
- })
562
- }
563
- `
564
- const diags = detectPyreonPatterns(code)
565
- // The inner arrow contains the if-return-null but is itself a
566
- // returned reactive accessor — not the "static-return-null" shape
567
- // because the OUTER component's body has no top-level if-return-null.
568
- expect(diags.filter((d) => d.code === 'static-return-null-conditional')).toEqual([])
569
- })
570
-
571
- it('only flags ONCE per component body even when chained', () => {
572
- const code = `
573
- function MultiGuard() {
574
- if (!a()) return null
575
- if (!b()) return null
576
- return <div>ok</div>
577
- }
578
- `
579
- const diags = detectPyreonPatterns(code)
580
- expect(
581
- diags.filter((d) => d.code === 'static-return-null-conditional'),
582
- ).toHaveLength(1)
583
- })
584
- })
585
-
586
- describe('as-unknown-as-vnodechild', () => {
587
- it('flags `expr as unknown as VNodeChild`', () => {
588
- const code = `
589
- function Wrapper() {
590
- return (<div>hi</div> as unknown as VNodeChild)
591
- }
592
- `
593
- const diags = detectPyreonPatterns(code)
594
- const hits = diags.filter((d) => d.code === 'as-unknown-as-vnodechild')
595
- expect(hits).toHaveLength(1)
596
- expect(hits[0]!.message).toContain('JSX.Element')
597
- })
598
-
599
- it('does NOT flag a single `as VNodeChild` (no double-cast)', () => {
600
- const code = `
601
- function Wrapper() {
602
- return (something as VNodeChild)
603
- }
604
- `
605
- const diags = detectPyreonPatterns(code)
606
- expect(diags.filter((d) => d.code === 'as-unknown-as-vnodechild')).toEqual([])
607
- })
608
-
609
- it('does NOT flag `as unknown as OtherType`', () => {
610
- const code = `
611
- const x = (foo as unknown as Whatever)
612
- `
613
- const diags = detectPyreonPatterns(code)
614
- expect(diags.filter((d) => d.code === 'as-unknown-as-vnodechild')).toEqual([])
615
- })
616
- })
617
-
618
- describe('island-never-with-registry-entry', () => {
619
- it('flags a hydrateIslands key matching a hydrate: "never" island declaration', () => {
620
- const code = `
621
- import { island } from '@pyreon/server'
622
- import { hydrateIslands } from '@pyreon/server/client'
623
- export const StaticBadge = island(() => import('./StaticBadge'), {
624
- name: 'StaticBadge',
625
- hydrate: 'never',
626
- })
627
- hydrateIslands({
628
- StaticBadge: () => import('./StaticBadge'),
629
- })
630
- `
631
- const diags = detectPyreonPatterns(code)
632
- const hits = diags.filter((d) => d.code === 'island-never-with-registry-entry')
633
- expect(hits).toHaveLength(1)
634
- expect(hits[0]!.message).toContain('StaticBadge')
635
- expect(hits[0]!.message).toContain("'never'")
636
- })
637
-
638
- it('does NOT flag a hydrateIslands key for a non-never island', () => {
639
- const code = `
640
- import { island } from '@pyreon/server'
641
- import { hydrateIslands } from '@pyreon/server/client'
642
- export const Counter = island(() => import('./Counter'), {
643
- name: 'Counter',
644
- hydrate: 'load',
645
- })
646
- hydrateIslands({
647
- Counter: () => import('./Counter'),
648
- })
649
- `
650
- const diags = detectPyreonPatterns(code)
651
- expect(diags.filter((d) => d.code === 'island-never-with-registry-entry')).toEqual([])
652
- })
653
-
654
- it('does NOT flag a never-island when no hydrateIslands call appears', () => {
655
- const code = `
656
- import { island } from '@pyreon/server'
657
- export const StaticBadge = island(() => import('./StaticBadge'), {
658
- name: 'StaticBadge',
659
- hydrate: 'never',
660
- })
661
- `
662
- const diags = detectPyreonPatterns(code)
663
- expect(diags.filter((d) => d.code === 'island-never-with-registry-entry')).toEqual([])
664
- })
665
-
666
- it('does NOT flag when hydrateIslands omits never-strategy islands (canonical)', () => {
667
- const code = `
668
- import { island } from '@pyreon/server'
669
- import { hydrateIslands } from '@pyreon/server/client'
670
- export const Counter = island(() => import('./Counter'), {
671
- name: 'Counter',
672
- hydrate: 'load',
673
- })
674
- export const StaticBadge = island(() => import('./StaticBadge'), {
675
- name: 'StaticBadge',
676
- hydrate: 'never',
677
- })
678
- hydrateIslands({
679
- Counter: () => import('./Counter'),
680
- // StaticBadge intentionally omitted
681
- })
682
- `
683
- const diags = detectPyreonPatterns(code)
684
- expect(diags.filter((d) => d.code === 'island-never-with-registry-entry')).toEqual([])
685
- })
686
-
687
- it('flags multiple never-islands registered in the same hydrateIslands call', () => {
688
- const code = `
689
- import { island } from '@pyreon/server'
690
- import { hydrateIslands } from '@pyreon/server/client'
691
- export const A = island(() => import('./A'), { name: 'A', hydrate: 'never' })
692
- export const B = island(() => import('./B'), { name: 'B', hydrate: 'never' })
693
- hydrateIslands({
694
- A: () => import('./A'),
695
- B: () => import('./B'),
696
- })
697
- `
698
- const diags = detectPyreonPatterns(code)
699
- const hits = diags.filter((d) => d.code === 'island-never-with-registry-entry')
700
- expect(hits).toHaveLength(2)
701
- expect(hits.map((h) => h.message).join('|')).toContain('"A"')
702
- expect(hits.map((h) => h.message).join('|')).toContain('"B"')
703
- })
704
-
705
- it('handles string-literal property keys in hydrateIslands', () => {
706
- const code = `
707
- import { island } from '@pyreon/server'
708
- import { hydrateIslands } from '@pyreon/server/client'
709
- export const X = island(() => import('./X'), { name: 'X', hydrate: 'never' })
710
- hydrateIslands({
711
- 'X': () => import('./X'),
712
- })
713
- `
714
- const diags = detectPyreonPatterns(code)
715
- expect(
716
- diags.filter((d) => d.code === 'island-never-with-registry-entry'),
717
- ).toHaveLength(1)
718
- })
719
-
720
- it('does NOT flag non-string `hydrate` values (variable indirection)', () => {
721
- const code = `
722
- import { island } from '@pyreon/server'
723
- import { hydrateIslands } from '@pyreon/server/client'
724
- const STRATEGY = 'never'
725
- export const X = island(() => import('./X'), { name: 'X', hydrate: STRATEGY })
726
- hydrateIslands({
727
- X: () => import('./X'),
728
- })
729
- `
730
- // The detector intentionally only recognizes string-literal hydrate
731
- // values — variable indirection takes us past the static-detection
732
- // surface into pyreon doctor --check-islands territory.
733
- const diags = detectPyreonPatterns(code)
734
- expect(diags.filter((d) => d.code === 'island-never-with-registry-entry')).toEqual([])
735
- })
736
-
737
- it('reports `fixable: false` (no auto-fix; manual edit required)', () => {
738
- const code = `
739
- import { island } from '@pyreon/server'
740
- import { hydrateIslands } from '@pyreon/server/client'
741
- export const X = island(() => import('./X'), { name: 'X', hydrate: 'never' })
742
- hydrateIslands({
743
- X: () => import('./X'),
744
- })
745
- `
746
- const diags = detectPyreonPatterns(code)
747
- const hit = diags.find((d) => d.code === 'island-never-with-registry-entry')
748
- expect(hit).toBeDefined()
749
- expect(hit!.fixable).toBe(false)
750
- })
751
-
752
- it('hasPyreonPatterns regex pre-filter recognizes the never-strategy form', () => {
753
- expect(
754
- hasPyreonPatterns(`island(() => import('./X'), { name: 'X', hydrate: 'never' })`),
755
- ).toBe(true)
756
- })
757
- })
758
-
759
- describe('query-options-as-function', () => {
760
- it('flags useQuery with an object-literal first arg', () => {
761
- const code = `const q = useQuery({ queryKey: ['user', id()], queryFn: fetchUser })`
762
- const diags = detectPyreonPatterns(code)
763
- const d = diags.find((x) => x.code === 'query-options-as-function')
764
- expect(d).toBeDefined()
765
- expect(d!.fixable).toBe(false)
766
- expect(d!.message).toContain('FUNCTION')
767
- expect(d!.suggested).toBe(
768
- "useQuery(() => ({ queryKey: ['user', id()], queryFn: fetchUser }))",
769
- )
770
- })
771
-
772
- it('flags useInfiniteQuery / useQueries / useSuspenseQuery too', () => {
773
- for (const hook of [
774
- 'useInfiniteQuery',
775
- 'useQueries',
776
- 'useSuspenseQuery',
777
- ]) {
778
- const diags = detectPyreonPatterns(`const r = ${hook}({ queryKey: ['k'] })`)
779
- expect(
780
- diags.some((d) => d.code === 'query-options-as-function'),
781
- ).toBe(true)
782
- }
783
- })
784
-
785
- it('does NOT flag the correct function form', () => {
786
- const code = `const q = useQuery(() => ({ queryKey: ['user', id()], queryFn: fetchUser }))`
787
- const diags = detectPyreonPatterns(code)
788
- expect(
789
- diags.some((d) => d.code === 'query-options-as-function'),
790
- ).toBe(false)
791
- })
792
-
793
- it('does NOT flag useMutation (options are a plain object by design)', () => {
794
- const code = `const m = useMutation({ mutationFn: save, onSuccess })`
795
- const diags = detectPyreonPatterns(code)
796
- expect(
797
- diags.some((d) => d.code === 'query-options-as-function'),
798
- ).toBe(false)
799
- })
800
-
801
- it('does NOT flag an identifier / call options arg (unprovable)', () => {
802
- const code = `const a = useQuery(opts); const b = useQuery(makeOpts())`
803
- const diags = detectPyreonPatterns(code)
804
- expect(
805
- diags.some((d) => d.code === 'query-options-as-function'),
806
- ).toBe(false)
807
- })
808
-
809
- it('hasPyreonPatterns pre-filter recognizes the object-literal form', () => {
810
- expect(hasPyreonPatterns(`useQuery({ queryKey: ['k'] })`)).toBe(true)
811
- expect(hasPyreonPatterns(`useQuery(() => ({ queryKey: ['k'] }))`)).toBe(
812
- false,
813
- )
814
- })
815
- })
816
- })