@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.
@@ -121,22 +121,10 @@ export function App() {
121
121
  }
122
122
  `
123
123
  const result = transformDeferInline(input, 'app.tsx')
124
- // No transform fires (multi-child shape doesn't match the inline-eligible
125
- // single-component-child pattern). No warning eitherv1 just leaves it
126
- // alone; downstream Defer's runtime behaviour handles the malformed shape.
127
- expect(result.changed).toBe(false)
128
- })
129
-
130
- test('skips Defer whose child has props (multi-prop closure capture)', () => {
131
- const input = `
132
- import { Defer } from '@pyreon/core'
133
- import { Modal } from './Modal'
134
- export function App() {
135
- return <Defer when={() => true}><Modal title="hi" /></Defer>
136
- }
137
- `
138
- const result = transformDeferInline(input, 'app.tsx')
124
+ // v2 now emits a `multiple-children` warning so the author knows to use
125
+ // the explicit `chunk` form. v1 was silentthat was a footgun.
139
126
  expect(result.changed).toBe(false)
127
+ expect(result.warnings[0]?.code).toBe('defer-inline/multiple-children')
140
128
  })
141
129
 
142
130
  test('fast-path: no Defer in source returns unchanged', () => {
@@ -157,7 +145,74 @@ export const count = signal(0)
157
145
  expect(result.code).toBe(input)
158
146
  })
159
147
 
160
- test('skips renamed imports { Modal as M } not handled in v1', () => {
148
+ // v1 bailed on `{ Modal as M }`. v2 handles renamed imports the
149
+ // local name in JSX is `M`, but the chunk extracts `__m.Modal` (the
150
+ // original exported name). See the positive test in the v2 section.
151
+ })
152
+
153
+ describe('transformDeferInline — v2 capabilities', () => {
154
+ test('preserves props on inline child', () => {
155
+ const input = `
156
+ import { Defer } from '@pyreon/core'
157
+ import { Modal } from './Modal'
158
+ export function App() {
159
+ return <Defer when={() => true}><Modal title="Confirm" size="md" /></Defer>
160
+ }
161
+ `
162
+ const result = transformDeferInline(input, 'app.tsx')
163
+ expect(result.changed).toBe(true)
164
+ expect(result.code).not.toContain("import { Modal }")
165
+ // Props pass through verbatim. Only the component name is replaced.
166
+ expect(result.code).toContain('{(__C) => <__C title="Confirm" size="md" />}')
167
+ })
168
+
169
+ test('preserves nested children on inline child (non-self-closing)', () => {
170
+ const input = `
171
+ import { Defer } from '@pyreon/core'
172
+ import { Modal } from './Modal'
173
+ export function App() {
174
+ return (
175
+ <Defer when={() => true}>
176
+ <Modal title="Hello">
177
+ <p>nested content</p>
178
+ </Modal>
179
+ </Defer>
180
+ )
181
+ }
182
+ `
183
+ const result = transformDeferInline(input, 'app.tsx')
184
+ expect(result.changed).toBe(true)
185
+ // Both opening AND closing tag names replaced with __C; nested JSX intact.
186
+ expect(result.code).toContain('<__C title="Hello">')
187
+ expect(result.code).toContain('</__C>')
188
+ expect(result.code).toContain('<p>nested content</p>')
189
+ })
190
+
191
+ test('captures closure variables via render-prop scope (signal in handler)', () => {
192
+ const input = `
193
+ import { Defer } from '@pyreon/core'
194
+ import { signal } from '@pyreon/reactivity'
195
+ import { Modal } from './Modal'
196
+
197
+ export function App() {
198
+ const open = signal(false)
199
+ const count = signal(0)
200
+ return (
201
+ <Defer when={open}>
202
+ <Modal count={count} onClose={() => open.set(false)} />
203
+ </Defer>
204
+ )
205
+ }
206
+ `
207
+ const result = transformDeferInline(input, 'app.tsx')
208
+ expect(result.changed).toBe(true)
209
+ // The render-prop arrow naturally captures `count` + `open.set` from
210
+ // the App function's scope — no closure-tracking pass needed, JS
211
+ // lexical scope just works.
212
+ expect(result.code).toContain('<__C count={count} onClose={() => open.set(false)} />')
213
+ })
214
+
215
+ test('handles renamed imports — { Modal as M }', () => {
161
216
  const input = `
162
217
  import { Defer } from '@pyreon/core'
163
218
  import { Modal as M } from './Modal'
@@ -166,11 +221,32 @@ export function App() {
166
221
  }
167
222
  `
168
223
  const result = transformDeferInline(input, 'app.tsx')
169
- expect(result.changed).toBe(false)
170
- // Renamed-import case is not yet supported falls through to the
171
- // import-not-found warning (no specifier whose `local.name === 'M'`
172
- // AND `imported.name === local.name` matches).
173
- expect(result.warnings[0]?.code).toBe('defer-inline/import-not-found')
224
+ expect(result.changed).toBe(true)
225
+ // Local name `M` is used at the JSX site, but the chunk extracts
226
+ // `__m.Modal` (the original exported name from './Modal').
227
+ expect(result.code).toContain(`chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}`)
228
+ expect(result.code).not.toContain("import { Modal as M }")
229
+ expect(result.code).toContain('{(__C) => <__C />}')
230
+ })
231
+
232
+ test('multi-specifier import: drops only the Defer-targeted binding', () => {
233
+ const input = `
234
+ import { Defer } from '@pyreon/core'
235
+ import { Modal, OtherThing } from './shared'
236
+ export function App() {
237
+ const use = OtherThing
238
+ return <Defer when={() => true}><Modal /></Defer>
239
+ }
240
+ `
241
+ const result = transformDeferInline(input, 'app.tsx')
242
+ expect(result.changed).toBe(true)
243
+ // OtherThing is referenced elsewhere — its import must survive.
244
+ expect(result.code).toContain("OtherThing")
245
+ expect(result.code).toMatch(/import \{\s*OtherThing\s*\} from '\.\/shared'/)
246
+ // Modal binding is gone from the import declaration.
247
+ expect(result.code).not.toMatch(/import \{[^}]*\bModal\b[^}]*\}/)
248
+ // ...but the dynamic chunk pulls Modal from './shared' (same source).
249
+ expect(result.code).toContain(`chunk={() => import('./shared').then((__m) => ({ default: __m.Modal }))}`)
174
250
  })
175
251
  })
176
252
 
@@ -197,3 +273,115 @@ export function App() {
197
273
  expect(result.code).toContain(`chunk={() => import('./Comments').then((__m) => ({ default: __m.Comments }))}`)
198
274
  })
199
275
  })
276
+
277
+ // Gap 4 from the v2 follow-up roadmap. Namespace imports were the last
278
+ // inline-Defer shape that fell back to the explicit form — closing this
279
+ // gap means EVERY common import shape is supported by the inline form.
280
+ describe('transformDeferInline — namespace imports (v3)', () => {
281
+ test('rewrites <M.Modal /> with namespace import — chunk extracts __m.Modal', () => {
282
+ const input = `
283
+ import { Defer } from '@pyreon/core'
284
+ import * as M from './Modal'
285
+ export function App() {
286
+ return <Defer when={() => true}><M.Modal /></Defer>
287
+ }
288
+ `
289
+ const result = transformDeferInline(input, 'app.tsx')
290
+ expect(result.changed).toBe(true)
291
+ expect(result.code).toContain(`chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}`)
292
+ // M.Modal in the JSX replaced with __C (the whole member expression
293
+ // is the "name" range that gets substituted).
294
+ expect(result.code).toContain('{(__C) => <__C />}')
295
+ expect(result.code).not.toContain('import * as M from')
296
+ })
297
+
298
+ test('rewrites with props on member-expression child', () => {
299
+ const input = `
300
+ import { Defer } from '@pyreon/core'
301
+ import * as M from './Modal'
302
+ export function App() {
303
+ return <Defer when={() => true}><M.Modal title="hi" /></Defer>
304
+ }
305
+ `
306
+ const result = transformDeferInline(input, 'app.tsx')
307
+ expect(result.changed).toBe(true)
308
+ expect(result.code).toContain('{(__C) => <__C title="hi" />}')
309
+ })
310
+
311
+ test('non-self-closing member-expression child preserves opening + closing replacement', () => {
312
+ const input = `
313
+ import { Defer } from '@pyreon/core'
314
+ import * as M from './Modal'
315
+ export function App() {
316
+ return <Defer when={() => true}><M.Modal title="hi"><span>body</span></M.Modal></Defer>
317
+ }
318
+ `
319
+ const result = transformDeferInline(input, 'app.tsx')
320
+ expect(result.changed).toBe(true)
321
+ expect(result.code).toContain('{(__C) => <__C title="hi"><span>body</span></__C>}')
322
+ })
323
+
324
+ test('bails when namespace is referenced elsewhere in the file', () => {
325
+ // `M` is used for multiple components. Removing the static import
326
+ // would break the other usage AND the dynamic import becomes a
327
+ // no-op (Rolldown bundles the module statically when ANY part is
328
+ // referenced).
329
+ const input = `
330
+ import { Defer } from '@pyreon/core'
331
+ import * as M from './Modal'
332
+ export function App() {
333
+ void M.Settings
334
+ return <Defer when={() => true}><M.Modal /></Defer>
335
+ }
336
+ `
337
+ const result = transformDeferInline(input, 'app.tsx')
338
+ expect(result.changed).toBe(false)
339
+ expect(result.warnings).toHaveLength(1)
340
+ expect(result.warnings[0]!.code).toBe('defer-inline/import-used-elsewhere')
341
+ })
342
+
343
+ test('bails on deeper member expression — <M.Sub.X /> not supported', () => {
344
+ const input = `
345
+ import { Defer } from '@pyreon/core'
346
+ import * as M from './Modal'
347
+ export function App() {
348
+ return <Defer when={() => true}><M.Sub.Modal /></Defer>
349
+ }
350
+ `
351
+ const result = transformDeferInline(input, 'app.tsx')
352
+ // analyzeChildElement returns null for non-depth-1 member
353
+ // expressions → no match in findDeferMatches → no warning. The
354
+ // Defer is left alone; runtime errors with "missing chunk".
355
+ expect(result.changed).toBe(false)
356
+ })
357
+
358
+ test('bails when member property is lowercase — <M.helper /> is not a component', () => {
359
+ const input = `
360
+ import { Defer } from '@pyreon/core'
361
+ import * as M from './lib'
362
+ export function App() {
363
+ return <Defer when={() => true}><M.helper /></Defer>
364
+ }
365
+ `
366
+ const result = transformDeferInline(input, 'app.tsx')
367
+ expect(result.changed).toBe(false)
368
+ })
369
+
370
+ test('bails when member expression but import is default (not namespace)', () => {
371
+ // `import M from './X'` (default) followed by `<M.Modal />` is a
372
+ // member access on the default-exported component itself, not a
373
+ // namespace lookup. Different semantics; out of scope. Compiler
374
+ // emits `unsupported-import-shape` so the author knows why.
375
+ const input = `
376
+ import { Defer } from '@pyreon/core'
377
+ import M from './Modal'
378
+ export function App() {
379
+ return <Defer when={() => true}><M.Modal /></Defer>
380
+ }
381
+ `
382
+ const result = transformDeferInline(input, 'app.tsx')
383
+ expect(result.changed).toBe(false)
384
+ expect(result.warnings).toHaveLength(1)
385
+ expect(result.warnings[0]!.code).toBe('defer-inline/unsupported-import-shape')
386
+ })
387
+ })
@@ -30,6 +30,7 @@ const COMPILER_CODES = [
30
30
  'for-missing-by',
31
31
  'for-with-key',
32
32
  'props-destructured',
33
+ 'props-destructured-body',
33
34
  'process-dev-gate',
34
35
  'empty-theme',
35
36
  'raw-add-event-listener',
@@ -40,6 +41,7 @@ const COMPILER_CODES = [
40
41
  'static-return-null-conditional',
41
42
  'as-unknown-as-vnodechild',
42
43
  'island-never-with-registry-entry',
44
+ 'query-options-as-function',
43
45
  ] as const
44
46
  type CompilerCode = (typeof COMPILER_CODES)[number]
45
47
 
@@ -0,0 +1,55 @@
1
+ import {
2
+ renderApiReferenceEntries,
3
+ renderLlmsFullSection,
4
+ renderLlmsTxtLine,
5
+ } from '@pyreon/manifest'
6
+ import manifest from '../manifest'
7
+
8
+ describe('gen-docs — compiler snapshot', () => {
9
+ it('renders a llms.txt bullet starting with the package prefix', () => {
10
+ const line = renderLlmsTxtLine(manifest)
11
+ expect(line.startsWith('- @pyreon/compiler —')).toBe(true)
12
+ })
13
+
14
+ it('renders a llms-full.txt section with the right header', () => {
15
+ const section = renderLlmsFullSection(manifest)
16
+ expect(section.startsWith('## @pyreon/compiler —')).toBe(true)
17
+ expect(section).toContain('```typescript')
18
+ })
19
+
20
+ it('renders MCP api-reference entries for every api[] item', () => {
21
+ const record = renderApiReferenceEntries(manifest)
22
+ expect(Object.keys(record).sort()).toEqual([
23
+ 'compiler/analyzeReactivity',
24
+ 'compiler/auditIslands',
25
+ 'compiler/auditSsg',
26
+ 'compiler/auditTestEnvironment',
27
+ 'compiler/detectPyreonPatterns',
28
+ 'compiler/detectReactPatterns',
29
+ 'compiler/diagnoseError',
30
+ 'compiler/formatIslandAudit',
31
+ 'compiler/formatReactivityLens',
32
+ 'compiler/formatSsgAudit',
33
+ 'compiler/formatTestAudit',
34
+ 'compiler/generateContext',
35
+ 'compiler/hasPyreonPatterns',
36
+ 'compiler/hasReactPatterns',
37
+ 'compiler/migrateReactCode',
38
+ 'compiler/transformDeferInline',
39
+ 'compiler/transformJSX',
40
+ 'compiler/transformJSX_JS',
41
+ ])
42
+ })
43
+
44
+ it('flags the experimental Reactivity-Lens entries', () => {
45
+ const r = renderApiReferenceEntries(manifest)
46
+ expect(r['compiler/analyzeReactivity']?.notes).toContain('[EXPERIMENTAL]')
47
+ expect(r['compiler/formatReactivityLens']?.notes).toContain('[EXPERIMENTAL]')
48
+ })
49
+
50
+ it('carries the foot-gun catalog into MCP mistakes for flagship APIs', () => {
51
+ const r = renderApiReferenceEntries(manifest)
52
+ expect(r['compiler/transformJSX']?.mistakes).toBeTruthy()
53
+ expect(r['compiler/detectPyreonPatterns']?.mistakes).toContain('fixable')
54
+ })
55
+ })
@@ -5,11 +5,23 @@
5
5
  * between the two backends.
6
6
  */
7
7
  import { transformJSX_JS } from '../jsx'
8
+ import type { ReactivitySpan } from '../jsx'
8
9
 
9
10
  // Load native if available
10
- let nativeTransform: ((code: string, filename: string, ssr: boolean, knownSignals: string[] | null) => {
11
- code: string; usesTemplates?: boolean | null; warnings: Array<{ message: string; line: number; column: number; code: string }>
12
- }) | null = null
11
+ let nativeTransform:
12
+ | ((
13
+ code: string,
14
+ filename: string,
15
+ ssr: boolean,
16
+ knownSignals: string[] | null,
17
+ reactivityLens?: boolean,
18
+ ) => {
19
+ code: string
20
+ usesTemplates?: boolean | null
21
+ warnings: Array<{ message: string; line: number; column: number; code: string }>
22
+ reactivityLens?: ReactivitySpan[] | null
23
+ })
24
+ | null = null
13
25
 
14
26
  try {
15
27
  const path = require('node:path')
@@ -39,6 +51,50 @@ function compareSsr(input: string) {
39
51
  expect(rs.code).toBe(js.code)
40
52
  }
41
53
 
54
+ // Reactivity-lens cross-backend gate (Phase 3). Asserts the Rust binary
55
+ // emits the SAME sidecar the JS oracle does. Two contracts:
56
+ // 1. ADDITIVE — `code` is byte-identical with the lens collected vs
57
+ // not, on BOTH backends (the option never affects codegen).
58
+ // 2. PARITY — the SET of recorded spans is identical JS↔Rust.
59
+ // Spans are compared order-independently: the LSP consumer sorts before
60
+ // rendering, so traversal order is NOT part of the contract — the SET of
61
+ // codegen decisions is. Sorting by (start,end,kind,detail) makes a
62
+ // missing/extra/wrong span fail loudly while ignoring walk order.
63
+ function canon(spans: ReactivitySpan[] | null | undefined): string[] {
64
+ return (spans ?? [])
65
+ .map(
66
+ (s) =>
67
+ `${s.start}|${s.end}|${s.line}|${s.column}|${s.endLine}|${s.endColumn}|${s.kind}|${s.detail}`,
68
+ )
69
+ .sort()
70
+ }
71
+
72
+ function compareLens(input: string, filename = 'test.tsx') {
73
+ const jsOff = transformJSX_JS(input, filename)
74
+ const jsOn = transformJSX_JS(input, filename, { reactivityLens: true })
75
+ const rsOff = nativeTransform!(input, filename, false, null, false)
76
+ const rsOn = nativeTransform!(input, filename, false, null, true)
77
+
78
+ // (1) additive — collecting the lens never changes emitted code, on
79
+ // either backend, and both backends still agree on that code.
80
+ expect(jsOn.code).toBe(jsOff.code)
81
+ expect(rsOn.code).toBe(rsOff.code)
82
+ expect(rsOn.code).toBe(jsOn.code)
83
+
84
+ // The opt-out path must NOT carry the sidecar (parity with JS, which
85
+ // omits the field entirely when not collecting).
86
+ expect(rsOff.reactivityLens == null).toBe(true)
87
+ expect(jsOff.reactivityLens == null).toBe(true)
88
+
89
+ // (2) parity — identical SET of spans.
90
+ const j = canon(jsOn.reactivityLens)
91
+ const r = canon(rsOn.reactivityLens)
92
+ expect(r).toEqual(j)
93
+ // Guard against the degenerate "both empty → trivially equal" pass:
94
+ // every fixture below is chosen to produce ≥1 span.
95
+ expect(j.length).toBeGreaterThan(0)
96
+ }
97
+
42
98
  // ─── Cross-backend equivalence ──────────────────────────────────────────────
43
99
 
44
100
  describeNative('Native vs JS equivalence — basic', () => {
@@ -729,3 +785,48 @@ describeNative('Native vs JS equivalence — DOM properties', () => {
729
785
  compare('<div><input title={x()} /></div>')
730
786
  })
731
787
  })
788
+
789
+ // ─── Reactivity-lens parity (Phase 3) ───────────────────────────────────────
790
+ // The Rust binary must emit the SAME sidecar as the JS oracle so the
791
+ // ~80% of users on the native path get the Lens too. Each fixture is
792
+ // chosen to exercise one of the five structural kinds; `compareLens`
793
+ // also asserts the additive guarantee (codegen byte-identical with the
794
+ // option on vs off, both backends) so this block doubles as the native
795
+ // regression guard for "the lens option must never affect output".
796
+ //
797
+ // Bisect-verified: removing ANY of the 6 `ctx.lens(...)` calls in
798
+ // native/src/lib.rs fails the matching fixture below with an
799
+ // array-length / element mismatch in `compareLens`'s parity assertion
800
+ // (e.g. dropping the `reactive-prop` call → `<Comp value={x()} />` fails
801
+ // `expect(r).toEqual(j)` because the Rust set is missing that span);
802
+ // restored → 9/9 pass.
803
+ describeNative('Reactivity-lens — JS↔Rust span parity', () => {
804
+ test('reactive text child (_bindText)', () =>
805
+ compareLens('<div>{count()}</div>'))
806
+
807
+ test('reactive accessor text child (() => …)', () =>
808
+ compareLens('<div>{() => count()}</div>'))
809
+
810
+ test('static-text child (baked once — the high-precision negative)', () =>
811
+ compareLens('<div>{someConst}</div>'))
812
+
813
+ test('reactive-prop on a component (_rp(() => …))', () =>
814
+ compareLens('<Comp value={count()} />'))
815
+
816
+ test('reactive-attr on a DOM element (live binding)', () =>
817
+ compareLens('<div><span title={count()}>hi</span></div>'))
818
+
819
+ test('hoisted-static (module-scope hoist)', () =>
820
+ compareLens('<Comp>{<b class="x">hi</b>}</Comp>'))
821
+
822
+ test('mixed: reactive + static + prop in one tree', () =>
823
+ compareLens(
824
+ '<section><Comp value={count()} /><p>{count()}</p><p>{label}</p></section>',
825
+ ))
826
+
827
+ test('multi-line source — line/column parity across newlines', () =>
828
+ compareLens('<div>\n {count()}\n <span title={other()}>\n z</span>\n</div>'))
829
+
830
+ test('signal auto-call shape — declared signal, bare {count} → reactive', () =>
831
+ compareLens('const count = signal(0); const App = () => <div>{count}</div>', 'auto.tsx'))
832
+ })
@@ -84,6 +84,137 @@ describe('detectPyreonPatterns', () => {
84
84
  })
85
85
  })
86
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
+
87
218
  describe('process-dev-gate', () => {
88
219
  it('flags typeof process + NODE_ENV production gates', () => {
89
220
  const code = `
@@ -624,4 +755,62 @@ describe('detectPyreonPatterns', () => {
624
755
  ).toBe(true)
625
756
  })
626
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
+ })
627
816
  })